TopC++ 入門 › STEP 11 › 第64回 例外処理の基本

第64回 例外処理の基本 — throw / try / catch でエラーを「投げて捕まえる」

C では関数の戻り値で -1errno を返し、呼び出し側が毎回チェックしていました。C++ では 例外 を使ってエラーをジャンプさせることができます。「失敗したら throw、捕まえる場所だけ catch」に書くだけで、中間の関数はエラー処理を書かずに済みます。

最低限ここだけ

  • throw 値; で例外を投げる
  • try { ... } catch (型 e) { ... } で捕まえる
  • 捕まえない例外はプログラム終了

余裕があれば

  • std::exception 階層と what()
  • スタック巻き戻しとデストラクタ
  • catch (...) の使いどころと落とし穴

1. まず触ってみる — 投げて捕まえる最短コード

例外処理は 「遠くで起きたエラーを、1 箇所で捕まえる」 ための仕組みです。階段を転げ落ちるように、関数の呼び出し階層を下から上へジャンプしてエラーが伝わっていきます。

1-1. イメージ — 警報ボタンと管制室

ビルのどこかで火災報知器を押す(throw)と、警報は階段を駆け上がって、どこかの階の管制室(catch)で対応されます。中間の部屋は何もしなくていい。もし最上階まで誰も受け取らなかったら建物ごと退避(プログラム強制終了)という仕組みです。

1-2. 最小コード

hello_throw.cppC++
#include <iostream> #include <stdexcept> int divide(int a, int b) { if (b == 0) throw std::runtime_error("zero div"); return a / b; } int main() { try { std::cout << divide(10, 0); } catch (const std::exception& e) { std::cout << "error: " << e.what(); } } // error: zero div

b == 0 なら throw」「try ブロックで囲った呼び出しを catch が受ける」というだけです。divide の中で return しないのに、main に戻ってきていることに注目してください。

1-3. よくある質問

  • Q. throw する値は何でもいい? 技術的には int でも文字列でも OK ですが、std::exception 派生クラスにするのが実務の標準。what() でメッセージが取れるからです。
  • Q. catch しなかったら? std::terminate() が呼ばれてプログラム強制終了。ターミナルには terminate called after throwing ... と出ます。
  • Q. 性能は? 例外が発生しない時の実行コストはゼロ(最近のコンパイラ)。発生時はスタック巻き戻しで重い。だからエラー経路にだけ使います。
次節で C の戻り値方式と並べて比較します。ここでは「throw は return を飛び越える特別なジャンプ」とだけ覚えれば OK。

2. C の戻り値エラーと何が違うのか

C では関数の戻り値や errno にエラーコードを詰め、呼び出した側が毎回 if でチェックしていました。C++ では例外を使えば中間の関数は素通りできます。

