1 例外安全性の概要

例外安全性とは、プログラムが例外を扱う際に、失敗が途中で起きても状態が壊れないように設計する考え方、および具体的な実装手法を指す。主対象は、例外が「発生し得る」操作の前後における整合性と、失敗時に残る後始末や後続処理の安全性である。呼び出し側が期待する挙動と、実装側が負う責務を明確に分け、失敗が伝播してもシステム全体が破綻しないことを目標とする。

1.1 例外安全性が扱う「不正な状態」とは

例外安全性の議論でいう「不正」とは、単に異常終了することに限らない。例外が起きた後に、データ構造が壊れて読めなくなる、リソースが回収されない、オブジェクトが成立しているはずの条件が崩れる、といった事象が含まれる。これらはその後の処理でさらに被害を拡大させるため、設計時点で回避または局所化する。

1.1.1 不変条件の破れ

オブジェクトには、生成後に常に満たされるべき性質(不変条件)がある。例外によって初期化の途中で中断されると、不変条件が満たされないままオブジェクトが観測される危険が生じる。たとえば、内部の整合性を前提にした探索構造で参照が欠ける、あるいはサイズ情報だけが更新されて矛盾するなどが該当する。例外安全性は、不変条件が破れた状態を外部に見せない、もしくは破れた状態を回復可能にすることを狙う。

1.1.2 部分更新による整合性崩壊

複数のフィールドや複数のコンテナを段階的に更新する処理では、途中の段階で例外が発生すると一部だけが反映される。結果として、関連する値が同じ時点の整合性を持たず、以後の計算が誤る。部分更新の問題は「更新順序」と「例外が起きる可能性のある位置」が密接に関係するため、更新を段階化して安全にコミットする、または変更前の状態を保持して復帰できるようにするなどの設計が必要になる。

1.1.3 リソースリーク

例外発生時に確保した資源が回収されないと、メモリ、ハンドル、ロックファイルディスクリプタなどが漏れる。漏れは直接のクラッシュだけでなく、枯渇による後続失敗を連鎖させる。さらに、解放順序が乱れることで未定義挙動につながる場合もあるため、資源のライフサイクルを例外経路にも確実に適用する仕組みが要点となる。

1.2 例外安全性の目的と保証の範囲

例外安全性は「何を、どこまで」保証するかが重要である。保証範囲を曖昧にすると、呼び出し側が期待している復旧性と、実装側が守れる現実の挙動が食い違う。目的は、例外が発生してもデータと資源の整合性が壊れず、呼び出し側が安全に次の手へ進める状態にすることである。

1.2.1 呼び出し側の期待

呼び出し側は、例外が起きた場合に少なくとも「破綻した状態ではない」こと、また可能なら「処理の再試行や別経路への移行」ができることを期待する。たとえば、関数呼び出しが失敗した後に同じオブジェクトを再利用できるのか、あるいは破棄して作り直す前提になるのか、といった前提が必要になる。呼び出し側の期待は、仕様として明文化されるべきであり、それに合わせて保証レベルが選択される。

1.2.2 実装側の責務

実装側は、例外が発生し得る箇所を特定し、成功時・失敗時それぞれにおける観測可能な状態を設計する責務を負う。具体的には、変更を安全に進めるための順序付け、資源管理の自動化ロールバックや再構築の手段、例外境界での捕捉方針などを用意することが含まれる。さらに、保証できないことを暗黙に放置せず、意図した挙動を仕様として示すことが求められる。

