未定義動作・実装定義・未規定動作¶
一言で言うと¶
C++標準は、すべてのコードの動作を定義していません。「定義されていない度合い」に3段階あります。
- 未定義動作 (UB: Undefined Behavior): 標準が一切の要求をしない。何が起きてもよい(クラッシュ、黙って壊れる、たまたま動く)。プログラムに含めてはいけない
- 実装定義 (implementation-defined): 処理系(コンパイラ)が決めて文書化する。移植性はないが、その処理系では一貫(例:
intのサイズ、charの符号) - 未規定 (unspecified): いくつかの候補のどれかになるが、文書化義務なし・その都度違ってもよい(例: 関数引数の評価順)
なぜUBという仕組みが存在するのか¶
C#なら範囲外アクセスは例外です。C++が「チェックして例外」にしないのは、チェックのコストを払わないという言語の設計選択だからです。コンパイラは「UBは起きない」と仮定して最適化します——境界チェックの省略、ループの変形、null チェックの削除。UBは高速さの代金であり、その代金は「プログラマがUBを書かない責任を負う」ことで支払われています(→ UBと最適化の関係)。
ゲーム開発で踏みやすいUBカタログ¶
// C++20 — すべて未定義動作(コンパイルは通る!)
int a[3];
int x = a[3]; // 1. 配列範囲外(v[i]も同じ)
int* p = nullptr;
int y = *p; // 2. ヌルポインタ参照外し
Enemy* e = GetDead(); // 3. 解放済みメモリへのアクセス(use-after-free)
e->hp = 0;
int big = INT_MAX + 1; // 4. 符号付き整数のオーバーフロー(unsignedは定義あり: wrap)
int i = 0;
int z = ++i + i++; // 5. 同一変数への非順序の複数副作用
float f; float g = f * 2; // 6. 未初期化変数の読み取り
// 7. データレース(同期なしの複数スレッド読み書き → 第7部)
// 8. strict aliasing違反(reinterpret_castで別型として読む → キャスト)
// 9. ダングリング参照(寿命切れのローカルへの参照を返す)
UBの怖さ: 「たまたま動く」¶
UBの最悪の性質はエラーにならないことです。
- デバッグビルドでは動き、リリースビルドで壊れる(最適化がUB前提の変形をするため)
- 今日は動き、コンパイラ更新で壊れる
- この関数では動き、インライン化されたら壊れる
- 「動いているからOK」がUBに対しては成立しない
// 有名な例: コンパイラはUBを「起きない」と仮定して分岐を消す
int Get(int* p) {
int v = *p; // ここでpを参照外し
if (p == nullptr) return 0; // 「参照外しできたのだからnullではない」→ この行は削除されうる
return v;
}
実装定義・未規定の例(こちらは「知って使う」もの)¶
| 分類 | 例 | 実務での扱い |
|---|---|---|
| 実装定義 | sizeof(int)(多くの環境で4)、charの符号、ビットフィールドの詰め方 |
固定幅が要るなら int32_t 等を使う。バイナリ保存では明示 |
| 実装定義 | エンディアン | 通信・セーブデータで意識 |
| 未規定 | f(A(), B()) のAとBの評価順 |
評価順に依存するコードを書かない |
| 未規定 | 同一ステートメント内の関数引数評価順 | 同上 |
「標準で保証されること」と「うちの環境でこう動くこと」を区別するのがプロの読み書きです。本Wikiでも「典型的な実装では〜」と書いた箇所は保証ではありません。
防衛手段¶
- 警告を最大化:
-Wall -Wextra(GCC/Clang)、/W4(MSVC)。警告はUBの前兆を多く捕まえる(→ 警告レベル) - サニタイザ: AddressSanitizer(範囲外・UAF)、UBSan(オーバーフロー等)を開発ビルドで常用(→ サニタイザ)
- 言語機能で塞ぐ:
at()、スマートポインタ、std::span、初期化必須の書き方(int x = 0;)、enum class - デバッグビルドのSTLチェック(MSVCのデバッグイテレータ等)
C#との違い¶
C#にもunsafeや一部未定義に近い領域はありますが、通常のC#コードは「壊れ方が定義されている」(例外・null・確定的な動作)世界です。「C++はコンパイルが通った=正しさの保証がある」とはC#以上に言えない、というマインドセットの切り替えが必要です。
Unity開発者が誤解しやすい点¶
- 「エラーが出ないなら合ってる」— UBは静かに通る。動作確認はUBを検出しない
- 「try-catchで拾える」— UBは例外ではない(→ 例外)
- 「未初期化でもデフォルト値(0)になる」— C#のフィールドは0初期化されるが、C++のローカル変数はゴミ値(読んだらUB)
ゲーム開発での使用例(遭遇シーン)¶
- リリースビルドだけで起きる敵の挙動バグ → 未初期化変数・UBを疑う定番シーン
- セーブデータをmemcpyで読む処理の移植で壊れる → エンディアン・パディング(実装定義)
- 「たまに」壊れる物理 → データレース(UB)
理解度チェック¶
- UB・実装定義・未規定の違いを一言ずつで言えますか。
- 「UBはたまたま動く」がなぜ最悪の性質なのですか。
- UBが存在する理由(言語設計上の交換条件)は?
演習¶
samples/ub_examples.cpp(コンパイルはするが実行しない注釈付きの教材)を読み、各UBに対して (a) どの防衛手段(警告/サニタイザ/言語機能)が検出できるかを表にしてください。