例外処理¶
throw された値は、それを受け止める catch が見つかるまで、呼び出しスタックを何段も遡って伝播します。その途中に finally があれば必ず実行し、try の中で起きた return や break すら横断していきます。この「実行の途中で制御を投げ出し、あるべき場所まで巻き戻す」振る舞いを、エンジンはインタプリタの内側でどう実装するのか。この章では、throw が起きたときのスタック巻き戻し、それを支える完了レコードという考え方、代表的な二つの実装モデルとそのトレードオフ、そして finally が絡んだときに崩れやすい落とし穴を扱います。
try/catch/finally が要求すること¶
例外処理の言語仕様は、突き詰めると次の三つの要求に整理できます。
- 巻き戻し:
throwが起きたら、現在の実行地点から、それを囲むもっとも内側のcatch(またはfinally)まで制御を移す。途中の関数フレームは、通常のreturnを経由せずに放棄される。 - finally の確実な実行:
tryブロックを抜ける経路が何であれ — 正常終了・例外・return・break・continueのいずれであっても — 対応するfinallyは必ず一度実行される。 - 結末の保存と再開:
finallyを実行するあいだ、「本来この後どうなるはずだったか」(例外を投げている最中なのか、値を返そうとしていたのか)を覚えておき、finallyが正常に終わったらその結末を再開する。
一つ目の巻き戻しがもっとも目を引きますが、実装の難所は二つ目と三つ目、とりわけ finally を挟んだ結末の受け渡しにあります。throw そのものは「制御をジャンプさせる」だけですが、finally は「一度中断した結末を、あとで正しく蘇らせる」必要があるからです。
ネイティブの世界では、C++ 例外や setjmp/longjmp のようなホスト言語の巻き戻し機構をそのまま借りることも考えられます。しかし JavaScript の例外は finally の意味論や完了値の扱いが仕様で細かく定まっており、ホスト言語の機構に丸投げすると制御しきれません。バイトコードを解釈するエンジンでは、例外の伝播も自前のバイトコード実行ループの中で扱うのが一般的です。
完了レコード — 実行の結末を一つの型で扱う¶
実装を見通しよくする鍵は、「文や式を評価した結果」を一種類のデータにまとめてしまうことです。ECMAScript 仕様はこれを 完了レコード(Completion Record) と呼びます。すべての評価は、値を返すのではなく、次のような結末の記述を返すと考えます。
Completion = {
type: normal / throw / return / break / continue
value: 結末に伴う値(返り値・投げられた値など)
target: break/continue が狙うラベル(該当時)
}
normalは「普通に評価が終わった」結末で、valueはその式の値。throwは「例外が発生した」結末で、valueは投げられた値。return/break/continueは、いわゆる 中断完了(abrupt completion) で、制御を通常の順序から外す。
この抽象化の利点は、例外処理を「特別な制御フロー」としてではなく、「完了レコードの type を見て振る舞いを決める通常のデータ処理」として書けることです。throw は type: throw の完了を生成する操作にすぎず、try 文の評価は「本体の完了レコードを受け取り、その type に応じて catch や finally に振り分ける」処理として記述できます。仕様の擬似コードが例外を明示的な制御構文なしに定義できているのは、この完了レコードのおかげです。
エンジンの実装がこの完了レコードをそのままメモリ上の構造体として持つとは限りません。多くのバイトコードエンジンでは、normal は「普通に次の命令へ進む」、throw は「保留中例外フラグを立てる」、return は「呼び出しフレームを畳んで返る」というように、type ごとに別々の機構へ写像します。それでも、設計を考えるうえでは「いまどの type の完了が飛んでいるのか」「その value をどこに保持しているのか」を完了レコードの語彙で捉えると、後述する finally の重ね合わせが整理しやすくなります。
巻き戻しの二つの実装モデル¶
throw が起きたときに、どうやって「囲みのハンドラ」を見つけ、そこへ飛ぶか。実装には大きく二つの流儀があり、多くのバイトコードエンジンは両者を組み合わせて使います。
方式1: ハンドラテーブル(例外テーブル)¶
コンパイラは、関数をバイトコードに落とすときに、try ブロックがバイトコード上のどの範囲(命令の区間)に対応するかを記録します。区間ごとに「この範囲で例外が起きたら、どの命令位置へ飛べばよいか(対応する catch/finally の入口)」を表にしておきます。これが ハンドラテーブル(例外テーブル) です。
関数フレームのハンドラテーブルは、概念的には次の形です。
| 区間 [開始IP, 終了IP) | ハンドラIP | 種別 |
|---|---|---|
| [10, 24) | 30 | catch |
| [5, 40) | 45 | finally |
実行中に throw が起きると、エンジンは現在の命令ポインタ(IP)を鍵に、そのフレームのテーブルから「IP を含む区間」のハンドラを探します。見つかればそのハンドラ IP へジャンプし、投げられた値を所定の場所(catch の束縛変数やオペランドスタック)に置いて実行を続けます。同じ IP を複数の区間が囲む場合は、もっとも内側(より狭い区間)を優先します。
現在のフレームに該当区間がなければ、そのフレームには受け手がいないということです。エンジンはフレームを一段捨て、呼び出し元のフレームへ移り、その呼び出し地点の IP で同じ検索を繰り返します。こうしてフレームを一段ずつ畳みながら、ハンドラが見つかるフレームまで遡るのが スタック巻き戻し の実体です。どのフレームにも見つからないままスタックの底まで達したら、その例外は捕捉されなかった(uncaught)ものとして処理されます。
flowchart TD
A["throw 発生"] --> B["現在フレームのハンドラテーブルを検索(IP を含む区間を探す)"]
B -. "該当区間あり" .-> C["ハンドラ IP へジャンプし、投げられた値を catch へ渡す"]
B -. "該当区間なし" .-> D["フレームを一段畳む"]
D -. "呼び出し元が残っている" .-> E["呼び出し元フレームへ移り、呼び出し地点の IP で再検索"]
E --> B
D -. "スタックの底に到達" .-> F["uncaught として処理"]
C --> G["ハンドラで実行を継続"]
throw が起きると、ハンドラが見つかるフレームまでフレームを一段ずつ畳みながら遡り、どこにも無ければ uncaught となる。
この方式の要点は、例外が起きない限りコストがゼロに近い ことです。テーブルは静的なデータで、正常実行のパスには一切の分岐も命令も挿入されません。コストを払うのは実際に throw が起きた瞬間のテーブル検索だけです。例外は「例外的」であるべき、という前提によく合います。
方式2: 保留中例外フラグ¶
もう一つは、エンジンの実行状態に「保留中例外(pending exception)」を置く場所を一つ設け、エラーを起こしうる操作はすべて、失敗を「保留中例外をセットして戻る」ことで通知する方式です。命令を実行するインタプリタループは、各操作のあとにこのフラグを検査し、立っていれば通常のディスパッチをやめてハンドラ探索に移ります。
保留中例外がセットされていれば:
現在のフレームのハンドラを探す
あれば → そのハンドラへ飛び、保留中例外をクリアして catch に渡す
なければ → フレームを畳んで呼び出し元へ、同じ検査を続ける
この方式は、throw 文だけでなく、あらゆるネイティブ操作やオペコードが失敗を報告する共通の経路 になる点が大きな利点です。プロパティアクセスが例外を投げる、算術が TypeError を起こす、組み込み関数が引数を拒否する — これらはすべて「保留中例外をセットして戻る」に統一できます。呼び出し側は戻り値で成否を確かめる代わりに、共通のフラグを見ればよくなります。C API を通じてホストへ制御が渡る境界でも、この明示的なフラグは「いま例外が伝播中か」を機械的に判定でき、扱いやすくなります。
欠点は、チェックのコストが正常実行のパスにかかる ことです。フラグ検査を厳密にやると、例外を起こしうる操作のたびに分岐が一つ増えます。ただし多くの実装では、ハンドラの探索そのものには方式1のテーブルを流用し、「失敗の通知手段」としてフラグを使うという折衷をとります。つまり保留中例外はエラーの発生を運ぶ器であり、実際に「どこへ飛ぶか」はフレームごとのハンドラテーブルで解決する、という組み合わせです。
二つの方式の比較¶
| 観点 | ハンドラテーブル | 保留中例外フラグ |
|---|---|---|
| 正常実行のコスト | ほぼゼロ(静的データのみ) | 操作ごとのフラグ検査が乗る |
| throw 時のコスト | テーブル検索+ジャンプ | 検索は同様、加えてフラグ管理 |
| エラー通知の一元化 | throw 命令に閉じがち | 全オペコード・ネイティブで共通化しやすい |
| ネイティブ境界との相性 | 別途規約が要る | フラグ検査で自然に扱える |
| 実装の見通し | ジャンプ機構が明快 | 失敗経路が単純で追いやすい |
多くのバイトコードエンジンは、この二つを排他とはせず、フレームごとのハンドラテーブルで「どこへ飛ぶか」を解き、保留中例外の状態で「いま失敗が伝播中か」を運ぶ、という組み合わせに落ち着きます。テーブルが正常パスのコストを抑え、フラグがエラー通知とネイティブ境界を一元化する、という互いの長所を活かせるためです。
finally の意味論と完了値の受け渡し¶
catch は比較的単純です。難しいのは finally で、これは「どんな結末で try(または catch)を抜けようとしても、その結末をいったん保留して割り込む」ブロックだからです。仕様上、finally が走る状況は次のいずれかです。
- 正常終了:
tryが普通に終わった。finallyを実行し、終わったら普通に続行する。 - 例外:
tryの中で例外が起きた。finallyを実行し、終わったら その例外を投げ直す。 - 中断完了の横断:
tryの中のreturn/break/continueがfinallyを跨いで外へ出ようとしている。finallyを実行し、終わったら その中断完了を再開する(たとえば保留していた返り値で関数から戻る)。
つまり finally の入口では、エンジンは「本来この後やるはずだった結末」= 保留中の完了レコード(type と value)を退避しておかなければなりません。そして finally の本体を実行し、その結果に応じて次を決めます。
finally 自身が中断完了を生んだ場合、それが保留中の完了を上書きする、というのが仕様の要です。finally の中で return(あるいは新たな throw)が実行されたら、退避しておいた元の結末は捨てられ、finally の生んだ結末が勝ちます。これは直感に反することがあります。
function f() {
try {
return "try"; // この返り値は保留される
} finally {
return "finally"; // finally の return が勝ち、"try" は破棄される
}
}
f(); // => "finally"
同じことは例外にも起こります。try で例外が飛んでいても、finally が正常に完了すればその例外は投げ直されますが、finally の中で新たに throw したり return したりすると、飛んでいた例外は握りつぶされ、finally の結末に置き換わります。
この振る舞いを実装するには、finally を「保留中の完了レコードを退避 → finally 本体を実行 → 本体が normal なら退避した完了を再開、本体が abrupt なら退避を破棄して本体の完了を採用」という手続きとして書けばよいことになります。完了レコードという共通の型を導入しておくと、この「退避と再開、あるいは上書き」を type と value の付け替えだけで表現でき、例外・return・break・continue を場合分けの少ない一つのロジックで扱えます。
バイトコードでは、コンパイラが finally の本体を、ここへ至る各経路(正常・例外・return・各ラベルの break/continue)から共有できるように配置し、入口で「どの結末で来たか」と「その値」を退避スロットへ、出口でそれを読み戻して分岐する、というコードを生成します。finally の中を通る break/continue が外側のループの finally をさらに跨ぐ、といった入れ子では、この退避と再開が何段も積み重なります。ここを取り違えると、後述するような取りこぼしが生まれます。
保留中例外モデルと命令ごとのチェック¶
保留中例外は「セットしたらできるだけ早く検査して処理する」ことが本質的に重要です。例外は その場で実行を止めなければならず、後回しにできない からです。もし失敗した操作のあとに一命令でも通常のディスパッチを続けてしまえば、無効な状態(たとえば失敗した演算の結果として置かれたゴミの値)の上でさらに処理が進み、観測される挙動が仕様からずれます。
このため、保留中例外の検査は原理的には 命令の境界ごと に行う必要があります。ここで前提となるのが、インタプリタが命令をまたぐ地点を「状態が一貫している安全な地点」として設計しているという事実で、これは「インタプリタ実行」の章で扱ったセーフポイントの考え方と同じ土台に乗ります。命令境界は、割り込み(実行時間制限やホストからの停止要求)を受け付ける地点でもあり、ガベージコレクタが動きうる地点でもあります。保留中例外のチェックも、この命令境界に相乗りさせるのが自然です。エラーを起こしうるオペコードの直後に検査を置く、あるいはディスパッチループの先頭で毎回検査する、といった形になります。
実装上は、すべての命令の後に無条件でチェックを入れると分岐が増えます。そこで、「そもそも例外を起こしえない命令(定数ロードやスタック操作など)の後では検査を省く」といった絞り込みや、失敗経路を専用のディスパッチ先へまとめて通常経路の分岐を減らす工夫が使われます。いずれにせよ、検査を省いてよいのは本当に例外を起こしえない命令に限る という原則を外すと、例外が一命令ぶん遅れて観測される、という再現しにくいバグになります。
エラーオブジェクトとスタックトレース¶
投げられる値は任意の JavaScript 値ですが、実務では Error とその派生が大半です。Error を特別にするのは スタックトレース で、これは「その Error オブジェクトが生成された時点の呼び出し履歴」を文字列などの形で保持したものです。
トレースを取るには、Error の生成時点(多くの実装では throw ではなく new Error() の瞬間)に、現在の呼び出しフレームを底まで辿り、各フレームの関数名・スクリプト位置(命令ポインタから逆引きした行・列)を集めます。ここには無視できないコストがあります。
- キャプチャのコスト: スタックを全段歩くため、深い呼び出しの下で大量の
Errorを生成すると、その走査だけで重くなる。 - 文字列化のコスト: 位置情報を人間可読な文字列へ整形する処理は特に重い。そこで、生成時には各フレームの生の情報(関数と命令位置)だけを軽く捕まえておき、実際に
.stackが読まれたときに初めて文字列へ整形する 遅延生成 がよく使われる。多くのコードは.stackを読まないため、この遅延で無駄を大きく減らせる。
例外がホットパス(例外を制御フローとして濫用するコードなど)で頻発する場合、このキャプチャコストが支配的になることがあります。エンジン側では上記の遅延生成やフレーム数の上限で緩和しますが、根本的には「例外を通常の制御フローに使わない」という利用側の設計が効きます。
実装上の罠¶
罠1: 伝播中の完了値は GC ルートでなければならない¶
巻き戻しの最中、投げられている例外値や、finally を跨ぐために退避してある返り値は、まだどの通常のオブジェクトからも参照されていない、宙に浮いた生きた値 です。ソーススタックからは既に外れ、行き先のハンドラにもまだ渡っていない、その隙間に存在します。
この値を、巻き戻しの一時領域(ネイティブのローカル変数や、GC から見えない退避スロット)にだけ置いていると、巻き戻しの途中で走るガベージコレクタから到達できず、回収されてしまいます。巻き戻しは何段ものフレームを畳む処理で、その過程で finally の本体が動けば、そこでの割り付けを契機に GC が走りえます。「ルート集合と到達可能性」の章で述べたとおり、GC が値を生かす根拠はルートからの到達可能性だけであり、退避中の完了値もルートに含めなければなりません。
対策は、投げられている値・退避中の返り値を、巻き戻しのあいだ GC 可視の場所(実行状態の固定スロットや、値を根付かせるハンドル)に保持することです。保留中例外を保持する専用スロットを設けているなら、そのスロットを GC のルート走査に必ず含めます。finally の退避スロットも同様です。これを怠ると、finally が確保を起こしたときにだけ、退避中の返り値や伝播中の例外が化ける、という再現の難しいバグになります。ネイティブ境界での一時値の保護と本質的に同じ問題で、対処も同じく「伝播中の値を GC から見える場所に置く」ことに尽きます。
罠2: finally の完了レコード優先順位を取り違える¶
前述のとおり、finally が中断完了を生んだときは、それが保留中の完了を上書きします。この優先順位を実装で取り違えると、症状は微妙で発見しにくいものになります。
- 上書きの取りこぼし:
finallyの中のreturn/throwが、伝播中だった例外や保留中の返り値を正しく捨てられていないと、破棄されるべき古い結末が復活してしまう。たとえばfinallyでreturnしたのにtryの例外が漏れ出す、といった誤り。 - 逆に、握りつぶしすぎ: 退避と再開の順序を誤って、
finallyが normal に終わったのに元の例外を再送しそこねると、本来伝播すべき例外が消える。 - 入れ子の取り違え:
try/finallyが入れ子になり、break/continueが複数のfinallyを跨ぐとき、各段の退避と再開を正しい順で積み上げないと、跨ぐべきfinallyを飛ばす、あるいは行き先のラベルを見失う。
これらは「例外が起きたときだけ」「特定の入れ子のときだけ」現れるため、正常系のテストをすり抜けがちです。完了レコードの type と value を明示的なデータとして扱い、「保留 → 本体実行 → 本体が abrupt なら本体を採用、normal なら保留を再開」という一つの規則にロジックを寄せておくと、場当たりな分岐に比べて取り違えを起こしにくくなります。仕様の try 文の意味論を、この完了レコードの言葉でそのまま写し取るのが、もっとも安全な実装方針です。
まとめ¶
- 例外処理は「巻き戻し」「finally の確実な実行」「結末の保存と再開」の三要求からなり、難所は throw そのものより finally を挟んだ結末の受け渡しにある。
- すべての評価結果を type と value を持つ完了レコードとして扱うと、例外・return・break・continue を場合分けの少ない共通ロジックで実装でき、finally の重ね合わせも整理できる。
- 巻き戻しの実装は、正常パスのコストがほぼゼロなハンドラテーブルと、失敗通知を一元化できる保留中例外フラグの二流儀があり、多くのエンジンは「テーブルで飛び先を解き、フラグで失敗を運ぶ」折衷をとる。
- finally は保留中の完了をいったん退避し、本体が中断完了を生めばそれが元の結末を上書きする。この優先順位を仕様どおりに写すことが正しさの鍵。
- 保留中例外の検査は命令境界ごとに行う必要があり、これはインタプリタのセーフポイント設計と同じ土台に乗る。例外は後回しにできない。
- 伝播中の例外値や finally を跨ぐ退避値は宙に浮いた生きた値であり、巻き戻し中の GC から守るために必ずルートに含めなければならない。
参考文献¶
- ECMA-262: Completion Record 仕様型 — 評価結果を type・value・target で表す完了レコードの定義。
- ECMA-262: The try Statement — try/catch/finally の実行時意味論。
- ECMA-262: The throw Statement — throw が throw 完了を生成する定義。
- ECMA-262: Error Objects — Error とその標準プロパティ。