例外処理
Exception Handling ・ れいがいしょり
想定外の失敗を通常の流れから切り離し、呼び出しを遡って受け止める仕組み。伝播と後始末の設計が頑健さを決める。
✦ 30秒で試す — 航海エラーは、呼び出した順と逆向きに遡っていく途中の「設定の読み込み」の残りの行は、どうなる?予想してみる概要
例外処理は、プログラムの実行中に起きる「想定外の失敗」を、正常な処理の流れから切り離して扱うための仕組みです。ファイルが存在しない、ネットワークが切れた、数のはずの入力が文字列だった — こうした失敗はどんなプログラムでも避けられません。例外処理は、失敗が起きた瞬間に通常の実行を中断し、あらかじめ用意しておいた「失敗の受け皿」まで制御を飛ばすことで、正常系のコードと異常系のコードを分けます。
多くの言語では try(失敗するかもしれない処理を囲む)、throw / raise(失敗を投げる)、catch / except(投げられた失敗を受け止める)、finally(成功しても失敗しても必ず実行する後始末)の組で表現されます。Java・Python・JavaScript・C++ など主流言語の大半がこのモデルを採っており、日々のコーディングで最も頻繁に向き合う言語機能のひとつです。
例外処理は「書けば終わり」の構文ではなく、設計の問題でもあります。どこで投げ、どこまで伝わらせ、どの層で受け止めて回復するか。途中で抜けたときに何を片付けるか。この判断の質が、障害時に「静かに壊れるシステム」と「原因がすぐ分かるシステム」の差を生みます。
なぜ生まれたか
例外の仕組みが広まる前、失敗は関数の戻り値で表すのが普通でした。C 言語では「成功なら 0、失敗なら負のエラーコードを返す」という約束が典型で、この方式には2つの根深い問題があります。第一に、呼び出し側が戻り値の確認を忘れても、コンパイルも実行も普通に進んでしまうこと。失敗は誰にも気づかれないまま握り潰され、まったく別の場所で不可解なバグとして噴き出します。第二に、確認を真面目に書くと、関数を1つ呼ぶたびに if (err != 0) return err; のような定型文が挟まり、本来やりたい処理がエラー確認の海に埋もれてしまうことです。
例外処理はこの2つを同時に解決します。失敗を「無視できない形」で投げれば、誰かが受け止めるまで自動的に呼び出し元へ伝わっていくため、確認忘れによる握り潰しが起きません。正常系のコードは、途中のエラー確認を書かずに「うまくいく場合の流れ」だけを素直に書けます。
系譜をたどると、1960年代の PL/I が「ON 条件」(ゼロ除算やファイル終端などの条件に対して、あらかじめ処理を登録しておく仕組み)を持っていました。1970年代には MacLisp が catch / throw で深い呼び出しから一気に抜ける非局所脱出を導入し、Barbara Liskov らの CLU は、関数が投げうる例外をシグネチャに書き、投げられたら呼び出しを打ち切って受け皿へ移る、という現在の例外の形を整えました。1983年の Ada が例外を言語標準に組み込み、C++ と Java がこのモデルを広めて、いまや手続き型の言語の標準装備になっています。
詳細
例外はコールスタックを遡る
例外の核心は「伝播」の仕組みにあります。関数呼び出しはスタックに積み重なっていきますが、例外が投げられると、実行系は現在の関数を即座に中断し、呼び出し元、そのまた呼び出し元へとスタックを遡りながら catch を持つ段を探します。これをスタックの巻き戻し(アンワインド)と呼びます。途中の関数は何も書かなくても例外を素通しで上へ渡すため、「深い場所で起きた失敗を、ずっと上の層でまとめて受け止める」ことができます。最後まで誰も受け止めなければ、プログラム全体が異常終了します。
この自動伝播こそが戻り値方式との最大の違いです。ただし裏返せば「どこから飛んでくるか分からない goto」でもあるため、後始末には注意が要ります。ファイルを開いた関数の途中で例外が飛ぶと、閉じる行まで実行が届きません。そこで多くの言語が、finally 節や C++ の RAII(オブジェクトの破棄に後始末を結びつける書き方)、Python の with、Go の defer のように、「途中で抜けても必ず実行される後始末」の仕組みを持っています。巻き戻しの順序を時間で追うと次のようになります。
次の部品で確かめてください。各段に catch と finally を付け外ししてから、いちばん上で throw を押します。例外が段を1つずつ遡り、崩れていく段の finally を上から順に実行して、最初に出会った catch で止まります。どの段にも catch がなければ、底を突き抜けてプログラムごと落ちます。
- (まだ何も起きていません)
いちばん上の readFile で throw を押してください。各段の catch・finally の札は、押すと付け外しできます。
例外安全 — 途中で抜けても壊さない
後始末の次に考えるのが、例外で途中から抜けたときにデータの辻褄が壊れないか、という「例外安全」です。たとえば「口座 A から引いて、口座 B に足す」処理で、引いた直後に例外が飛ぶと、お金が消えた状態が残ります。C++ の世界ではこれを3段階の保証として整理します。何が起きてもリソースを漏らさず不変条件を保つ「基本保証」、失敗したら呼ぶ前の状態に完全に戻る「強い保証」、そもそも例外を投げない「不送出保証」です。強い保証の定石は、コピーの上で全部の変更を済ませてから、最後に例外の起きない入れ替えで反映することです。データベースのトランザクションは、同じ考え方をデータの側で実現したものです。
非同期処理と例外
try / catch が受け止められるのは、同じコールスタックの上で起きた例外だけです。JavaScript で setTimeout やコールバックの中で投げられた例外は、呼び出したときのスタックがすでに消えたあと、イベントループから改めて呼ばれた別のスタックで起きるため、外側の try には届きません。Promise では、失敗は「拒否(rejection)」という値として Promise に記録され、catch を付けていないと「処理されない拒否(unhandled rejection)」になります。ブラウザはこれを警告として出し、Node.js は既定でプロセスを終了させます。async / await は、拒否を await の地点で例外として投げ直すことで、非同期のコードでも同期と同じ try / catch で受け止められるようにした仕組みです。
検査例外の教訓
Java は「検査例外(checked exception)」という野心的な設計を試みました。関数が投げうる例外を型シグネチャに書かせ、呼び出し側に捕まえるか再宣言するかをコンパイル時に強制する — 失敗の握り潰しを型システムで防ごうという、CLU から受け継いだ発想です。しかし実際には、面倒になった開発者が catch (Exception e) {} と空の catch で黙殺する、意味を考えず機械的に throws を連鎖させる、といった形骸化が広がりました。C# や Kotlin など後発の言語が検査例外を採らなかったのは、この経験を踏まえてのことです。「強制するだけでは良いエラー処理は生まれない」というのが検査例外が残した教訓ですが、「失敗の可能性を型で表す」という発想自体は、次の Result 型へ形を変えて受け継がれています。
例外か、Result 型か
近年の言語には、例外とは逆方向のアプローチを選ぶものが増えています。Rust は失敗しうる計算を Result<T, E>(成功値かエラー値のどちらかを持つ型)で返し、Go は value, err := f() という多値返却を約束にしています。一見「戻り値方式への先祖返り」に見えますが、決定的な違いがあります。Rust の Result は成功値を取り出すためにエラーの場合分けが型として強制され、C のように確認を忘れることができません。エラーが正常値と同じ「ただの値」になることで、関数型プログラミングの道具(map やチェーンによる合成)でエラーの流れを組み立てられるのも利点です。例外が「エラーは例外的だから通常の流れから隠す」哲学だとすれば、Result 型は「エラーも戻り値の一種として明示的に流す」哲学であり、失敗が日常茶飯事の I/O や分散システムでは後者の見通しの良さが効いてきます。
実務の指針 — どこで捕まえ、どう扱うか
例外処理の設計でまず守るべきは「握り潰さない」ことです。空の catch 節は失敗の証拠を消し去り、後日まったく別の症状として発覚する時限爆弾になります。捕まえたものの自分の層では対処できない場合は、「設定ファイルの読み込みに失敗した」のような文脈を付けて投げ直し、元の例外を原因(cause)としてつなぎます。こうしておけば、最終的なログに「何をしようとして、根本原因は何だったか」の連鎖が残ります。
次に「回復できる層で捕まえる」こと。ファイルを読む関数の中では、失敗にどう対処すべきか(リトライか、既定値か、利用者への通知か)を決められません。判断材料を持つのは、より文脈を知っている呼び出し側の層です。したがって低い層では原則として捕まえずに伝わらせ、Web のサーバならリクエストハンドラの最上位、CLI ツールなら main の直下といった「境界」に集約の受け皿を置いて、そこでログ記録・エラー応答への変換・後始末をまとめて行うのが定石です。
最後に、想定内の失敗(入力の検証エラーなど)と想定外のバグ(null 参照など)を同じ catch で混ぜないこと。前者は丁寧に回復し、後者はむしろ早く大きく失敗させて検知につなげるべきものです。例外は制御フローの道具ではなく、あくまで「異常の通知と回復」の道具として使う — この線引きが、読みやすく頑健なコードへの近道です。
試問
下から main → handleRequest → parseConfig → readFile の順に積まれた呼び出しの段(コールスタック)です。各段の catch(例外を受け止める)と finally(成功しても失敗しても必ず実行する後始末)の札は押すと付け外しでき、catch は受け止めたときの対応(readFile は空の中身を返す・parseConfig は既定の設定を使う・handleRequest は 500 を返す)が決まっています。いちばん上の readFile で throw を押すと、例外が投げられます。
- (まだ何も起きていません)
いちばん上の readFile で throw を押してください。各段の catch・finally の札は、押すと付け外しできます。
