プログラミングの「抽象化」ってどういう意味で、なぜ必要なのか

<追記>いろいろ反応あってたしかになーって思いましたが、ここで説明されてるのは「汎化」とか「パラメタライズ」としたほうが正しいですね。抽象化というと、一塊の手続きをブラックボックスにして、実装を隠蔽する面のほうが正解に近いです。でもまあそこを差し引いて読んでいただければ、それなりに有用ではある記事だと思うので、このまま残しておきます</追記>

プログラミングに限らない話かもしれませんが、ふだんの生活で触れないような概念というのは、一度わかってしまえば便利なんだけど、どうしてもとらえどころがない、というようなことが多いと思います。プログラミングにもそういう概念はたくさんあって、わたしのような凡人は新しい概念にぶち当たるたびに苦労しています。今日はそんな中で「抽象化」という言葉について、「昔の自分にこうやって説明してあげたかったな〜」という説明をします。

プログラミングを学んでいく中で、「とりあえず処理が書ける」というところから一歩進んで「良い設計ってなんだろう」ということを考え始めたとき、いきなり「抽象化」という言葉が出てきて面食らったことはありませんか?わたしはあります。「抽象化ってこういうことだよ」という説明のないまま、「抽象化」の事例をたくさん見ることで「うーん抽象化ってなんとなくこんな雰囲気……」みたいな感じでお茶を濁す感じになる事例というのはけっこう多い気がしています。

そんなふわっとした「抽象化」という言葉ですが、わからないときは逆の言葉の意味を考えて見るといいでしょう。「抽象的」の反対は「具体的」ですね。つまり、「抽象化」というのは「具体的じゃない状態にする」ということだと思えばいいでしょう。まだちんぷんかんぷんですね。例で考えてみましょう

たとえば、「user_id:1のユーザーの所持金額のうち50Gを、user_id:2のユーザーに移行する処理を作ってくれ」という指示が出ているとします。これを愚直に実装すると、以下のような感じになるでしょうか(トランザクションとかそういう話は今回の話に直接関係ないので無視します)。

def transfer_gold
  # user_1 から50G引いて
  user_1 = User.find(1)
  user_1.gold -= 50
  user_1.save!

  # user_2に50G足す
  user_2 = User.find(2)
  user_2.gold += 50
  user_2.save!
end

このコードを、「具体的じゃなくして」行ってみましょう。

今はuser_id:1のユーザーとuser_id:2のユーザーという「具体的な」ユーザーを直接コードで書いていますね。ここの「具体性」をなくしてみましょう。

def transfer_gold(from_user_id, to_user_id)
  user_a = User.find(from_user_id)
  user_a.gold -= 50
  user_a.save!
  
  user_b = User.find(to_user_id)
  user_b.gold += 50
  user_b.save!
end

はい、ユーザーidを引数で与えるようにしました。これで、コードからは「具体的なユーザーid」を排除して、ちょっと「具体的じゃないコード」になりましたね。

もうちょっと具体性を排除していきましょう。今は50Gという具体的な金額がまだコード上に残っています。これも引数で渡してあげましょう。

def transfer_gold(amount, from_user_id, to_user_id)
  user_a = User.find(from_user_id)
  user_a.gold -= amount
  user_a.save!
  
  user_b = User.find(to_user_id)
  user_b.gold += amount
  user_b.save!
end

はい。これでコードからはさらに「具体性」がなくなりました。こんな感じで、「具体的な値」とかをコードから排除していってコードの具体性を減らす行為全般を「抽象化」と呼ぶと考えて良いでしょう。

ところで、具体的な値をコードから追い出したことによって、なにが起こったでしょうか?具体的な値がベタ書きされていたときと違って、コードが「いろんな値に対応するようになった」ということが言えるでしょう。引数に10Gとuser_id:3とuser_id:4を渡せば、user_id:3からuser_id:4に10G移行することもできるし、20Gとuser_id:5とuser_id:6を渡せばuser_id:5からuser_id:6に20G移行することもできます。

さて、いいことづくめのように見える抽象化ですが、今度は「そのコードを使う側」の視点から見てみましょう。もしプログラムに具体的な値がベタ書きされていた場合、使う側はなにも考えずにそのコードを実行すれば良いだけです。一方、抽象化されたコードの場合、使う側が「どのユーザからどのユーザにいくら渡すのか」ということを判断しなければなりません。

そこに注目すると、抽象化というのは、具体的な値としてなにを使うのかという「判断の責任」を「そのコードを使う側」に押し付けることでもあることが見えて来ると思います。

言い換えます。抽象化されていない具体的なコードでは、メソッドの側に「具体的な値はこれとこれ!」ということが書いてあるので、呼ぶだけで具体的に意味のある処理が実現されます。一方、抽象化されたメソッドでは、メソッドを呼ぶ側がそこに「具体的な値」を与えることではじめて具体的で意味のある処理が実現されます。

まとめると、抽象化によって、使うときにいろんな値を突っ込んで使うことができ、柔軟になるというメリットを得る一方で、使うときにその都度使う側が「どんな値が適切なのか」ということを判断する責任を負うというデメリットも負うことになるわけです。

