C++の例外処理は、プログラムの実行中に発生した異常を、通常の処理の流れとは分けて扱うための仕組みです。
たとえば、ファイルが開けない、メモリ確保に失敗する、関数に不正な値が渡される、外部サービスとの通信に失敗する、といった状況があります。
こうした問題を通常の戻り値だけで扱おうとすると、呼び出し元のあちこちでエラーチェックが必要になり、処理の見通しが悪くなることがあります。
例外処理を使うと、エラーが発生した場所では「異常が起きた」と通知し、その異常にどう対応するかは、より上位の処理に任せることができます。
つまり、C++の例外処理は、エラーの発生場所とエラーへの対応場所を分離するための仕組みです。
C++の例外処理の基本的な考え方
C++の例外処理では、異常が起きたときに例外を投げます。
そして、その例外を受け取る側で捕捉して処理します。
基本的な流れは次のようになります。
まず、ある処理の中で異常が発生します。
次に、その異常を例外として投げます。
その後、呼び出し元をさかのぼりながら、対応できる場所が探されます。
対応できる場所が見つかると、そこでエラー処理が行われます。
この仕組みにより、下位の関数で起きたエラーを、上位の処理でまとめて扱えるようになります。
C++の例外処理で使う主な要素
C++の例外処理では、主に3つの要素が使われます。
try
try は、例外が発生する可能性のある処理を囲むためのものです。
「この範囲では例外が起きるかもしれない」という処理をまとめておき、もし例外が発生した場合には、対応する例外処理へ制御が移ります。
try の中で例外が発生すると、その後に書かれている通常処理は実行されず、対応するエラー処理へ進みます。
throw
throw は、例外を発生させるためのものです。
何らかの異常が起きたとき、「このまま通常処理を続けることはできない」という意味で例外を投げます。
C++では、理論上さまざまな型の値を例外として投げることができます。
ただし、実務では標準例外クラス、またはそれを継承した独自例外クラスを使うのが一般的です。
文字列や数値をそのまま投げることもできますが、例外処理の設計としてはあまり推奨されません。
理由は、捕捉する側で型がばらばらになり、扱いづらくなるからです。
catch
catch は、投げられた例外を受け取って処理するためのものです。
例外には種類があるため、どの種類の例外を受け取るかを指定できます。
たとえば、不正な引数に関する例外、実行時エラー、範囲外アクセスに関する例外などを分けて処理できます。
また、どんな例外でも受け取るための書き方もあります。
ただし、すべての例外をまとめて受け取る方法は、原因が分かりにくくなるため、使いどころには注意が必要です。
標準例外クラス
C++には、よく使われる標準例外クラスが用意されています。
std::exception
std::exception は、標準例外クラスの基底となるクラスです。
多くの標準例外クラスは、この std::exception を基にしています。
そのため、標準的な例外をまとめて扱いたい場合には、std::exception 系の例外を捕捉する形がよく使われます。
std::exception には、例外の説明を取得するための what() という機能があります。
これにより、エラー内容を文字列として確認できます。
std::runtime_error
std::runtime_error は、実行時に発生するエラーを表すための例外です。
たとえば、ファイルが開けない、外部サービスへの接続に失敗した、設定ファイルの読み込みに失敗した、といった状況に使われることがあります。
プログラムの書き方そのものというより、実行環境や外部要因によって発生するエラーに向いています。
std::logic_error
std::logic_error は、プログラムのロジック上の誤りを表すための例外です。
たとえば、関数に渡してはいけない値が渡された、事前条件を満たしていない、設計上あり得ない状態になった、といった場面で使われます。
std::invalid_argument
std::invalid_argument は、不正な引数が渡されたときに使われる例外です。
たとえば、年齢を表す引数に負の値が渡された場合や、空文字列を許可していない関数に空文字列が渡された場合などに使われます。
std::out_of_range
std::out_of_range は、指定された値や位置が有効な範囲を超えている場合に使われる例外です。
ただし、C++ではすべての範囲外アクセスで自動的にこの例外が投げられるわけではありません。
たとえば、配列やコンテナのアクセス方法によっては、範囲外でも例外が投げられず、未定義動作になる場合があります。
範囲外アクセスを例外として検出できるかどうかは、使用する関数やライブラリの仕様によります。
std::bad_alloc
std::bad_alloc は、メモリ確保に失敗したときに使われる例外です。
大きなメモリ領域を確保しようとして失敗した場合などに発生する可能性があります。
ただし、実際の挙動はOSや実行環境に依存することもあります。
例外を受け取るときの注意点
C++で例外を受け取るときには、いくつか重要な注意点があります。
例外は参照で受け取る
例外は、基本的に参照で受け取るのが望ましいです。
特に、標準例外を扱う場合は、定数参照で受け取るのが一般的です。
これにより、不要なコピーを避けられます。
また、値として受け取ると、オブジェクトスライシングが発生する可能性があります。
オブジェクトスライシングとは、派生クラスのオブジェクトを基底クラスの値として受け取ったときに、派生クラス部分の情報が失われてしまう現象です。
たとえば、実際にはより具体的な例外が投げられているのに、基底クラスとして値渡しで受け取ると、具体的な情報が失われる可能性があります。
そのため、例外は値ではなく、定数参照で受け取るのが基本です。
catchの順番に注意する
複数の例外を捕捉する場合、catch の順番は重要です。
C++では、例外は上から順に判定されます。
そのため、基底クラスの例外を先に書いてしまうと、派生クラスの例外が先に基底クラス側で捕捉されてしまいます。
つまり、具体的な例外を先に書き、より広い例外を後に書くのが基本です。
たとえば、範囲外エラーを個別に処理したい場合は、その例外を先に捕捉し、最後に標準例外全般を捕捉するようにします。
この順番を間違えると、後ろに書いた具体的な例外処理が実質的に使われなくなることがあります。
catchですべてを握りつぶさない
すべての例外を捕捉する書き方は便利ですが、使いすぎると危険です。
特に、例外を捕まえたあとに何もせず無視するのは避けるべきです。
エラーが発生しているのに、プログラム上は何もなかったように進んでしまうからです。
例外を捕捉した場合は、少なくとも次のいずれかの対応を行うべきです。
- エラーメッセージを出す。
- ログを残す。
- ユーザーに通知する。
- 状態を復旧する。
- 上位の処理へ再度例外を伝える。
何もせずに例外を消してしまうと、バグの原因を追跡しにくくなります。
例外が発生したときの処理の流れ
例外が発生すると、その場で通常の処理は中断されます。
その後、現在の関数を抜け、呼び出し元の関数へ戻りながら、対応する例外処理が探されます。
このように、呼び出しスタックをさかのぼっていく動きをスタックアンワインディングと呼びます。
スタックアンワインディング
スタックアンワインディングでは、例外が発生した関数から順番に処理を抜けていきます。
その過程で、スコープ内に存在していたローカルオブジェクトのデストラクタが呼ばれます。
これはC++において非常に重要な性質です。
なぜなら、例外によって関数が途中で終了しても、ローカルオブジェクトの後片付けが行われるからです。
たとえば、ファイルを管理するオブジェクト、メモリを管理するオブジェクト、ロックを管理するオブジェクトなどは、デストラクタでリソースを解放できます。
この仕組みによって、例外が発生してもリソースリークを防ぎやすくなります。
RAIIと例外処理
C++の例外処理を理解するうえで、RAIIは非常に重要です。
RAIIとは
RAIIは、Resource Acquisition Is Initialization の略です。
日本語では、「リソースの取得をオブジェクトの初期化に結びつける考え方」と説明できます。
より分かりやすく言えば、リソースをオブジェクトに管理させる設計です。
オブジェクトが作られたときにリソースを取得し、オブジェクトが破棄されるときにリソースを解放します。
RAIIが重要な理由
例外が発生すると、通常の処理は途中で中断されます。
もし手動でリソースを解放する設計にしていると、例外によって解放処理が実行されず、メモリリークやファイルの閉じ忘れ、ロックの解除忘れが起こる可能性があります。
しかし、RAIIを使っていれば、スコープを抜けるときにオブジェクトのデストラクタが自動的に呼ばれます。
そのため、例外が発生してもリソースを安全に解放しやすくなります。
C++ではRAIIを前提に設計する
C++では、例外安全なコードを書くためにRAIIを活用するのが基本です。
- ファイルはファイルストリームのオブジェクトに管理させる。
- メモリはスマートポインタやコンテナに管理させる。
- ロックはロック管理用のオブジェクトに管理させる。
このように、リソースの取得と解放をオブジェクトの寿命に結びつけることで、例外に強いコードになります。
例外の再送出
例外を捕捉したあと、何らかの処理をしてから、もう一度上位へ伝えたい場合があります。
たとえば、エラー内容をログに残したうえで、実際の対応はさらに上位の処理に任せたい場合です。
このような場合は、現在捕捉している例外をそのまま再送出します。
再送出では元の例外を保つことが重要
例外を再送出するときには、元の例外の型や情報を保つことが重要です。
誤った方法で投げ直すと、例外オブジェクトが新しく作られたり、型情報が失われたりする可能性があります。
特に、基底クラスとして受け取った例外を値として投げ直すと、オブジェクトスライシングが起きる可能性があります。
そのため、再送出では、現在捕捉している例外をそのまま投げ直す方法を使うべきです。
独自例外クラス
標準例外クラスだけでは、エラーの意味を十分に表現できない場合があります。
そのような場合は、独自の例外クラスを定義できます。
独自例外を作る目的
独自例外を作る目的は、エラーの種類をより明確にすることです。
たとえば、データベース関連のエラー、設定ファイル関連のエラー、ネットワーク関連のエラーなどを、それぞれ別の例外として扱えるようにすると、捕捉する側で処理を分けやすくなります。
標準例外を継承するのが一般的
独自例外を作る場合は、標準例外クラスを継承するのが一般的です。
特に、実行時エラーを表す場合は std::runtime_error を基にすることが多いです。
プログラムの使い方やロジック上の誤りを表す場合は、std::logic_error 系を基にすることもあります。
標準例外を継承しておくと、上位では標準例外としてまとめて扱うこともできます。
noexceptとは
noexcept は、その関数が例外を外へ出さないことを示す指定です。
noexceptの意味
noexcept は、「この関数から例外が外に漏れない」という約束を表します。
厳密には、「関数の内部で例外を発生させてはいけない」という意味ではありません。
内部で例外を捕捉し、外に出さなければ問題ありません。
問題になるのは、noexcept が付いた関数から例外が外に漏れる場合です。
noexcept違反が起きるとどうなるか
noexcept が付いた関数から例外が外に出ると、通常の例外処理ではなく、プログラム終了につながる処理が呼ばれます。
そのため、noexcept は軽い気持ちで付けるべきではありません。
「この関数は本当に例外を外へ出さない」と保証できる場合に使います。
noexceptが重要になる場面
noexcept は、特にデストラクタ、ムーブコンストラクタ、ムーブ代入演算子、リソース解放処理などで重要になります。
標準ライブラリのコンテナなどは、型のムーブ操作が例外を投げないかどうかによって、内部処理の最適化や安全性を判断することがあります。
デストラクタと例外
C++では、デストラクタから例外を外へ出す設計は避けるべきです。
デストラクタから例外を出すと危険な理由
デストラクタは、オブジェクトが破棄されるときに自動的に呼ばれます。
特に、例外が発生してスタックアンワインディングが行われている最中にも、ローカルオブジェクトのデストラクタは呼ばれます。
このとき、すでに1つの例外が処理されている最中に、デストラクタからさらに別の例外が外へ出ると、プログラムが強制終了する可能性があります。
デストラクタでは例外を内部で処理する
デストラクタ内で失敗する可能性のある処理を行う場合は、例外を外へ出さないようにするべきです。
必要であれば、デストラクタ内で例外を捕捉し、ログ出力などにとどめます。
デストラクタは後片付けを行う場所であり、失敗を外部に通知する設計には向きません。
コンストラクタと例外
コンストラクタで例外を投げることは、C++では一般的に認められている設計です。
初期化に失敗したら例外を投げる
オブジェクトの初期化に失敗した場合、中途半端な状態のオブジェクトを作るよりも、例外を投げて生成に失敗したことを知らせるほうが自然です。
たとえば、必要なファイルが開けない、引数が不正で初期化できない、リソース取得に失敗した、といった場合です。
コンストラクタで例外が発生した場合の注意点
コンストラクタで例外が発生すると、そのオブジェクトは完全には生成されません。
そのため、そのクラス自身のデストラクタは呼ばれません。
ただし、すでに構築済みのメンバ変数や基底クラスについては、適切にデストラクタが呼ばれます。
この点は非常に重要です。クラス自身の後片付けをデストラクタに任せるだけでは不十分な場合があるため、メンバオブジェクトにリソース管理を任せるRAII設計が重要になります。
ファイル処理と例外
ファイル処理では、ファイルが開けない、読み込みに失敗する、書き込みに失敗するなど、さまざまなエラーが発生します。
ファイルが開けない場合
ファイルを開こうとして失敗した場合は、状態を確認し、必要に応じて例外を投げる設計にできます。
これは自然な使い方です。
ストリームは標準では自動的に例外を投げない
ただし、注意点があります。
C++の標準ストリームは、デフォルトでは多くの入出力エラーを例外として自動的に投げるわけではありません。
通常は、ストリームの状態を確認してエラーを判断します。
入出力エラーを例外として扱いたい場合は、ストリーム側に例外を投げる設定を行う必要があります。
そのため、「ファイル処理でエラーが起きれば必ず例外になる」と考えるのは正確ではありません。
メモリ確保失敗と例外
C++では、通常のメモリ確保に失敗した場合、メモリ確保失敗を表す例外が発生することがあります。
std::bad_alloc
メモリを確保できない場合に使われる代表的な例外が std::bad_alloc です。
大きなデータを確保しようとした場合や、システムのメモリが不足している場合などに発生する可能性があります。
実際の挙動は環境に依存する
ただし、巨大なメモリ確保を行った場合の挙動は、実行環境によって異なります。
OSのメモリ管理方式、コンパイラ、標準ライブラリの実装などによって、確保時点で失敗する場合もあれば、実際にメモリへアクセスした時点で問題が表面化する場合もあります。
そのため、メモリ確保失敗の例外は理解しておくべきですが、実際の挙動には環境依存の部分があります。
例外安全性
C++では、例外が発生したときにプログラムやオブジェクトの状態がどの程度安全に保たれるかが重要です。
これを例外安全性と呼びます。
基本保証
基本保証とは、例外が発生してもリソースリークが起きず、オブジェクトが破壊可能で有効な状態を保つことです。
ただし、処理の途中で値が変更されている可能性はあります。
多くのコードでは、最低限この基本保証を満たすことが求められます。
強い保証
強い保証とは、例外が発生した場合に、処理前の状態へ戻ることです。
つまり、処理が成功するか、何も変わらないかのどちらかになります。
これは非常に理想的な保証ですが、常に実現できるとは限りません。
非送出保証
非送出保証とは、例外を外へ出さないことです。
リソース解放処理、デストラクタ、ムーブ処理などでは、この保証が重要になることがあります。
noexcept は、この非送出保証と深く関係しています。
例外とエラーコードの使い分け
C++では、エラー処理に例外を使う場合もあれば、エラーコードや戻り値を使う場合もあります。
どちらが正しいかは、状況やプロジェクトの方針によります。
例外が向いている場面
例外は、通常の処理の流れでは起きない異常を扱うのに向いています。
たとえば、ファイルが存在しない、設定ファイルが壊れている、データベース接続に失敗した、メモリ確保に失敗した、といった状況です。
このようなエラーは、その場で簡単に回復できないことが多いため、上位の処理へ伝播させる設計が自然です。
戻り値やエラーコードが向いている場面
一方で、失敗が通常の処理の一部である場合は、例外よりも戻り値やエラーコードのほうが自然なことがあります。
たとえば、検索したが見つからなかった、入力値が条件に合わなかった、変換できるかどうかを試した、といったケースです。
このような場合、失敗が頻繁に発生する可能性があるため、例外を使うとかえって処理が分かりにくくなったり、パフォーマンス上の懸念が出たりすることがあります。
std::optionalやstd::expected
値が存在しない可能性を表す場合は、std::optional が使われることがあります。
また、C++23以降では、成功値またはエラー値を表すために std::expected という選択肢もあります。
これらは、例外を使わずにエラーや失敗を戻り値として表現したい場合に役立ちます。
例外を使いすぎない
例外は便利ですが、通常の条件分岐の代わりに使いすぎるべきではありません。
通常の分岐にはifや戻り値を使う
たとえば、ある値が条件を満たしているかどうか、データが存在するかどうか、ユーザー入力が形式に合っているかどうかといった処理は、通常の分岐で扱ったほうが自然な場合があります。
例外は、あくまで「通常の流れではない異常」を表すためのものとして使うと、コードの意図が分かりやすくなります。
例外を使うかどうかは設計方針による
ただし、例外を使うかどうかに絶対的なルールがあるわけではありません。
プロジェクトによっては、例外を積極的に使う場合もありますし、逆に例外を禁止してエラーコードやステータス型を使う場合もあります。
重要なのは、プロジェクト全体で方針を統一することです。
動的例外指定とnoexcept
昔のC++には、関数がどの例外を投げるかを指定する動的例外指定という機能がありました。
動的例外指定は現代C++では使わない
かつては、関数が特定の例外を投げることを宣言する書き方がありました。
しかし、この機能は現在では非推奨または削除されており、現代C++では使うべきではありません。
現代C++では、関数が例外を外へ出さないことを示す場合には noexcept を使います。
現代C++ではnoexceptを使う
noexcept は、関数が例外を外へ出さないことを表すために使います。
ただし、前述のとおり、noexcept を付けた関数から例外が外に出るとプログラム終了につながるため、慎重に使う必要があります。
実務での例外処理の考え方
実務でC++の例外処理を使う場合は、単に例外を投げて捕まえるだけでは不十分です。
- どこで例外を投げるのか。
- どこで捕捉するのか。
- どの粒度で例外を分けるのか。
- ログをどこで残すのか。
- ユーザーにどのように通知するのか。
- リソースをどう安全に解放するのか。
こうした設計が重要になります。
低レイヤーでは具体的な異常を通知する
低レイヤーの処理では、ファイルが開けない、パースに失敗した、接続できないなど、具体的な異常を例外として通知します。
この段階では、ユーザーにどう表示するかまでは考えず、何が失敗したのかを明確に伝えることが重要です。
中間レイヤーでは文脈を追加する
中間レイヤーでは、必要に応じて例外に文脈を追加します。
たとえば、単に「ファイルを開けません」ではなく、「設定ファイルの読み込みに失敗しました」のように、どの処理の中で失敗したのかを分かるようにします。
最上位でまとめて処理する
最上位の処理では、例外をまとめて捕捉し、ログ出力やユーザーへの通知を行います。
アプリケーション全体として異常をどう扱うかは、最上位で決めることが多いです。
よくある間違い
C++の例外処理では、いくつかありがちな間違いがあります。
例外を値で受け取る
例外を値として受け取ると、コピーが発生したり、オブジェクトスライシングが起きたりする可能性があります。
基本的には、定数参照で受け取るべきです。
catchの順番を間違える
基底クラスの例外を先に捕捉してしまうと、派生クラスの例外を個別に処理できなくなります。
具体的な例外を先に、広い例外を後に書くのが基本です。
何でもcatchして無視する
すべての例外を捕捉して何もしない処理は危険です。
エラーが隠れてしまい、後から原因を追いにくくなります。
デストラクタから例外を外へ出す
デストラクタから例外を外へ出すと、プログラムの強制終了につながる可能性があります。
デストラクタでは、例外を内部で処理し、外に漏らさない設計にするべきです。
例外を通常の分岐に使う
例外を通常の条件分岐の代わりに使いすぎると、コードが読みにくくなります。
異常系には例外、通常の分岐には戻り値や条件分岐を使う、という考え方が基本です。
C++の例外処理のベストプラクティス
C++で例外処理を書くときは、いくつかの基本方針を押さえておくと安全です。
標準例外系を使う
例外を使う設計であれば、標準例外クラス、またはそれを継承した独自例外クラスを使うと扱いやすくなります。
文字列や数値をそのまま投げるより、例外の種類や意味を明確にできます。
例外は定数参照で受け取る
例外を捕捉するときは、基本的に定数参照で受け取ります。
これにより、コピーやスライシングを避けられます。
派生クラスを先に捕捉する
複数の例外を捕捉する場合は、具体的な例外を先に、基底クラスを後にします。
この順番を守ることで、意図した例外処理を実行できます。
RAIIを使う
例外が発生してもリソースを安全に解放できるように、RAIIを活用します。
手動でリソースを解放する設計では、例外発生時に解放漏れが起きやすくなります。
デストラクタから例外を出さない
デストラクタでは、例外を外へ漏らさないようにします。
後片付け中の失敗は、ログに残すなどして内部で処理するのが基本です。
noexceptは慎重に使う
noexcept は、関数が例外を外へ出さないことを示す強い約束です。
誤って付けると、例外発生時に通常の捕捉ができず、プログラム終了につながります。
例外を握りつぶさない
例外を捕捉した場合は、何らかの意味のある対応を行うべきです。
ログを残す、復旧する、ユーザーに通知する、再送出するなど、目的を明確にします。
まとめ
C++の例外処理は、異常が発生したときに通常の処理とは別の流れでエラーを扱うための仕組みです。
基本的には、異常が起きた場所で例外を投げ、それを適切な場所で捕捉して処理します。
ただし、C++の例外処理で本当に重要なのは、単に例外を投げることではありません。
例外が発生しても、リソースリークを起こさず、オブジェクトの状態を壊さず、安全に処理を中断または復旧できるようにすることです。
そのためには、RAII、スタックアンワインディング、例外安全性、noexcept、デストラクタの扱いを理解する必要があります。
特に重要なポイントは次の通りです。
- 例外は標準例外系を使うのが基本です。
- 例外は定数参照で受け取ります。
- 具体的な例外を先に捕捉し、広い例外を後に捕捉します。
- 例外を握りつぶさず、意味のある対応を行います。
- リソース管理にはRAIIを使います。
- デストラクタから例外を外へ出さないようにします。
noexceptは本当に例外を外へ出さない関数にだけ使います。- 通常の分岐と異常系を区別して設計します。
C++の例外処理は、正しく使えばエラー処理を整理し、堅牢なプログラムを書くための強力な仕組みになります。
一方で、設計を誤ると、エラーの原因が分かりにくくなったり、プログラムが突然終了したりする原因にもなります。
そのため、例外処理は「エラーを捕まえる技術」ではなく、「異常時にも安全な状態を保つための設計」として理解することが大切です。
以上、C++の例外処理についてでした。
最後までお読みいただき、ありがとうございました。
