例外処理
Exception Handling ・ れいがいしょり
想定外の失敗を通常の流れから分離して扱う仕組み。伝播と回復の設計がプログラムの頑健さを決める。
概要
例外処理は、プログラムの実行中に起きる「想定外の失敗」を、正常な処理の流れから切り離して扱うための仕組みです。ファイルが存在しない、ネットワークが切れた、数値のはずの入力が文字列だった — こうした失敗はどんなプログラムでも避けられません。例外処理は、失敗が起きた瞬間に通常の実行を中断し、あらかじめ用意しておいた「失敗時の受け皿」まで制御を飛ばすことで、正常系のコードと異常系のコードを分離します。
多くの言語では try(失敗するかもしれない処理を囲む)、throw / raise(失敗を宣言して投げる)、catch / except(投げられた失敗を受け止める)という三点セットで表現されます。Java・Python・JavaScript・C++ など主流言語の大半がこのモデルを採用しており、日々のコーディングで最も頻繁に向き合う言語機能のひとつです。
一方で、例外処理は「書けば終わり」の構文ではなく、設計の問題でもあります。どこで投げ、どこまで伝播させ、どの層で受け止めて回復するか。この判断の質が、障害時に「静かに壊れるシステム」と「原因がすぐ分かるシステム」の差を生みます。
なぜ生まれたか
例外機構が普及する前、失敗は関数の戻り値で表現するのが普通でした。C言語では「成功なら 0、失敗なら負のエラーコードを返す」という規約が典型で、この方式には二つの根深い問題があります。第一に、呼び出し側が戻り値の確認を忘れても、コンパイルも実行も普通に進んでしまうこと。失敗は誰にも気づかれないまま握り潰され、まったく別の場所で不可解なバグとして噴出します。第二に、確認を真面目に書くと、関数を1つ呼ぶたびに if (err != 0) return err; のような定型文が挟まり、本来やりたい処理がエラー確認の海に埋もれてしまうことです。
例外処理はこの二つを同時に解決します。失敗を「無視できない形」で投げれば、誰かが受け止めるまで自動的に呼び出し元へ伝わっていくため、確認忘れによる静かな握り潰しが起きません。そして正常系のコードは、途中のエラー確認を書かずに「うまくいく場合の流れ」だけを素直に記述できます。1960年代の LISP や PL/I で原型が生まれ、C++ と Java が広めたこのモデルは、いまや手続き型言語の標準装備になっています。
詳細
例外はコールスタックを遡る
例外の核心は「伝播」の仕組みにあります。関数呼び出しはスタックに積み重なっていきますが、例外が投げられると、実行系は現在の関数を即座に中断し、呼び出し元、そのまた呼び出し元へとスタックを遡りながら(スタックアンワインディングと呼びます)、catch を持つフレームを探します。途中の関数は何も書かなくても例外を素通しで上へ渡すため、「深い場所で起きた失敗を、ずっと上の層でまとめて受け止める」ことができます。最後まで誰も受け止めなければ、プログラム全体が異常終了します。
この自動伝播こそが戻り値方式との最大の違いです。ただし裏返せば、「どこから飛んでくるか分からない goto」でもあるため、リソースの後始末には注意が要ります。多くの言語が finally 節や、C++ の RAII・Python の with・Go の defer のような「途中で抜けても必ず実行される後始末」の仕組みを併せ持っているのはこのためです。
検査例外の教訓
Java は「検査例外(checked exception)」という野心的な設計を試みました。関数が投げうる例外を型シグネチャに明記させ、呼び出し側に捕捉か再宣言をコンパイル時に強制する — 失敗の握り潰しを型システムで防ごうという発想です。しかし実際には、面倒になった開発者が catch (Exception e) {} と空の catch で黙殺する、意味を考えず機械的に throws を連鎖させる、といった形骸化が蔓延しました。C# や Kotlin など後発の言語が検査例外を採用しなかったのは、この経験を踏まえてのことです。「強制するだけでは良いエラー処理は生まれない」というのが検査例外が残した教訓ですが、「失敗の可能性を型で表す」という発想自体は、次に述べる Result 型へと形を変えて受け継がれています。
例外か、Result 型か
近年の言語は、例外とは逆方向のアプローチを選ぶものが増えています。Rust は失敗しうる計算を Result<T, E>(成功値かエラー値のどちらかを持つ型)で返し、Go は value, err := f() という多値返却を規約にしています。これは一見「戻り値方式への先祖返り」に見えますが、決定的な違いがあります。Rust の Result は成功値を取り出すためにエラーの場合分けが型として強制され、C のように確認を忘れることができません。エラーが正常値と同じ「ただの値」になることで、関数型プログラミングの道具(map やチェーンによる合成)でエラーの流れを組み立てられるのも利点です。例外が「エラーは例外的だから通常の流れから隠す」哲学だとすれば、Result 型は「エラーも正常な戻り値の一種として明示的に流す」哲学であり、失敗が日常茶飯事であるIO処理や分散システムでは後者の見通しの良さが効いてきます。
実務の指針 — どこで捕まえ、どう扱うか
例外処理の設計でまず守るべきは「握り潰さない」ことです。空の catch 節は、失敗の証拠を消し去り、後日まったく別の症状として発覚する時限爆弾になります。捕まえたものの自分の層では対処できない場合は、「設定ファイルの読み込みに失敗した」のような文脈を付けて再送出し、元の例外を原因(cause)として連結します。こうしておけば、最終的なログに「何をしようとして、根本原因は何だったか」の連鎖が残ります。
次に「回復できる層で捕まえる」こと。ファイル読み込み関数の中では、失敗にどう対処すべきか(リトライか、デフォルト値か、ユーザーへの通知か)を決められません。判断材料を持つのは呼び出し側の、より文脈を知っている層です。したがって低い層では原則として捕まえずに伝播させ、Webサーバならリクエストハンドラの最上位、CLIツールなら main の直下といった「境界」に集約ハンドラを置いて、そこでログ記録・エラーレスポンス変換・後始末を一括して行うのが定石です。想定内の失敗(入力バリデーションなど)と想定外のバグ(null 参照など)を同じ catch で混ぜないことも重要で、前者は丁寧に回復し、後者はむしろ早く大きく失敗させて検知につなげるべきものです。例外は制御フローの道具ではなく、あくまで「異常の通知と回復」の道具として使う — この線引きが、読みやすく頑健なコードへの近道です。