note:ところで、さきほど「具体的な値」「とか」 をコードから排除する、と書きましたが、とか、というからには、値以外のものも抽象化することが可能です。抽象化のレベルが上がってくると、値だけではなく、たとえば計算内容そのものを抽象化したり(高階関数やストラテジーパターンなどがそれを実現します)、型を抽象化したり、というパターンも出てきます。しかし、どんな抽象化も、「具体的な<なにか>」をコードから排除して、そこを「入れ替え可能なもの」にするという点では共通しています。「抽象化」という言葉がでてきたときには、「ここではどんな具体的なものをコードから引っぺがしているのだろう」と考えてみると、すっきりと理解できることが多いでしょう。

さて、以上の議論を踏まえた上で、「世界一抽象化されたコード」というのを今から書いてみましょう。一瞬でかけます。

# nop

上にあげたものがそうです。つまり、「なにもしない」というコードこそが、世界一抽象化されたコードです。冗談を言っているわけではありません。抽象化とは「具体的な値や処理などをなくして、使う側が必要なそれを選べるようにする」ものでした。では「はい、まったくなにも書かれてません。ここに必要な処理を書いていってください」というのが、究極に抽象化されたコードになるのはなにも変なことではありません。こんな落語みたいなことを言ってなにが主張したいのかというと、それは「抽象化すればいいってもんじゃない」ってことが言いたいのです。

抽象化はあくまで「具体的な判断を遅らせることで柔軟性を得る」ためのものです。コードが具体的な処理を行うためには、必ずどこかでその「具体的な判断」をしなければなりません。冒頭の例ならば、必ずどこかで引数に具体的なuser_idや金額を与えなければならないわけです。

そうである以上、プログラムの設計では「どこを抽象化するのか」「どこまで抽象化するのか」という判断が必要になってきます。しかし、「抽象化」のメリットデメリットを理解した今なら、その判断の一助になるものさしを持っています。それは、「具体的な判断は誰がするべきなの?」という視点です。

たとえば最初にあげた抽象化の例ですが、アプリケーションの仕様上、本来はお金の移行というのはありえない処理で、事故処理のためにめっちゃ特別に今回限りのスペシャル対応としてたった一回だけ行われる処理なのであれば、抽象化されてないメソッドのほうが、「使う側の負担」は少なくなりますね。なぜなら、使う側が「えーっと今回の事故処理ではだれからだれにいくら移行しなきゃいけないんだっけ」ってことを考えなくていいからです。一方で、どんなユーザーからどんなユーザーにいくらでもお金の移行が行われ得るような仕様のアプリケーションならば、移行が行われる都度「だれからだれにいくら」という判断をしなければいけないので、抽象化して「メソッドを使う側」にその判断をしてもらうべきでしょう。

かように、「正しい抽象化」というのはアプリケーションに求められている性質によって異なってきます。プログラムの設計とは、「このアプリケーションにはこういう性質があるから、だからここは抽象化して「使う側」に判断の責任を持ってもらおう、逆にここは具体的に書いておこう」ということを考えることでもあると言えるでしょう。

抽象化するための方法はいろいろあります(メソッドにして引数で渡すようにするとか、デザインパターンとか、型クラスとか……)が、それはあくまで「実装方法」であり、設計そのものではありません。実装方法をたくさん知っていることは武器にはなりますが、実装方法を知っているからといってむやみやたらに抽象化をしまくったとしても、それは単に責任の所在がいろんなところにとっちらかってて使いにくいプログラムにしかなりません。ギターの早弾きができることは武器になりうるけど、音楽的に意味のない早弾きは曲芸でしかないみたいなもんです。抽象化の方法をたくさん知った上で、アプリケーションが求める抽象化を正しく見出せるようになりたいものですね。

ブックマークコメントでいろいろ補足や指摘されていて、もっともな話が多いので、そちらも参考にしてください。とくにこのコメントは「まじでそのとおりですね!」という感じです。

プログラミングの「抽象化」ってどういう意味で、なぜ必要なのか - 猫型の蓄音機は 1 分間に 45 回にゃあと鳴く

これは抽象化の役割のうちパラメタライズに注目した説明。抽象化には他にも様々な問題に共通する構造を取り出すという役割があり、それは時に思考の足場や指針を与える。nopの例えが変なのは後者を考えてないから。

2015/08/05 09:00

なお過去に書いた抽象化の記事として適切に抽象化されたコードとはなにかって話というのがあり、こちらではこの記事で触れることができなかった抽象化の側面について触れています。よろしければそちらもお読みください。

宣伝

8/20,21,22に開催される、プログラマフジロック(あるいはプログラマの夏コミ)とも言えるYAPC::Asia 2015 にて、Perlで学ぼう!文系プログラマのための、知識ゼロからのデータ構造と計算量という発表をします。今回の記事の内容とはまたちょっと趣が変わって、もうちょっと実装よりの話になりますが、なるべくわかりやすい発表を心がけますので、気になった方はぜひ聴きに来てください。

