とある言語の例外処理 またはTry::Tinyの落とし穴

zentooo
2010-12-13

今年の Advent Calendar もいよいよ中盤戦。
もういくつ寝ると、メリーク×ト×ス!
最近、休日になると漫画を大人買いするのが趣味になってしまったいけない大人になってしまった僕ことzentoooですが、最近のお気に入りは「未来日記」です。あー由乃かわいいなーかーわいいなーーーかーーーわいーなあーーーーあーあーあーーーーーーあーーーーーーーーーーー頭おかしいけど。




ふぅ。


さて、みなさん、コード書いてますか、コード読んでますか。
短く簡潔なコードを読むと、気持ちもスッキリしますね。
でも、たまには例外処理なんてどうですか。
Perlで例外処理ができるなんて、なんか渋くないですか。

Perlにおけるベーシックな例外処理

冗談はさておき、初めてのPerlというハレンチな本を読んだりすると、多分書いてあるのは以下のような方法による例外処理です。実際に初めてのPerlに書いてあるかどうかは忘れました。

eval {
  die 'I will never die.';
};

if ($@) {
  print $@;
}

この例外捕捉方法にはいくつか$@にまつわる落とし穴があって、どういう場合にその落とし穴にはまってしまうかの証明はここに書くには余白が足りないので割愛します。後述するTry::Tinyのdocに例が書いてあります。

(2010/12/13 19:12追記: $@のハンドリングに関しては僕自身がちょっと誤解していたのですが、あえて local $@ しない場合はそこまで問題にはならないようです。このへん興味がある人は自分で調べてみてください)

Try::Tiny

eval/if にまつわる諸々の面倒なことをアプリを書くときにいちいち処理するのは面倒なので、「よしそれじゃあ便利モジュールを書いてその中でよしなにしちゃえばいいよね!」というのは誰もが思いつくことだと思います。面倒なハンドリングはライブラリ内でやって、ユーザ側からはストレスなく使える、というのは理想的なコードの再利用ですね。

eval/if に落とし穴があることは昔から知られていたので、Perlには多くの例外処理用モジュールがあります。僕が数えただけでも108個あります。その中でも最近よく使われている(といっても僕はどちらかといえば最近Perlを始めたので最近のこと以外は知らないのですが)モジュールがTry::Tinyです。

Try::Tinyは、余計なことをせずシンプルなtry/catch構文もどきを実現してくれるモジュールです。使い方はこんな感じです。

# handle errors with a catch handler
try {
  die "foo";
} catch {
  warn "caught error: $_"; # not $@
};

$@が$_に変わっているだけで、とても分り易いですね。これで例外処理もバッチリね!と思ったかもしれませんが、Try::Tinyの導入するtry/catch構文もどきにも実は落とし穴があります。

Internal of Try::Tiny

Perlをちょろっと勉強したことのある人なら誰でも、「Try::Tinyの導入してくれるtry/catch構文もどきは一体どうやって実現されているのかな?」と疑問に思うことでしょう。tryという関数にはコードブロックが渡されているように見えますが、Perlでコードブロックを渡すといえばサブルーチンリファレンスを渡すことで、そしたら try(sub { ... }) になるはずだよね、とかとか。

種明かしをしてしまうと、これを実現するにはプロトタイプ宣言を使います。

sub do_block(&) {
  my $block = shift;
  $block->();
}

do_block {
  # YouやっちゃいなYO
};

プロトタイプ宣言で、「一つめの引数はサブルーチンリファレンスだよ!」と教えてあげた場合にのみ、本来ならばdo_block sub { ... } と書かなければいけないところをdo_block { ... }と書けるような関数が書けるんですね。ちなみにこれが可能なのは、第一引数のみです。

この機能に併せてdo_block(&@)のように書くことで、例えば組み込みの map { ... } @aryや grep { ... } @ary のような挙動をする関数を定義できます。こういったDSLっぽい記述は人によって好みが分かれるところですが、知らないよりは知ってる方がいいよね、という感じです。

これでtryのような関数の書き方はわかりますが、catchはどうなっているのでしょうか?Try::Tinyの中身を見れば分かるのですが、catchもまたプロトタイプ宣言のなされた普通の関数で、中身はこうなっています。

sub catch (&;@) {
  my ( $block, @rest ) = @_;

  return (
    bless(\$block, 'Try::Tiny::Catch'),
    @rest,
  );
}

catchは、第一引数としてコードリファレンスを受け取り、それをblessしたもの + 残りの引数を単にリストにして返す関数です。第一引数のコードリファレンスはtryの中で例外が起こった場合のみ実行されるべきなので、ここでは評価されずに呼び出し元へと受け渡され、結果的にtryの引数になります。@restはfinallyを実現するための引数で、これも結果的にtryに渡されます。

つまり、Try::Tinyを使って、try { ... } catch { ... } finally { ... };という式を評価したときの流れは以下のようになります。

1. finally関数が実行され、引数のコードリファレンスがTry::Tiny::Finallyにブレスされて返される
2. catch関数が実行され、引数のコードリファレンスおよび1.で返されたコードリファレンスのリストが返される。($catch: CodeRef -> 'Try::Tiny::Catch', $finally: CodeRef -> 'Try::Tiny::Finally')
3-0. try関数が実行され、受け取ったコードリファレンス群をよしなに処理する。
3-1. tryの第一引数のコードリファレンス実行時に例外が起こった場合は$@をよしなに処理して$catchを実行。
3-2. $finallyの実行を、スコープ脱出によるオブジェクトの破棄 -> デストラクタの実行によって保証。

Try::Tinyの落とし穴

なんだか本筋からずれちゃいましたが、要はtryもcatchもfinallyも単なる関数です。なので、本物のtry/catch構文であるかのような気分で以下のようなコードを書いてしまうと

try {
  die "foo";
} catch {
  warn "caught error: $_"; # not $@
} # (セミコロンがない!)

あばばばばばばば、となってしまいますし、catch { ... } の中身は関数なので、本物のtry/catch構文であるかのような気分で外側の関数からのreturnを図っても

sub foobar {
  try {
    die "foo";
  } catch {
    return $_; # ここで関数foobarからreturnしたつもりが...
  };
  print 'hoge finished successfully.'; # 期待に反し実行されてしまう
}

foobar();

foobarからreturnしてるつもりだけど実はcoderefの中でreturnしてるだけ、という悲しい結果になってしまいます。以上2つの落とし穴は、Perl歴50年の匠ですら「時々ひっかかる」と言っているので、「Try::Tinyがナウいらしいぜ!」となんとなく使い始めた人がひっかかるのはもはや必定です。

ちなみに、try or catchの中でreturnした結果は最終的にtryの返り値になるので、Try::Tinyが導入しているのはtry/catch構文ではなく値を返せるtry/catch式であるといえます。

Try::Tinyは銀の弾丸ではない

というわけで、Try::Tinyを使うことによってeval/ifの落とし穴は回避できるようになるのですが、また別の落とし穴が現れてしまいました。じゃあどうすればいいねん!っていう話なんですが、「シンタックスシュガーはシンタックスシュガーに過ぎないので、その裏で起こっているであろうことには注意を払わなければいけない」「if/evalとtry/catch、どっちを使うにしろ用量/用法を守って正しく使いましょう」という感じでしょうか。


明日のキーパーソンは、punytanさんです!
どーぞ楽しみにしといてちょ!