TopC++ 入門 › STEP 11 › 第65回 noexcept と例外安全性

第65回 noexcept と例外安全性

noexcept は「この関数は例外を投げません」というコンパイラ向けの宣言です。実は vector の push_back 性能まで左右します。併せて例外安全性の 3 レベル(basic / strong / nothrow)を押さえて、「例外があっても壊れないコード」を書けるようになりましょう。

最低限ここだけ

  • noexcept = 投げません宣言
  • 破ると std::terminate
  • デストラクタと move は noexcept に

余裕があれば

  • vector の伸長が move を選ぶ条件
  • basic / strong / nothrow 保証
  • copy-and-swap イディオム

1. まず触ってみる — noexcept って何の宣言?

noexcept は関数のシグネチャに書く約束事です。「この関数は例外を投げません」とコンパイラに宣言します。約束を破ると std::terminate で強制終了です(catch もできない)。

1-1. イメージ — ドアに貼る「静かにしてください」の札

noexcept は関数に貼る約束札です。札を貼った関数は「中で例外が飛び出すようなことは絶対しません」と周囲に伝えるもの。周りのコード(特に vector のようなライブラリ)はこの札を見て最適化の選択をします。

1-2. 最小コード

noexcept_basic.cppC++
int add(int a, int b) noexcept { // ← 札 return a + b; // 例外投げない } int divide(int a, int b) { // 札なし if (b == 0) throw std::runtime_error("zero"); return a / b; } // コンパイル時にも使える: static_assert(noexcept(add(1,2))); // OK static_assert(!noexcept(divide(1,2))); // OK(投げうる)

関数の後ろに noexcept を付けるだけ。条件付き noexcept(noexcept(条件))もありますが、まずは付けるか付けないかの 2 択から入ればいいです。

1-3. 約束を破ったらどうなる?

break.cppNG
void liar() noexcept { throw std::runtime_error("oops"); // 嘘つき! } int main() { try { liar(); } catch (...) { } // ← ここに届かない } // std::terminate で即死

catch できずにプログラムが落ちます。コンパイラは警告するがエラーにしないので、書く側が責任を持ちます。

最優先で noexcept を付けるべき関数: デストラクタ、move コンストラクタ、move 代入、swap。理由は次節で。

2. vector が noexcept を気にする理由

vector の push_back で容量が足りなくなると、もっと大きい領域を取って既存要素を新領域へ移す必要があります(第 3 回のアニメで見ました)。このとき vector は次の分岐をします:

  • move コンストラクタが noexceptmove する(速い・省メモリ)
  • そうでない → copy する(安全・でも遅い)

なぜか?途中で例外が飛ぶと、新領域が「半分だけ移動済み」になってしまい復旧できないからです。例外を投げない保証があれば move、そうでなければ安全な copy、と vector が自動で選んでいます。

▶ vector 伸長: move か copy か

要素型の move が noexcept かどうかを切り替えて、vector の再配置がどう変わるか見てみましょう。

vector の伸長動作がここに表示されます。
move_noexcept.cppC++
struct T { std::string s; T(T&&) noexcept = default; // ★ noexcept move T& operator=(T&&) noexcept = default; T(const T&) = default; }; static_assert(std::is_nothrow_move_constructible_v<T>); std::vector<T> v; v.reserve(4); for (int i=0; i<10; ++i) v.push_back({...}); // 伸長時 move が使われる(noexcept 宣言のおかげ)
コンパイラ既定の move は「メンバ全員の move が noexcept なら noexcept」。だから自作クラスは普通 = default で済みます。問題は自分で move を書いた時 — 明示的に noexcept を付けないと vector が copy を選び、意図せず遅くなります。

3. 例外安全の 3 レベル

「例外が起きても壊れない」にもグラデーションがあります。Abrahams が示した3 レベルが業界標準です。

LEVEL 1

基本保証(basic)

例外後もオブジェクトは何らかの有効な状態。リーク無し、クラッシュ無し。ただし値は変わっているかも。

最低限これは必須

LEVEL 2

強い保証(strong)

例外が起きたら操作前の状態に完全復帰(ロールバック)。vector::push_back がこれ。

実装は copy-and-swap など。

LEVEL 3

例外を投げない(nothrow)

例外を絶対に投げない。これが noexcept。dtor / move / swap は必ずここ。

最強だが制約も最強。

3-1. copy-and-swap で strong を作る定番

copy_swap.cppC++
class Buf { int* p_; size_t n_; public: Buf& operator=(Buf rhs) noexcept { // ★ 値渡し swap(rhs); // ★ swap は投げない return *this; } // rhs は dtor で旧値を解放 void swap(Buf& o) noexcept { std::swap(p_, o.p_); std::swap(n_, o.n_); } }; // 利点: 途中で throw すると rhs の ctor 段階で失敗 → this は無傷 // swap は noexcept なので commit は失敗しない

ポイントは 2 つ:「失敗する可能性のある作業(複製)は commit より前に済ませる」「commit(swap)は noexcept」。これで操作が成功か、何もしなかったか、のどちらかになります。

4. noexcept を付ける・付けないの判断表

種類noexcept?理由
デストラクタ必須スタック巻き戻し中に投げると二重例外 = terminate
move コンストラクタほぼ必須vector 伸長で move が選ばれる条件
move 代入ほぼ必須上に同じ
swap必須copy-and-swap の commit 段で投げないため
数値計算のユーティリティ付けてよい例外境界を越えないなら最適化に効く
コンストラクタ全般付けない方がよいメンバの割り当て失敗で bad_alloc 等を投げうる
I/O 系 / ネットワーク付けない失敗が普通に起きる領域
ユーザコールバックを呼ぶ関数付けないユーザ側が投げるかも

4-1. 条件付き noexcept(テンプレでよく使う)

cond_noexcept.cppC++
template<class T> void swap(T& a, T& b) noexcept( std::is_nothrow_move_constructible_v<T> && std::is_nothrow_move_assignable_v<T> ) { T tmp = std::move(a); a = std::move(b); b = std::move(tmp); } // T の move が noexcept なら swap も noexcept // → 依存先の契約を透過的に引き継げる

5. C の経験と結びつける

  • C の free()絶対に失敗しない契約 → C++ のデストラクタの noexcept と同じ思想。
  • C で「エラー時もリソースを漏らさない」ための goto cleanup; パターン → C++ では RAII + 例外 でデストラクタが自動で掃除。
  • C の memcpy などは失敗しない関数として設計されている → C++ の noexcept な関数は、そういう「失敗しない側」のインターフェース宣言。
  • コンパイラは noexcept な関数呼び出しで例外ハンドラ用テーブルを省略でき、コードサイズと実行速度が若干良くなります。
まとめ: noexcept は単なる最適化ヒントではなく、「vector などのライブラリが move を選ぶか copy を選ぶか」の契約書。デストラクタ / move / swap は確実に付けよう。

6. 理解度チェック

4 問。

Q1. noexcept 関数が実際に例外を投げるとどうなる?

コンパイルエラーになる
呼び出し元の catch で普通に受けられる
std::terminate が呼ばれて強制終了する
noexcept は約束。破ると catch する暇もなく terminate です。コンパイラは警告程度で、エラーにはしません。

Q2. vector<T> が push_back で伸長する時、T の move が noexcept でないとどうなる?

安全のため copy にフォールバックする
強制的に move が使われる
コンパイルエラーになる
vector は strong guarantee を保つため、move が投げうる場合は copy を選ぶ。自作型は move を noexcept に宣言するのが鉄則。

Q3. 「strong guarantee」の定義として正しいのは?

例外を絶対に投げない
例外が起きたら操作前の状態に完全復帰する
例外が起きても有効な状態を保つ(値は変わりうる)
strong = ロールバック保証。「何もしなかったか、全部成功したか」のどちらかになる。copy-and-swap で実現するのが定番。

Q4. 次のうち noexcept を「付けないほうがいい」のは?

デストラクタ
swap
std::string を受け取ってファイルを開くコンストラクタ
ファイル I/O は普通に失敗する(存在しない、権限ない)。そこで noexcept を付けると、失敗のたびに terminate してしまう。