なお、すでにチケットは完売しておりますが、なんとイベントレポーターになるとタダでYAPCに参加できるという裏技が存在しております!ぜひご検討ください。

YAPC::Asia 2015で喋ることになりました

8/20,21,22に開催される、プログラマフジロック(あるいはプログラマの夏コミ)とも言えるYAPC::Asia 2015ですが、無事トークが採択され、発表させていただく運びとなりました。詳細は下記より。

http://yapcasia.org/2015/talk/show/9f7059dc-003c-11e5-a00c-89c77d574c3a

自分が「これ聴きたいなー」と思うようなトークも不採択になったりしており、これでマズい発表をしたら各方面に対して顔向けできない感じで、プレッシャーも大いに感じていますが、まずは素直に嬉しいし、聞きに来てくれたひとになにか良いものを残せるようなトークをしたいと強く思っています。

ところで、スピーカー枠をゲットした都合により、個人スポンサーチケットが一枚あまっております。「しまったヤプシーのチケット買い忘れた!!」というかたは、

  • 必ず参加すること
  • ヤプシーを最大限楽しむぞ!!という気持ちを持って臨むこと
  • ノベルティはわたしにくれること(タンブラーほしい……)
  • 現地でわたしとコミュニケーション取ってくれること

を条件に、無料でチケットをお譲りします。ツイッター、ブログコメント、どんなチャンネルでもかまわないのでわたし宛にメッセージやメンションなどをください。なお、応募多数の場合は厳正な抽選の上一名様にお譲りします。当選発表は発送(発送とは……)をもってかえさせていただきます。

まだだれからも「くれ」って言われてないよ!今がチャンス!!

追記、譲り先が来ましました。みなさんヤプシー楽しみましょう!!

「地方」のIT勉強会は参加者がもっと外に向かってアピールしてほしい

わたしの立ち位置

今は東京都でプログラマをやっているが、3,4年くらい新潟県プログラマをやっていて、当時は地元のコミュニティによく顔を出していた、という人間です

以下本文

少なくともわたしが知っている限りの話です。新潟県には優秀なプログラマが一定数います。そして、最近はコミュニティがそういうプログラマにリーチしつつあって、交流は活発になっていると感じます。でも、参加者がレポートとかを対外的に書いたりはあまりしてないように思えます。これはとてももったいないことです。優秀なプログラマを喉から手が出るくらいほしがってる「都会」の企業が、「なんかあの地域めっちゃプログラマたちの活動活発だな、開発拠点作る意味あるかも」って思ってくれるくらいまで、「俺たちプログラマとして楽しいことやってるぜ!」ってのをアピールしていったらいいのにな、と思うんですよ。

これは多分新潟に限らないのではないかと思うんでけど、多分、力を持て余してるプログラマは全国各地に散らばっています。そして、優秀でやる気のあるひとから、どんどん東京の会社に吸い取られていく実際があります。新潟で出会ったプログラマのうち、何人もが「東京に転職することにしました」と言って新潟を去っていくのを見てきました。それはべつに悪いことだと思いません。一方で、家族の都合やいろいろな都合から、地元を離れることが難しいひとたちが、くすぶり続けているようなシーンもたくさん見てきました。

ここから先は単にわたしの願いであり、「そうあるべきだ」という話ではないですが、地元のプログラマ同士で切磋琢磨するのはとても楽しかったし、実際楽しいです。でも、そこからさらに「ほらほら俺たちこんなに楽しいことやってるよ!」というのを、参加者ひとりひとりが外に向けて発信して行ったら、優秀なプログラマと出会いたいと思っている「外の」ひとたちと、地元のひとたちになにか新しいつながりが生まれる可能性は増大すると思うんです。だから、コミュニティや勉強会の主催者だけではなくて、参加者がもっと、「俺たちめっちゃ楽しいぜ!!」を発信してくれたらいいのにな、と思っています。

手始めに、先週末のNDSに参加したひとはツイッターだけじゃなくてブログでレポートとかを公開しようよ!単純にわたしが読みたい!!!

YAPC::Asia 2015 のプロポーザル「文系プログラマのための、知識ゼロからのデータ構造と計算量」

2015年、8月20日から22日までの三日間、プログラマの夏フェスことYAPC::Asiaが今年も開催されます。わたしもトークを応募した。

YAPC::Asiaってなに?

YAPCとは? | What is YAPC?

世界最大のYAPCが最後の大花火をぶちあげにもどってきました!YAPCはYet Another Perl Conferenceの略で、Perlに関するカンファレンス・・・いや、お祭りです!Perlだけに限らず、様々な分野のギーク達が集まり技術の話と楽しさに満ちた三日間のお祭りが開かれます。Perlに関連する事に興味がなくとも心配する必要は全くありません、YAPC::Asia Tokyo 2015は技術者であれば誰でも楽しめるカンファレンスです。

今年も様々なゲストを集めて熱いトークが交わされます。世界中のギーク達がどんな事を今を考えているのか行っているのか、是非皆様も体験しに来て下さい!

概要 / ABOUT | YAPC::Asia Tokyo 2015

どんなトークを応募したの?

