YARV Maniacs 【第 10 回】 例外処理(初級編)

書いた人:ささだ

はじめに

YARV: Yet Another RubyVM を解説するこの連載。...なんと、前回 YARV Maniacs 【第 9 回】 特化命令 が掲載されたのは 2006 年 11 月だそうで、4 年と半分くらいぶりです。我ながら、放置ぶりにびっくりです。前回の最後に「多分やると思いますので気長にお待ちください」とありますが、気長すぎです。

さて、これまでの間、YARV は放置していたかというと、そういうわけではなくて、無事に Ruby 1.9 の一部として取り込まれ、リリースされているのはご承知の通りかと思います。ちなみに、Ruby 1.9 の最新安定版は 昨年の夏にリリースされた Ruby 1.9.2 になります。Ruby 1.9.2 は、Ruby 1.9.1 などで指摘されてきたバグなどが修正されており、使いやすいバージョンではないかと思います。私個人も、Ruby プログラムを作るときは Ruby 1.9 (ただし、開発最新版) を使うことが多いです (サーバとかに置くのには、インストールが面倒で 1.9 は使ってないのだけれど)。

といいますか、YARV って言葉自体、もう知らねーよ、という人も多そうですね。詳しくは、この連載のバックナンバーをご覧下さい。いわゆる、Ruby 向けの言語処理系を実現する仮想マシンです。現在、仮想マシンというと、VMware とか KVM とか VirtualBox とか、物理マシンを仮想的に実現するソフトウェアを連想する人が多いと思いますが、そうではなく、プログラミング言語を動かすために仮想的に定義したマシンアーキテクチャ、およびその実行系です。例えば、Java 仮想マシンが有名だと思います。現在だと、Google が作っている Android 向けの Dalvik 仮想マシンが有名なんでしょうか。

YARV が CRuby に公式に取り込まれたのに、まだ Yet Another と名乗るのか、という話は、百万回くらい聞かれた気がするのですが、連載の名前を変えるのも面倒ですので、YARV という名前を、Ruby 向け言語処理系の 1 実装である CRuby に搭載されている仮想マシンの実装のこと (長い) を指すこととします。何気なく 1 実装と言いましたが、Rubinius とか MacRuby とか凄い (らしい) ですよね。どちらも LLVM を使って Just-in-Time (JIT) コンパイルをサポートしているらしいです。カッコイイですね。Ruby 2.0 はどうなるんだろう。

今回は、とくに状況が変わったわけでも無いのですが (というか、2008 年あたりに変わってから、YARV は大して動きがないのですが。ただただ、Ruby 1.9.2 などに向けて、多くのバグフィックスは行われました)、何となく、何気なく、何事も無かったかの如く、この連載を続けようと思います。今回のテーマは例外処理です。

例外処理 (初級編) では、例外処理をどのように扱っていくか、その大枠をご紹介します。あくまで大枠なので、あまり細かいことはやりません。

例外処理

Rubyist の皆さんは、例外処理をよくご存じのことと思います。begin/rescue/ensure/end で括るアレです。例外的な事象が発生したとき、その事象をどうやって処理するか、を記述するための文法です。

 begin
   # BODY
 rescue E_C => e1
   # RESCUE_C
 rescue => e2
   # RESCUE
 else
   # ELSE
 ensure
   # ENSURE
 end

すぐに、こんなコードが思い浮かぶことでしょう。ちなみに、大文字で書いた BODY などは、何かプログラムが書いてあると思って下さい。

言うまでもありませんが、BODY を実行中に E_C クラスの例外が発生したら (もう少し正確に書くと、E_C、または その派生クラスの例外が発生したら) RESCUE_C が、それ以外の例外が発生したら (もう少し正確に書くと、StandardError またはその派生クラスの例外が発生したら)、RESCUE が実行されます。RESCUE_C も RESCUE も実行されなければ、ELSE が実行されます (この機能は知らない人も居るかもしれませんね)。RESCUE_C、RESCUE、ELSE が実行されたかされないかに関わらず、後処理を担当する ENSURE が実行されます。

(余談1:ちなみに、RESCUE_C、RESCUE、ELSE のどれもが実行されない場合、というのはどういう場合かわかりますか?答えは何個かあります。)

(余談2:ちなみに、上では ENSURE はどの場合も実行される、と書きましたが、ENSURE が実行されない場合があります。どういう場合かわかりますか?これも、答えは何個かあるかと思います。)

さて、今回はこのような例外処理をどうやって YARV で実現しているのか、ということを解説してみようと思います。

例外処理 (rescue 節) を Ruby の case/when 文で書いてみる

まずは rescue 節を理解するために、複数ある rescue 節を 1 つの case/when 文で書いてみましょう。

結局のところ、rescue 文は例外クラスによって処理を分岐させるだけなので、条件分岐を実現するための case/when 文で書けるわけです。では、先ほどの例を早速 case/when 文を使って書いてみましょう。

 begin
   # BODY
 rescue Exception => e
   case e
   when E_C
     # RESCUE_C
   when StandardError
     # RESCUE
   else
     # ELSE
   end
 ensure
   # ENSURE
 end

こんな感じになります。case/when 文では、「when cond」と条件があったとき、「cond === e」のように評価されます。 上記の例では「Class#===」が呼ばれますので、「e」が当該例外クラスのインスタンスかどうか比較が行われ、期待した動作になります。簡単ですね。

つまり、resuce 節は、だいたいこんな感じで case/when 文と同じような感じで実装されています。簡単ですね。

例外の捕捉

さて、前節では、どの例外処理がおこなわれるか、というのがわかっている前提で話をしていました。例外処理の対象となるプログラム片が、1 つという、とても単純で直感的なものでした。

