コンテンツにスキップ

未定義動作・実装定義・未規定動作

一言で言うと

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でも「典型的な実装では〜」と書いた箇所は保証ではありません。

防衛手段

  1. 警告を最大化: -Wall -Wextra(GCC/Clang)、/W4(MSVC)。警告はUBの前兆を多く捕まえる(→ 警告レベル)
  2. サニタイザ: AddressSanitizer(範囲外・UAF)、UBSan(オーバーフロー等)を開発ビルドで常用(→ サニタイザ)
  3. 言語機能で塞ぐ: at()、スマートポインタ、std::span、初期化必須の書き方(int x = 0;)、enum class
  4. デバッグビルドのSTLチェック(MSVCのデバッグイテレータ等)

C#との違い

C#にもunsafeや一部未定義に近い領域はありますが、通常のC#コードは「壊れ方が定義されている」(例外・null・確定的な動作)世界です。「C++はコンパイルが通った=正しさの保証がある」とはC#以上に言えない、というマインドセットの切り替えが必要です。

Unity開発者が誤解しやすい点

  1. 「エラーが出ないなら合ってる」— UBは静かに通る。動作確認はUBを検出しない
  2. 「try-catchで拾える」— UBは例外ではない(→ 例外)
  3. 「未初期化でもデフォルト値(0)になる」— C#のフィールドは0初期化されるが、C++のローカル変数はゴミ値(読んだらUB)

ゲーム開発での使用例(遭遇シーン)

  • リリースビルドだけで起きる敵の挙動バグ → 未初期化変数・UBを疑う定番シーン
  • セーブデータをmemcpyで読む処理の移植で壊れる → エンディアン・パディング(実装定義)
  • 「たまに」壊れる物理 → データレース(UB)

理解度チェック

  1. UB・実装定義・未規定の違いを一言ずつで言えますか。
  2. 「UBはたまたま動く」がなぜ最悪の性質なのですか。
  3. UBが存在する理由(言語設計上の交換条件)は?

演習

samples/ub_examples.cpp(コンパイルはするが実行しない注釈付きの教材)を読み、各UBに対して (a) どの防衛手段(警告/サニタイザ/言語機能)が検出できるかを表にしてください。


前: STL | カテゴリ目次 | 次: 演習