yapcasia.org

こちらに詳しいですが、ブログにも同じ内容を載せておきます。

概要

「データ構造やアルゴリズム、計算量について知っておく事は、プログラマにとって、とても大切なことです」といろんなところで言われています。一方で「そんなの知らなくっても降ってくるお仕事は片付けられるもんねー」というのも、まあ、たしかにそうですね、という感じがします。でも、データ構造と計算量のことを知らないでいると、カジュアルに下手なインデックスを貼ってしまったり、単純な集計のはずなのに6時間動き続けて返ってこない……のようなスクリプトを書いてしまったりすることがあるので、やっぱり、プログラマにとってデータ構造やアルゴリズム、計算量の基本を知っているというのは、とても大切なことなんじゃないかな、とわたしは思っています。少なくとも、「計算機の気持ちになってコードを読み書きする」ときの助けになることはたしかです。

でも、やっぱり計算量の話とかって、とっつきづらいですよね。「でもさー。わたし、文系なんだよね。なんか計算量がどうこうとかいうひとたちって数式でしゃべるじゃん。log とか? アクセスログかよって思う。日本語でしゃべれって感じだよね」みたいな。わたしも文系卒なので、そんな気持ちはよくわかります。というか、わたしはそう思っていました。この発表は、そんなひとたち(つまり、昔の自分)がなるべく苦痛を感じないようにデータ構造と計算量に触れ、その後の独学独習の助けになるような基礎的な知識をつけてもらうことがゴールです。

アジェンダ(仮)

  • 連結リスト
  • ハッシュテーブル
  • B+ツリー
  • スキップリスト

以上のデータ構造について見ていきながら、時間計算量と空間計算量について、なるべく平易に、しかし嘘をつかないように、実際の事例を交えながら学んでいきます。

対象となる方

  • コードを書くことはできるけど、「このデータはメモリにどう展開されるから〜」とか言われるととたんに置いていかれるひと
  • アルゴリズムやデータ構造などに対して苦手意識が強い

対象とならない方

  • 上記のデータ構造をすでに理解できているひと

もし気になるようでしたら、ぜひ上記リンクからトークのページへ飛んで、各種シェアボタンで応援してくださると嬉しいです。

see also

uzulla.hateblo.jp

引っ越しました

仕事の都合で東京のはずれに引っ越しました。

新潟にいる間は、新潟のプログラマがとても仲良くしてくれて、おかげでプログラマとしてめちゃめちゃ楽しく生活することができました。なかでも @hayajo さん、@civicさん、@dictavさんには格別にお世話になりました。ズッ友だょ!!

東京のプログラマのみなさん、今後ともよろしくおねがいします。

wishlistはこちらです。

www.amazon.co.jp

Perlの黒魔術を解説するよ〜〜〜〜

まずはこちらをごらんください。

shinh.hatenablog.com

すごすぎる……。恐ろしいですね。

なぜこんなことになるのか、解説していきましょう。まずはPerlの気持ちになりましょう。

Perlの気持ち編

ポイントその1 barewordを数値コンテキストで評価するとどうなるのかということ

件のプログラムは、base64 っぽい文字列が書かれていますが、これを前からPerlコードとして読んでいくと、大きく2つのパートに分かれることに気づきます。というのも、前から一文字ずつ読んでいくと、「+」という演算子にぶつかるわけですね。

それに気づくと、このコードは前半部分

dXNlIE1JTUU6OkJhc2U2NDtwcmludCBlbmNvZGVfYmFzZTY0IGpvaW4nJyw8PjsKX19FTkRfXwo

と、

s//v62/e+s//v60/e+s//v44/e+s//v39/e+s//v39/e+s//join/+s//v32/e+s//base64/ss+ s//v95/e+s//decode/+s//v32/e+s//print/+s//v59/e+s//Base64/+s//v58/e+s//v58/e +s//MIME/+s//v32/e+s//use/s/eval

に分けることができる、と気づくでしょう。

まずは前半部分からやっつけていきましょう。Perlコードにおいて、""などで囲まれていない上にsigilを持たない文字列は、「bare word」として解釈され、use strictをしていない環境では、同名の解決可能な関数などが見つからない場合、文字列扱いになります。

my $bare =  nyan;
print $bare; # => nyan

というわけで、前半部分はdXNlIE1JTUU6OkJhc2U2NDtwcmludCBlbmNvZGVfYmFzZTY0IGpvaW4nJyw8PjsKX19FTkRfXwoというbarewordとして解釈され、この部分は"dXNlIE1JTUU6OkJhc2U2NDtwcmludCBlbmNvZGVfYmFzZTY0IGpvaW4nJyw8PjsKX19FTkRfXwo"という文字列として解釈されます。

一旦文字列として解釈された"dXNlIE1JTUU6OkJhc2U2NDtwcmludCBlbmNvZGVfYmFzZTY0IGpvaW4nJyw8PjsKX19FTkRfXwo"は、 + オペレータに渡されます。このとき、+演算は渡されたものを数値コンテキストとして解釈します。文字列"dXNlIE1JTUU6OkJhc2U2NDtwcmludCBlbmNvZGVfYmFzZTY0IGpvaW4nJyw8PjsKX19FTkRfXwo"を数値コンテキストとして解釈するとどうなるでしょうか。やってみましょう。