2 保証レベル(基本分類

保証レベルは、例外発生時にどの程度まで状態を保つかを段階的に整理した枠組みである。代表的には基本保証、強い保証、移動保証があり、加えて「例外を投げない」設計(非例外保証)も議論される。実装者は目的に応じて適切な強度を選ぶが、強い保証ほど設計・実装コストや性能コストが増える傾向があるため、現実的な選択が重要になる。

2.1 基本保証

基本保証とは、例外が発生した場合でもプログラムの不正な状態を避け、対象が破綻しないことを要求する考え方である。具体的には、不変条件が維持される、リソースが漏れない、オブジェクトが中途半端な壊れ方をしない、などが中心になる。一方で、部分的に変更された値は元に戻らない可能性があるため、呼び出し側は失敗後の再試行方針を慎重に決める必要がある。

2.1.1 例外後の整合性維持

整合性維持の対象は、データ構造の意味的整合性と資源の所有関係の双方である。たとえば、コンテナの内部表現が壊れず、参照先無効にならないこと、ロックが確実に解放されること、破棄時に二重解放が起きないことが含まれる。基本保証は「壊さない」ことに軸足があり、復元までを保証するとは限らないため、実装では途中更新の影響を局所化して破綻を防ぐ工夫が採られる。

2.2 強い保証

強い保証は、例外が発生した場合に、操作が「なかったのと同等」であることを目指す。つまり、例外後の状態は例外が起きる前の状態に復帰する(ロールバックされる)ことが要点になる。この強さは呼び出し側の扱いやすさを大きく向上させ、失敗時に再試行や代替経路の選択が容易になる。

2.2.1 例外前の状態に復帰(ロールバック)

ロールバックは、変更前の情報を保存する、または変更をコミット可能な形で遅延させることで実現される。代表的には、先に作業領域で更新してから最後に安全に差し替える手法(コピーしてからスワップなど)が用いられる。復帰の仕組みが確実であることが重要であり、途中で解放不能な資源や外部状態(入出力の副作用など)を直接更新すると、強い保証が難しくなる場合がある。

2.3 移動保証とその考え方

移動保証は、例外後にオブジェクトが有効ではあるが、値としては変更され得る、というタイプの保証として整理されることが多い。特にムーブ操作の文脈では、例外が起きた場合でもオブジェクトは破綻せず、破棄や再構築が可能であることが重視される。呼び出し側は、そのオブジェクトの状態が不確定になっても安全に扱える方針をとれる。

2.3.1 破棄・再構築を含む設計

移動保証を成立させるには、例外後の対象が「再利用してもよい」と誤解されないようにする設計が必要になる。通常は、例外後に状態が整理され、少なくとも破棄は安全であるようにする。必要であれば、失敗時に後続で破棄・再構築すべき前提を仕様として明示する。これにより、呼び出し側は例外を受けても破綻する経路に入らずに済む。

2.4 例外を「投げない」設計(非例外保証)

非例外保証は、そもそも例外を発生させない設計を採る考え方である。実装は失敗を返り値、フラグ、または別の手段で表現し、制御フローを例外に依存しない。完全に例外ゼロを達成できない場合でも、少なくとも特定の境界領域(たとえばデストラクタや低レベルの後片付け)を例外不可能にするなど、実用上の意味を持つ。

2.4.1 継続可能性と副作用管理

例外がない代わりに、失敗時の挙動を設計し直す必要がある。返り値による失敗伝達を採る場合は、呼び出し側が結果を検査し、適切な分岐を行えることが前提となる。さらに、副作用(ログ出力や状態更新)をどこまで行うかを整理し、失敗が起きても整合性を保つ必要がある。例外を投げない設計は、制御の見通しをよくする一方で、エラーハンドリングの流儀を統一することが重要になる。

3 実装パターン

例外安全性を実装へ落とし込むための定番手法がある。ここでは、資源管理を自動化するRAII、更新を安全に進めるコピー&スワップ、変更を段階化するトランザクション的設計、例外境界の取り扱い、デストラクタと後片付けの方針を扱う。どれも「例外経路でも安全である」という同一の目的に寄与するが、対象と得意分野が異なる。

3.1 RAIIによる資源管理

RAII(Resource Acquisition Is Initialization)は、資源の獲得をオブジェクトの構築と結びつけ、解放を破棄(スコープ終了)と結びつける手法である。例外が起きてもスタックが巻き戻されるため、破棄が確実に実行されれば漏れは回避できる。結果として、例外経路での資源安全性が高まる。

3.1.1 スコープ終了時の自動解放

スコープを抜ける過程では、通常の終了でも例外による離脱でも同様に破棄処理が走る。そこで、ファイルハンドルや動的メモリなどを所有するラッパ型を用意し、デストラクタで解放する設計を取ると、制御フローに依存しない安全性が得られる。所有権と寿命が明確になるため、複数箇所での解放漏れや二重解放を防ぎやすい。

3.1.2 破棄の例外を避ける設計

デストラクタが例外を投げると、別の例外が進行中の巻き戻し中にさらに例外が重なる可能性があり、結果は不安定になり得る。そのため、破棄処理では例外を発生させない設計が望ましい。失敗が避けられない場合でも、後片付けの試行結果を例外ではなく、ログや状態として外に出すなどの手段に切り替えるのが一般的である。

3.2 コピー&スワップ

コピー&スワップは、変更後の状態を一時オブジェクトに作ってから、最終的にスワップして置き換える考え方である。更新途中で例外が起きても元の対象は変更されないため、強い保証に近づきやすい。特に代入演算子の実装でよく用いられる。

3.2.1 部分更新の回避

部分更新が危険なのは、途中の段階で例外が起きたときに一部のフィールドだけが変わってしまう点にある。コピー&スワップでは、必要な変更はまずコピー側で完了させるため、元の対象に対する書き換えは最後のスワップ時点まで行われない。結果として、例外が発生する可能性のある処理の影響が「一時領域」に閉じ込められる。

3.2.2 スワップ可能性とコスト

スワップによる置換を効率よく行うには、対象がスワップ可能であり、スワップが安全であることが必要になる。一般にスワップはフィールド入れ替え程度で済むため、コストが抑えられやすい一方、コピーを先に行うため準備コストは上がる。したがって、対象サイズやコピーの高価さによっては、別の保証レベルや設計が検討される。

3.3 トランザクション的設計

トランザクション的設計は、変更を「段階」として扱い、成功したときだけコミットする発想をプログラム構造に組み込む。データベースのトランザクションに似た考え方だが、必ずしも永続化に限らずメモリ上の整合性更新にも応用できる。重要なのは、失敗時に復元できる形で進める点である。

3.3.1 変更の段階化

まず検証や準備を行い、その後で実データへ反映する。たとえば、必要な要素の確保、途中計算、整合性検査といった工程を先に済ませ、最後に一度の切り替えで反映する。工程間で例外が発生しても、コミット前なら元状態のままでいられるため、強い保証に寄せやすい。

3.3.2 失敗時の復元戦略

復元戦略は大きく二系統に分かれる。一つは変更前の状態を保持して元へ戻す方法、もう一つはコミット方式によりそもそも変更が可観測にならないようにする方法である。後者は外部副作用(ログや入出力など)との兼ね合いが課題になることがあるため、境界ごとに設計を調整する必要がある。どちらにせよ「失敗後に矛盾が残らない」ことを具体的に確認する。

3.4 例外境界の設計(境界で吸収・再送出)

例外安全性は、関数の内部だけでなく、呼び出し元との接点でも成立する必要がある。そこで、どの範囲で例外を捕捉して処理するのか、また捕捉した例外を再送出するのか、方針を境界として定める。境界設計が適切であるほど、例外による制御フローの拡散を抑えられる。

3.4.1 例外を握りつぶさない方針

例外を捕捉した場合でも、黙って処理を終えると不具合が隠れ、後で別の場所で破綻することがある。したがって、捕捉の目的(復旧、ログ記録、資源解放の後処理など)を明確にし、必要に応じて再送出することが重要になる。少なくとも呼び出し側が判断できる形で失敗が伝わるようにするのが基本方針である。

3.4.2 ログと通知の扱い

例外境界でログや通知を行う場合は、副作用の安全性が問題になる。ログ出力自体が失敗する可能性、また出力先の状態が不安定な状況での挙動を検討する必要がある。実装としては、例外を引き起こさない方式で記録する、あるいは記録の失敗を影響範囲の外に閉じ込めるなどの工夫が求められる。

3.5 デストラクタと後片付けの方針

後片付けは失敗時に最も実行されやすい経路であるため、例外安全性の観点では特別に扱う必要がある。デストラクタの設計や、後片付け関数が例外を扱うかどうかは、全体の安定性に直結する。

3.5.1 例外の伝播禁止領域

巻き戻し中にさらに例外が伝播すると、処理の整合性が崩れやすい。したがって、デストラクタやクリーンアップ領域では原則として例外を外へ出さない方針を採るのが一般的である。失敗しても致命的にならない設計にしておくことで、例外が連鎖する状況を避けられる。

3.5.2 後始末の失敗許容

後片付けが完了できない場合でも、システムが破綻しないように設計する。たとえば、解放の失敗を検知しても例外で中断せず、監視やログに回す、あるいは将来の状態復旧に任せるなどが選択肢になる。重要なのは「後片付けで安全性が失われない」ことであり、失敗時の挙動を仕様化しておくと運用上の判断が容易になる。

4 計測・検証と注意点

例外安全性は設計だけでなく検証によって裏付ける必要がある。評価観点、テスト方針、レビューでの確認、典型的な落とし穴、そして性能とのトレードオフを整理することで、机上の保証を実装の現実に結びつけられる。特に例外は頻度が低いため、意図的な検証が不可欠になる。

4.1 例外安全性を評価する観点

評価では「例外が起きた後に何が保証されるか」を、観測可能な項目として確認する。成功時と失敗時の両方を同じ粒度で見なければ、部分的な安全性が過大評価される危険がある。また、資源のライフサイクルを追跡することで、漏れや二重解放を早期に発見できる。

4.1.1 成功時・失敗時の状態確認

成功時は不変条件と整合性が成立していること、失敗時は対象が壊れていないことを確認する。確認対象には、内部構造の整合、外部から観測できる値、例外後に次の操作が可能かどうかが含まれる。強い保証を狙う場合は、失敗後が例外前と同等であるかも評価指標になる。

4.1.2 リソースのライフサイクル検証

動的メモリ、ファイル資源、ロック、スレッド関連オブジェクトなどを対象に、確保と解放の対応が例外経路でも成立することを確かめる。静的解析だけでは漏れの全てを捕まえられないことがあるため、実行時観測(サニタイザ、リーク検出ツール)と組み合わせるのが効果的である。解放順序や所有権の移動についても、失敗時に崩れていないかを点検する。

4.2 テスト戦略

テストは例外安全性の弱点を露出させる。一般の単体テストでは成功経路が中心になりがちで、例外経路が未検証になりやすい。そこで、例外注入や境界条件の網羅により、現実の失敗に近い挙動を意図的に再現する。

4.2.1 例外注入(ファジング的手法)

例外が起きる可能性のある操作の点を選び、一定確率または指定条件で例外を発生させる。これにより、通常では起きにくい組み合わせで破綻が検出できる。ファジング的に制御点を広げると探索効率が上がり、局所的な安全性が全体で破れるパターンも見つかりやすい。

4.2.2 境界条件(低メモリ・入出力失敗)

低メモリ状況、タイムアウト、入出力エラーなど、失敗が連鎖しやすい条件をテストする。特に例外が起きたあとに、後片付けやロールバックでさらに失敗が起きないかを確認する。境界条件は環境依存のため再現性の工夫が必要だが、期待する保証レベルに直結するため、優先度は高い。

4.3 コードレビューのチェックリスト

レビューでは「例外が発生し得る箇所」と「それに対する責務分界」を具体的に追う。設計が正しくても実装の細部で逸脱が起きるため、代入やムーブなど例外が混入しやすい操作に注意を集中させる。チェックリスト化すると属人的判断を減らせる。

4.3.1 例外が起きる箇所の特定

まず、コンストラクタ、関数呼び出し、メモリ確保、入出力、比較や変換など、例外を投げ得る操作の箇所を洗い出す。次に、その近傍でどのデータが更新されるか、更新が可観測になる時点がどこかを確認する。これにより、部分更新や不変条件破れのリスクを具体化できる。

4.3.2 代入・ムーブ操作の妥当性

代入演算子やムーブ操作は、所有権の移動と破棄タイミングが絡むため、例外安全性が崩れやすい。特にスワップ、自己代入、再構築の有無、ムーブが例外を起こし得るか、などを点検する。レビューでは保証レベルが仕様に合っているかも併せて確認する。

4.4 よくある落とし穴

例外安全性を阻害する典型パターンはいくつかある。落とし穴は「部分更新」「解放順序」「失敗伝達の不整合」の形で現れやすい。早期に発見し修正できるよう、頻出の問題を事前に意識しておくと効果的である。

4.4.1 代入演算子の不整合

代入が例外を考慮せずにフィールドを順番に書き換えると、失敗後の状態が矛盾する。コピー&スワップのように安全な置換を採用せず、手動で部分更新を行うと基本保証すら満たせない場合がある。さらに自己代入の扱いが不適切だと、予期せぬ解放や上書きが起きる。

4.4.2 部分構築と解放順序

コンストラクタ内で段階的に部品を組み立てると、途中例外で部分構築状態が発生する。巻き戻しでは既に構築済みの部分が破棄されるが、そのとき参照関係が残っていると不正になることがある。解放順序や所有権の持ち方が、部分構築でも成立するように設計する必要がある。

4.4.3 例外指定や戻り値との整合

例外が投げられるのか投げられないのか、仕様と実装が一致していないと、呼び出し側は誤った前提で処理を進める。非例外保証を目指す場合は戻り値検査の契約が成立している必要があるし、例外を利用する場合も「捕捉すべき例外」と「伝播させるべき例外」の線引きが重要になる。両者の整合性はレビューとテストで確認する。

4.5 パフォーマンスとトレードオフ

強い保証はしばしば余分な作業を要求する。コピーや追加のメモリ確保、状態保持、検証のための計算などが増えるため、性能への影響を無視できない。例外の発生頻度やサイズ、要求される信頼性に応じて保証の選択を行う必要がある。

4.5.1 強い保証のコスト

強い保証を実現するには、ロールバックのための複製や検証が必要になりやすい。特に大きなオブジェクトではコピーコストが高く、メモリ使用量も増える。さらに外部副作用を絡めると、厳密なロールバックが難しくなり設計が複雑化するため、コストは計画段階で見積もるべきである。

4.5.2 最小限の保証選択

必要な強度は常に最大であるとは限らない。重要なデータに対しては強い保証を選び、軽量な更新や再試行が容易な操作では基本保証や移動保証で十分な場合がある。最小限の保証を選ぶ際は、呼び出し側の運用(再試行、破棄、次の手)を含めて整合を取ることが前提になる。

4.5.3 実運用での判断基準

判断基準としては、例外の起こりやすさ、損害の大きさ、復旧コスト、性能要件、観測可能な整合性の必要度が挙げられる。加えて、変更対象が外部世界(入出力、ネットワーク、永続領域)に影響するかどうかも重要である。設計段階では「どの操作にどの保証が必要か」を分類し、実装とテストでその分類を裏付けることが、運用での失敗を減らす。