例外処理を実現する上で難しいのは、例外が発生したとき「どの例外処理が呼ばれるか」ということを判断する点にあります。

複雑な例

例えば、次のようなプログラムを考えます。

 # 例1
 # A
 begin
   # B
 rescue
   begin
     # C
   rescue
     # D
   ensure
     # E
   end
 ensure
   # F
 end

この例では、A を実行中に例外が発生しても捕捉されませんが、B を実行中なら捕捉されて C から始まる節を実行します。そして C には、例外処理 D と 後処理 E が、また B には後処理 F がくっついています。

もう一つの例を考えてみましょう。

 # 例2
 def m1
   begin
     m2
   rescue
     # RESCUE
   end
 end

 def m2
   raise "foo"
 end

 m1

このプログラムでは、m1 -> m2 と呼び出されたときに、m2 で発生した例外を m1 で捕捉して例外処理を行います。そのため、m2 を見ただけではどこで例外が捕捉されるかわかりません。

これらを適切に処理するために必要になるのは、プログラムカウンタと、それから例外の伝搬になります。

例外の捕捉 (プログラムカウンタ)

例 1 について考えてみます。

どの例外処理で捕捉すべきかは、A を実行中か、B を実行中か、あるいは C〜E を実行中かで変わります。例えば A を実行中に例外が発生しても何もしませんが、B を実行中に発生したなら C で始まる節を実行します。つまり、どこを実行中に例外が起きたかによって、例外処理の内容を変える必要があります。

「どこを実行中か」というのは YARV Maniacs 【第 2 回】 VM ってなんだろうYARV Maniacs 【第 3 回】 命令ディスパッチの高速化 でご紹介している、現在のプログラムカウンタの値を見ることでわかります。例えば、プログラムカウンタがある値の範囲を指していれば、A を実行していたんだな、ということがわかります。

ここで、B はプログラムカウンタ x から y までの範囲だったとしましょう。プログラムを実行する前に行われる、Ruby プログラムから YARV バイトコードへのコンパイル時に、プログラムカウンタが x から y の間に例外が発生したら、C へジャンプする、というような情報を整理して格納しておきます。この例ですと、処理 B と C それぞれに rescue 節がついているので、2 つの情報が必要になります。また、ensure 節にも、同じような情報が必要になります。

YARV では、この辺をテーブルで管理しています。具体的には、rb_iseq_t (バイトコードを表す構造体) の catch_table メンバ変数です。catch_table は iseq_catch_table_entry 構造体の配列となっており、各エントリにはプログラムカウンタがどこからどこまでの間に、例外が発生したらこの例外処理 (捕捉処理) を実行する、という情報が入っています。

例外が発生すると、プログラムカウンタがこの各エントリに合致するか線形に探索し、捕捉するべきかどうかを判断します。この「線形に探索」するというのは、ちょっと遅いような気がしますが (例えば、データ構造をバイナリツリーなどにすれば、もっと高速に探索できる、んじゃないかな。範囲判定の問題)、例外が発生するのは、言葉通り例外的だろう、と思って放置しています。

このテーブルは、iseq ごと、つまりバイトコード列ごとに用意されます。例外処理のブロック (例えば、C で始まるブロック) は、独立したバイトコード列になり、これらは再帰的な構造になっています。

例外の捕捉 (例外の伝搬)

さて、次に m1、m2 が出てくる例 2 を考えてみます。

この例では、m2 で例外を発生させています。しかし、m2 のバイトコード (rb_iseq_t) の情報を見ても、例外を捕捉する箇所がありません。そこで、例外を呼び出し元、つまり m1 に例外を伝搬させることになります。

例外の伝搬はスタックフレームを巻き戻すことで行います。m2 の状態でスタックフレームを 1 つ巻き戻すと、m1 では、m2 の呼び出しのところで例外が発生した、と認識しますので、先述したテーブルの検索によって、RESCUE が実行されることになります。

基本的に、スタックフレームをすべて辿り終わるまで、これを繰り返すことになります。もし、スタックフレームをすべて辿り終わっても、実行するべき例外処理が見つからない場合は例外発生の理由 (例外クラスなど) と例外発生時のスタックトレースを出力してプログラムを終了します。「エラーが落ちて異常終了した」というのはこの状況です。

発展的な内容

おおざっぱな例外処理の理解は、だいたい今回の話で全部なのですが、細かい内容を言い出すといろいろあります。例えば、次の内容は、実際に例外処理を正しく実装しようとすると考えるところではないかと思います。

  • コンパイル時
    • ensure を正しく実装する方法
    • ensure を効率よく実装する方法
    • VM のスタックポインタを正しく調整する方法 (これは,コンパイラ全体の設計の話とも関係)
  • 例外発生時
    • 例外オブジェクト、とくにバックトレースを生成・管理する方法
    • 例外処理を効率よく行う方法

他にもあったかな。なんか色々面倒くさいです。

あと、この例外処理の仕組みを使って、他の機能、例えば似たような機能である catch/throw や、あまり関係なさそうな break、return などの文法要素を実装しています。その辺が YARV の例外処理のコードを複雑にしています。

終わりに

さて、久しぶりの YARV Maniacs でしたが、いかがでしたでしょうか。

個人的には難易度設定がおかしかったというか、簡単な内容を丁寧に、難しい内容をてきとーに説明してしまったような気もするんですが、正直例外処理って難しいところは難しいんですよね (情報量がない文章)。というわけで、例外処理 (初級編) でした。中級編とか上級編なんかをするかどうかは未定です。では、次を気長にお待ち下さい。

著者について

ささだこういち。非学生。久々に VM のソースを見てしまいました。まぁ、いいか。