✔  perl -e 'print "dXNlIE1JTUU6OkJhc2U2NDtwcmludCBlbmNvZGVfYmFzZTY0IGpvaW4nJyw8PjsKX19FTkRfXwo" + 0'
0

parseできないので、0として解釈されているのがわかるでしょう。

これで、前半をPerlコードとして見たときには 単に '0 + 後半'というコードとして解釈されることがわかりました。では後半にいきましょう。

後半戦

さて、同じように読み進めていくと、'+'という記号がキモになっていることに気づくでしょう。前から順に + で様々な項が前から順に評価されています。

後半を仔細に見ていくと、基本的に s//vなんか/e という項と、 s//なんか/sが0からn個という項が+で連結されていて、最後にs//use/s/evalが続いていますね。ではそれぞれの項がどう評価されるか見てみましょう。

ポイントその2 s//vなんか/e という項

s/なんか/べつのなんか/option というのは、正規表現の置換リテラルですね。ではeというオプションはなにかというと、s/なんか/べつのなんか/eの、「べつのなんか」の部分をPerlコードとして評価する、というオプションです。では「vなんか」というbarewordはPerlにとってどういう意味を持っているでしょうか。これは「ヴァージョン文字列」と呼ばれるものです。(see perldata - Perl のデータ型 - perldoc.jp )。

というわけで、v62の評価結果は、10進数における62を16進数に変換して、"\x3e"となります。これは">"です。

ところで、s//v62d/e の部分、=~でマッチしていませんね。この場合、Perl$_というデフォルト変数に対する置換として解釈します。今$_は空ですから、空文字列に対して、「空文字列にマッチしたら最初の空文字列を">"として置き換えてそれを$_に代入する」という動きをします。つまりこういうことです。

s//v62/e;
print $_; # => ">"

要するに、$_の先頭にv62dの評価結果を文字列コンテキストで挿入してるわけですね。そして、これを繰り返すことで、$_に文字列を貯めていきます。

ポイントその3 s//なんか/0からn文字のs という項について

これは「0からn文字のs」が置換正規表現のオプションとして扱われますね。ではsというオプションは何を意味するでしょうか。これは「ワイルドカードのドット( . )が改行にもマッチするようにする」というオプションです。今回は改行使ってないので、この「0からn文字のs」については無視して良いことになりますね。

さて、そうなると、s//なんか/0からn文字のs の評価結果は、$_ の先頭に「なんか」の部分の文字列を貯めていくことになるわけです。

ポイントその4 最後の項

さて、最後はs//use/s/evalという項です。

