第64回 例外処理の基本 — throw / try / catch でエラーを「投げて捕まえる」
C では関数の戻り値で -1 や errno を返し、呼び出し側が毎回チェックしていました。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 は到達しない。