err_c.cC: 全階層で if 分岐
int parse(const char* s, int* out); int load(const char* p, int* out) { FILE* f = fopen(p, "r"); if (!f) return -1; // ← 伝搬 char buf[64]; if (!fgets(buf, 64, f)) { fclose(f); return -2; // ← 伝搬 } int r = parse(buf, out); fclose(f); return r; // ← 伝搬 } // main でも if (load(...) < 0) { ... }
err_cpp.cppC++: 中間は素通り
int parse(const std::string&); int load(const std::string& p) { std::ifstream f(p); if (!f) throw std::runtime_error("open"); std::string buf; std::getline(f, buf); return parse(buf); // ← 例外はそのまま通過 } // ↑ ifstream は dtor が閉じる int main() { try { load("data.txt"); } catch (const std::exception& e) { ... } }
C の戻り値エラーC++ の例外
エラー発生時の処理呼び出し元に戻り値で通知throw で階層を飛び越える
中間関数の if チェック全ての階層で必要不要(素通り)
クリーンアップ手動 free / closeデストラクタが自動(RAII)
正常時のコスト毎回 if 分岐ほぼゼロ
異常時のコスト軽い重い(巻き戻し)
エラー情報の量整数 1 個が普通任意のオブジェクト
設計原則:「起きたら致命的 / めったに起きない」は例外向き、「日常的にありうる失敗(キーが見つからない等)」は戻り値 or std::optional が向きます。詳しくは第 57 回で。

3. スタック巻き戻しをアニメで見る

例外が投げられると、catch が見つかる階層まで関数呼び出しが逆順に破棄されます。これをスタック巻き戻し(stack unwinding)と呼びます。このとき、各フレーム内のローカル変数はデストラクタで解体されます。だから RAII が効く。

▶ 巻き戻しシミュレータ

main → a() → b() → c() の順に呼び出し、c() で throw します。どのフレームで catch するか選んでください。

コールスタックの動きがここに表示されます。

3-1. デストラクタが呼ばれる順

unwind_dtor.cppC++
struct Guard { std::string name; Guard(std::string n) : name(n) { std::cout << "ctor " << name << "\n"; } ~Guard() { std::cout << "dtor " << name << "\n"; } }; void c() { Guard g{"c"}; throw 42; } void b() { Guard g{"b"}; c(); } void a() { Guard g{"a"}; b(); } int main() { try { a(); } catch (int e) { std::cout << "caught " << e << "\n"; } } // ctor a / ctor b / ctor c / dtor c / dtor b / dtor a / caught 42

巻き戻し中にデストラクタから例外を投げると std::terminate。だからデストラクタは noexcept にしておくのが鉄則です(次回で扱います)。

4. std::exception 階層と what()

標準ライブラリは std::exception を頂点に、用途別のクラスを階層として提供しています。what() で共通にメッセージを取れるので、catch する時は const std::exception& を基本にします。

std::exception ├── std::logic_error // プログラマのバグ系 │ ├── std::invalid_argument // 引数が不正 │ ├── std::domain_error // 数学的定義域外 │ ├── std::length_error // size が大きすぎ │ └── std::out_of_range // at() が投げる ├── std::runtime_error // 実行時の環境起因 │ ├── std::range_error // 結果が表現不能 │ ├── std::overflow_error // 桁あふれ │ └── std::underflow_error // 桁下がり ├── std::bad_alloc // new 失敗 ├── std::bad_cast // dynamic_cast 失敗 └── std::bad_optional_access // optional::value

4-1. 自作するときのひな型

my_error.cppC++
class ParseError : public std::runtime_error { public: using std::runtime_error::runtime_error; // ctor を継承 }; void parse(const std::string& s) { if (s.empty()) throw ParseError("empty input"); } try { parse(""); } catch (const ParseError& e) { std::cout << e.what(); } // empty input

5. catch の書き方と順番

5-1. 必ず参照で、const 推奨

catch (std::exception e) だとスライシング(派生の情報が切り捨てられる)が起きます。必ず const std::exception& で受けましょう。

slicing.cppNG
try { throw std::out_of_range("idx"); } catch (std::exception e) { // ← 値で受ける std::cout << e.what(); // "std::exception" 的な文字列 } // ← 派生の情報が欠落
ref.cppOK
try { throw std::out_of_range("idx"); } catch (const std::exception& e) { // 参照で受ける std::cout << e.what(); // "idx" が出る } // const で書き換え防止

5-2. catch 節の順番は「派生 → 基底」

上から順にマッチングされるので、先に基底を書くと派生が届かない。細かい型を上、一般的な型を下に書きます。

order.cppC++
try { ... } catch (const std::out_of_range& e) { ... } // 1. 派生 catch (const std::logic_error& e) { ... } // 2. 中間 catch (const std::exception& e) { ... } // 3. 基底 catch (...) { ... } // 4. 最後の砦

5-3. catch(...) と throw;

catch (...)全部の例外を受ける「何でも捕まえる網」。ログを出してからthrow;(引数なし)で再送(rethrow)できます。

rethrow.cppC++
void task() { try { doWork(); } catch (...) { log("task failed"); // 記録だけして throw; // そのまま投げ直す } }

6. 実務で気をつける 5 つのこと

  • 例外は例外的な状況で投げる。「見つからない」「空文字列」のような日常ケースに使うと遅い&読みづらい。
  • コンストラクタで失敗したら例外を投げる(戻り値がないので他に方法がない)。
  • デストラクタで投げない。二重例外で即 terminate。
  • 例外境界で捕まえる。main / スレッド関数 / コールバックの入口では必ず catch して、裸の terminate を防ぐ。
  • 例外を使わないコード(組込み / ゲームエンジンの一部)と混ぜない。そういう場面では std::expected(C++23) や tl::expected、あるいは戻り値エラーを選ぶ。
ポインタで throw しないthrow new MyError(...) は解放責任が catch 側に移って漏れやすい。値で投げて参照で受けるのが定石です。

7. 理解度チェック

4 問。選択肢をクリックすると正誤と解説が出ます。

Q1. 次のうち正しいのはどれ?

例外を catch しないと警告が出るだけでプログラムは継続する
例外を catch しないと std::terminate が呼ばれ強制終了する
throw した値は main の戻り値になる
誰も catch しないと std::terminate()。だから例外境界(main/スレッドエントリ)で必ず catch すること。

Q2. catch を書く順番として正しいのは?

基底 std::exception → 派生 out_of_range
派生 out_of_range → 基底 std::exception
順番は無関係、どこに書いても同じ
catch は上から順にマッチング。基底を先に書くと派生の catch は永久に届かない(コンパイラが警告することも)。派生 → 基底の順が正解。

Q3. catch (std::exception e) の問題点は?

派生クラスの情報が失われるスライシングが起きる
コンパイルエラーになる
const を付けないと catch できない
値で受けると基底クラスに切り詰められ、派生クラスの what() が失われる。必ず参照で、const 推奨

Q4. 次のコードが出力するのは?

struct G { ~G(){ std::cout << "~G "; } };
void f() { G g; throw 1; std::cout << "end "; }
int main() {
  try { f(); } catch (int) { std::cout << "ok "; }
}
end ok
~G ok
ok(デストラクタは呼ばれない)
throw でスタック巻き戻しが起きると f 内の g のデストラクタが先に呼ばれ、その後 main の catch が実行される。end は到達しない。