これはちょっとトリッキーですが、Perlの気持ちになって読むと、(今まで解釈してきた部分 + s//use/s) / (eval) という割り算として解釈できます。 / の優先順位は + より高いですからね。 つまり、今までは単純に前から読んできたけど、評価順としては、(今まで読んできた部分 + s//use/s) を評価して、そのあと(eval) を評価して、最後に / で割り算する、という形ですね。でも割り算の結果は捨てられてるので、結果的には前から順に評価されてるのと同じことです。

では最後の部分をみてみましょう。s//use/s についてはさきほど見たとおりですね。 $_ の先頭に "use" をappendするように解釈されます。そして、そのあと(eval) が評価されるわけですが、evalは引数が省略された場合、$_を暗黙の引数として取ります。これで、「最後の項を除く部分を評価した結果得られた $_ をevalで評価する」というプログラムの完成です。

では、今まで見てきたものをまとめましょう。

Perlの気持ち解決編

dXNlIE1JTUU6OkJhc2U2NDtwcmludCBlbmNvZGVfYmFzZTY0IGpvaW4nJyw8PjsKX19FTkRfXwo+
s//v62/e+s//v60/e+s//v44/e+s//v39/e+s//v39/e+s//join/+s//v32/e+s//base64/ss+
s//v95/e+s//decode/+s//v32/e+s//print/+s//v59/e+s//Base64/+s//v58/e+s//v58/e
+s//MIME/+s//v32/e+s//use/s/eval
(dXNlIE1JTUU6OkJhc2U2NDtwcmludCBlbmNvZGVfYmFzZTY0IGpvaW4nJyw8PjsKX19FTkRfXwo+
s//v62/e+s//v60/e+s//v44/e+s//v39/e+s//v39/e+s//join/+s//v32/e+s//base64/ss+
s//v95/e+s//decode/+s//v32/e+s//print/+s//v59/e+s//Base64/+s//v58/e+s//v58/e
+s//MIME/+s//v32/e+s//use/s) / (eval)

を評価した結果、

$_ =  "use MIME::Base64;print decode_base64 join'',<>";
eval $_;

という計算が得られることになります。おお!!!base64デコーダだ!!!

base64の気持ち編

では、Perlの気持ちになったときに無視されていた前半のbarewordをbase64として解釈してみましょう。これはbase64エンコードするPerlコードになっています。

echo "dXNlIE1JTUU6OkJhc2U2NDtwcmludCBlbmNvZGVfYmFzZTY0IGpvaW4nJyw8PjsKX19FTkRfXwo+" | base64 -D
use MIME::Base64;print encode_base64 join'',<>;
__END__

この出力をPerlコードとして解釈すると、__END__以下は無視されるから、そのあとは(base64としてvalidならば)自由になんでも書くことが可能!!!!!!結果、base64エンコードするPerlコードが得られるわけですね。かしこーーーーーーーい!!!!

まとめ

Perlの黒魔術を説明しつつ、黒魔術コードを読み解いてみました。読む方としては「なるほどなー」って感じだけど、これを書くの変態すぎるしすごすぎる……。これ、base64が'+'と'/'を使えることと、Perlの'$_'と、ヴァージョン文字列とかいう変態仕様を最大限悪用してるんですよ。すごすぎる!!!という思いを得ることになりましたね!!!!世の中どんだけ天才がいるんだよ……。

エラーハンドリング・クロニクル #nds41

はじめに

プログラミング技術の歴史は、ありとあらゆる歴史がそうであるように、いろんな「史観」で眺めることができます。ならば、プログラミング技術の歴史を、「エラーハンドリングとの戦い」という視点から見ることもできるのではないでしょうか。本日は、エラーハンドリングとの戦いの歴史を俯瞰することで、エラーハンドリングの勘所について考えていこうと思います。

なお、このエントリはNDSという勉強会の第41回で発表した内容と同一です。

Cの時代

Cの時代のエラーハンドリングでは、関数の返り値と、グローバル変数errnoを見ることで処理が成功したか失敗したかを見るのが一般的でした。

例として、文字列をlongに変換するstrtol関数をmanで引いてみましょう。すると、だいたい以下のようなことが書かれています。

  • 変換に失敗すると、0を返す
  • 変換に失敗した場合、グローバルな変数であるerrnoに以下の定数を格納する
    • invalidな文字列を渡された:EINVAL
    • longをoverflowしたりunderflowする数を渡された:ERANGE

さて、一見してとてもシンプルな仕様ですが、現代的な視点からみてみると、いくつか気になることがありそうです。ざっと挙げてみると……

  • 関数を呼び出したあと、エラーであるかどうかを手動でチェックする必要があるし、仮にチェックしなかったとしてもコンパイラが怒ってくれるわけではない
int main(int argc, char *argv[]){
  long l = strtol("string");
 
  // なんかこうごちゃごちゃといろいろやる
 
  some_function(l); // ここでおかしなことになる
  return 0;
}

これは結構怖いですね。たとえば"String"みたいな文字列をstrtolに渡して、エラーチェックをしなかった場合、0という数だと解釈されたまま、プログラムは動き続けます。そして、どこかこのstrtolを呼び出したところから遠く離れたところで、いきなりプログラムがクラッシュします。バグを入れ込んでしまったところと、バグが発見されたところが遠く離れているとき、デバッグは困難を極めます。うーん。おそろしい。

では、必ずエラーを手動でチェックすればそれで問題は解決でしょうか。そんなことはありません。たとえばあなたが文字列と文字列を受け取り、それをlongとして解釈して足す関数を含むライブラリ作成しているとしましょう。このとき、もしstrtolにinvalidな文字列を渡してEINTVALが帰ってきても、自分ではそのエラーをどうハンドリングすべきか、ということはわかりません。なぜかというと、「変な値入れられた時にそれをどう扱うべきか」というのは、ライブラリが決めるべきことではなくて、アプリケーションの要件によって決まることだからです。

なので、あなたはあなたが作成している関数の利用者に対して「invalidな文字列が渡されたよ」というエラーを通知する必要があるわけです。つまり、エラーを上流に伝播させる必要があるわけです。そして、このようなシチュエーションは決して珍しいものではありません。そのたびにあなたは起こりうるエラーに対してすべてエラーコードを定義して、エラーの場合の返り値を決めて、という作業をしなければいけません。そして、あなたの書くコードは本質的な処理よりもエラーハンドリングのためのコードによってどんどん太っていきます。

つまり、このやり方では、

  • エラーを無視しやすい
  • エラーを上流に伝播させるのがだるいしむずかしい

という問題があるわけです。

そして、それだけではありません。まだ気になる点はあります。それは、

という点です。グローバル変数は、いつ書きかわるかわかりません。なので、errnoをあとから参照したい場合はそれ用の変数を作ってコピーしておく、という回避策が一般に取られています。

int main(int argc, char *argv[]){
  long l = strtol("string");
  int strtol_err = errno; // コピーしとかないといけない
 
  // ちょっとなんかやる間にerrnoが書きかわる可能性‥‥
 
  if (strtol_err== EINVAL) {
    // エラー処理
  } else if (strtol_err == ERANGE) {
    // エラー処理
  }
 
  return 0;
}

シングルスレッドで動いているならば、「次の行で必ずチェック、あるいはコピーする」ということを徹底すれば(それだって結局人間がやらなければならないのですけれど)問題にはならないでしょう。しかし、もしもこれがマルチスレッドで動いていたら?errnoは本当に「どのタイミングで書きかわるかわからない」ものになります。

さらにもうひとつ問題があります。それは「エラー時にリソースの解放をするのが煩雑」という点です。

エラーになってしまったとき、mallocで確保していたものをfreeせずに早期returnなどをすると、正常系では解放されるリソースが解放されなかったりします。これを防ぐためによるあるパターンは、関数の後ろのほうにリソース解放の処理を書いておき、返り値は変数に入れておき、gotoでリソース解放のところにすっ飛ぶパターンです。

int nyan(){
  int *p1 = (int *)malloc(sizeof(int))
  int *p2 = (int *)malloc(sizeof(int))
  int *p3 = (int *)malloc(sizeof(int))
  int retval = 0;

  // ごちゃごちゃなんかやる
  
  //!エラーが起こった!  
  if (err) {
     retval = -1; // エラーコードを入れて
     goto cleanup; //cleanupにすっ飛ぶ
  }
  
  //正常系は続く
  
  retval = 1; //正常系の場合の返り値を入れて
  
cleanup:
  free(p1);
  free(p2);
  free(p3);
  return retval;
}

エラー処理周りのコードだけで、こんなに大きな関数になってしまいました。バグを入れ込みそうで怖いですね!一時期話題になったApple史上最大のセキュリティバグ、goto fail; なんかはこのパターンをつかってたやつです。

Cスタイルのエラーハンドリングの問題点については、おおまかにこんなところでしょうか。

一度まとめておくと、

  • エラーを無視しやすい
  • エラーを上流に伝播させるのがだるいしむずかしい
  • エラーの内容がグローバル変数に格納されている
  • リソース管理が絡むと煩雑になる

ですね。

例外の時代

さて、Cの時代のような問題に対抗するために、人間は例外という新しい武器を作り出しました。例をあげながら、上述の問題点がいかに解決されているのかを見てみましょう。

  • エラーを無視しやすい

例外をrescue(キャッチ)しわすれるということはあり得ますが、その場合もプログラムはすぐさまクラッシュしてくれるので、エラーを無視してしまっても、「間違えた内部状態のままプログラムが進んでしまって、バグを入れ込んだところと遠く離れたところでいきなりクラッシュする」というようなことは防げるようになりました。

class WanError < StandardError; end

def wan
  raise WanError, "エラーだよ!!!"
end

wan # rescueしていないのでここでプログラムは止まってしまう
  • エラーを上流に伝播させるのがだるいしむずかしい

例外をraiseしてそれをrescueしなかった場合、例外はコールスタックを上流に向かってどんどん突き進んでいきます。なので、何も書かなくても nyan の中でよんだ wan のエラーを main で捕まえることができます。

class WanError < StandardError; end

def nyan
  wan
end

def wan
  raise WanError, "エラーだよ!!!"
end

begin
  nyan
rescue WanError => e # wanの中で発生したエラーをここで捉えられる
  p e
end

例外が起こるたびに例外オブジェクトを発生させるので、グローバル変数にエラーを入れておく必要がありません。

  • リソースお片付け問題

ensureやfinaly(例外が起こっても起こらなくてもかならず実行される部分)があるので、そこでお片付けすればシンプルです。

r = Resource.new
begin
  # do something
rescue => e
  # do something
ensure
  r.close
end  

さて、いいことづくめであるような気がする例外機構ですが、近年、この例外機構をもってしても解決できない問題が人類を襲いました。

  • キャッチし忘れ問題

例外をキャッチしわすれると、アプリは死にます。それはもう見事に簡単に死にます。Javaの検査例外は例外のキャッチし忘れをコンパイル時に見つけてくれる仕組みですが、批判も多い機能ですね。今回はちょっと分量的に無理なので検査例外の話には立ち入りません。

  • 非同期処理との相性の悪さ

一般に、非同期処理が絡むと、例外の扱いはかなり難しくなってきます。というのも、(たとえば)スレッドAで起こった例外は、そのままスレッドBでキャッチすることはできません。これは、スレッドAとスレッドBが別のコールスタックを持っていることを考えれば当然のことです。そのため、スレッドをまたいだ例外の取り扱いというのは非常にむずかしいものとなります。Javaはそれに対して Callable と Future という回答を出し、それは一定の成果を上げていると言えそうです(このあたりも詳しく入り込む余裕がないので入り込みません)。しかし、たとえば goroutine のように、非同期なタスクから連続的に値を受け取りたいときなどはどうすればいいでしょうか? Runnable では依然として別スレッドの例外を補足する方法はなく、非同期処理と例外機構というのは、やはり結構相性が悪いもののようです。

Eitherの時代

さて、Cスタイルのエラーハンドリングに対して、例外とは別の方向から回答を出したのが、関数型界隈でよく使われているEitherというデータ型です。

Scalaの例で説明しましょう。Scalaにおける Either というのは、LeftかRightどちらかの値を持つデータ型です。LeftとRightはコンテナになっていて、どんな値でもその中に入れることができます。

Eitherの使い方としては、正常に処理が成功した場合はRight(正しい、という意味のRightと掛けている)に値を突っ込んで、失敗した場合は失敗の理由などを表すオブジェクトをLeftに突っ込んで返します。こんな感じ。

def divide(x:Int, y:Int): Either[String, Int] = {
  if (y == 0) {
    Left("can't divide by zero")
  } else {
    Right(x / y)
  }
}

divide(2, 2) // => Right(1)
divide(0, 0) // => Left("can't divide by zero")

Cスタイルと同じく、値としてエラーかどうかを返すスタイルです。が、Eitherの場合どのようにCスタイルの問題が解決されているのか見てみましょう。まず、

  • エラーを無視しやすい

という点は解決されています。というのも、Eitherの中身はそのままでは使えません。なんらかの方法で取り出す必要があります。

たとえば、Eitherの中身は、パターンマッチで取り出すことができます。

val either = divide(2, 2)

either match {
  case Right(x) => println(x)
  case Left(message) => println(message)
}

このとき、RightだけでパターンマッチしたりLeftだけでパターンマッチしようとすると、コンパイラが「caseが網羅的じゃないよ」と怒ってくれます。終始こんな感じで、Leftを無視してRightの中身だけを扱おうとするとコンパイラに怒られる仕組みが揃っています。

次に

  • エラーを上流に伝播させるのがだるいしむずかしい

という問題について見てみましょう。Eitherにはrightというメソッドがあって、これを呼ぶとRightProjectionというものが取得できます。これは「Eitherのright側を正当なものとして扱うよ」と決めたもの、のようなものです。このRightProjectionにはmapメソッドが生えていて、そのmapメソッドは「Leftの場合はそのままLeftを返して、Rightの場合は引数に指定した計算を行う」という挙動をします。

def divideAndDouble(x: Int, y:Int): Either[String, Int] = divide(x, y).right.map(_ * 2)

divideAndDouble(2, 2) // => Right(2)
divideAndDouble(0, 0) // => Left("can't divide by zero")

このように、map(やflatMap)を利用することで簡単にエラーを伝播させることができます(for式やモナドについては触れません。興味があれば調べてください)。

毎回Eitherを作るのでグローバル変数は駆逐できます。

  • リソースの解放問題

これはEitherによって解決されるものではないですが、関数型スタイルではリソースの確保や解放は副作用とみなします。関数型スタイルでは、副作用をなるべく局所的にまとめて、ロジックから分離するという別の方法で解決しています(雑な説明ですがここに入り込むとまた時間がかかるのでこれも詳しく気になるひとは調べてください)。

というわけで、Cスタイルの問題点はどうやらEitherでだいぶ解決できそうです。

ここからさらに、例外が持ち込んでしまった問題点をEitherがどのように解決しているかも見てみましょう。

  • キャッチしわすれ問題

上述の通り、エラーを無視しようとすると、コンパイラが怒ってくれますし、検査例外ほど煩雑でもありません。

  • 非同期処理との相性問題

例外機構は制御構文ですが、Eitherは単なるデータ型です。スタックを飛び越えたりしないし、「普通の値」として扱えます。なので、例外機構よりも素直に非同期処理においてエラーを扱うことができます。何度も言いますが、単なる値ですから。

おまけ・現代プログラミング言語の異端児、golangについて

golangも、例外機構以外の方法でエラーを扱う言語です。これは、goroutineの存在が大きいのではないでしょうか。goroutineはJavaのFutureとかと異なり、goroutine同士でデータをやりとりするために channel を使います。このとき、素直な例外機構はまったく役に立ちませんよね。

そこでgolangは、返り値を複数持てることを利用して、最初の返り値に正常系の返り値、2つめの返り値に異常系の時のエラー値を返す、という「慣習」をつくることにしてしました!!!!

file, err := os.Open(filename)

これはかなり大胆な考えかたですが、実は結構バランスのとれた解だと思います。

まず、うっかりerrを受け取り忘れると、コンパイル時に怒られます

f := os.Open(filename) //multiple-value os.Open() in single-value context

さらに、errを受け取ったとして、それを無視してもコンパイル時に怒られます

f, err := os.Open(filename)
// このあとなにもしないと、err declared and not usedと怒られる

さらに、リソースお片付け問題に関しても、defer を導入することで解決しています。

func openAndClose(filename string) {
    f, err := os.Open(filename)
    // snip
    defer f.Close() // openAndCloseを抜けるときに必ず呼ばれる
    // snip
}

現代的な言語なのにいわゆる例外がないの!!!!って最初はびっくりしますが、goroutineとの絡みを考えると非常にバランスのとれた設計だと言えそうな気がしますし、ある意味「あんまり堅苦しくないしモナドじゃないEither」みたいな立ち位置で、かなり面白いですね!

まとめ

エラーハンドリングのパラダイムをいろいろ見てみました。「どれが最高の正解」ってことはないけれど、それぞれのプラットフォームやパラダイムがどう問題を解決しようとしているのかを知ることで、より安全なアプリケーションを書く助けにはなるのではないでしょうか。