Menu

C++の未定義動作:その正体と回避方法

未定義動作(UB)とは、C++標準が一切のルールを定めていないコードのことです。クラッシュしたり、データを破壊したり、一見正常に動いたりします。よくある原因、「ちゃんと動いた」が何も証明しない理由、そしてUBを検出するツールを学びましょう。

このページのコードはエディタで実行できます - 編集してすぐに結果を確認できます。

「未定義動作」が実際に意味するもの

前のページでは、プログラムが定義して意図的に投げるエラーを try/catch がどう扱うかを示しました。未定義動作はその逆です。C++標準が一切の意味を与えることを拒否する操作の集まりです。捕まえるべき例外も、エラーコードも、クラッシュの保証もありません。コンパイラはUBが決して起きないと仮定してよく、起きたときには好きなことをしてよいのです。

その自由こそがUBをこれほど危険にしています。同じバグのある行が、ノートPCでは「正しい」答えを出力し、サーバーではゴミ値を返し、-O2 では最適化器によって丸ごと削除されることがあります。UBは「私たちが文書化していない動作」ではなく、「言語が何も約束しない動作」です。あなたの仕事は、そもそもそれを書かないことです。

int arr[3] = {1, 2, 3};
int x = arr[5];   // 未定義動作:配列の末尾を越えて読んでいる

ここにコンパイルエラーはなく、多くの実行ではこっそり迷子の整数を渡してくるでしょう。その見かけ上の成功こそが罠なのです。

範囲外の読み書き

UBの最も一般的な形は、自分の所有していないメモリに触れることです。組み込み配列と std::vector::operator[] は境界チェックを一切行いません。末尾を越えたインデックス(あるいは負のインデックス)は、読んでも書いても即座にUBです。

注意すべきバグは、< のつもりで <= を使ってしまうことです。i == v.size() のとき、最後の要素のひとつ先をインデックスしてしまい、これはUBです。インデックスが不要なときは範囲ベースの for(既出)を使いましょう。末尾を越えて走ることがないからです。それでも手動でインデックスし、安全網が欲しいときは、v.at(i) はメモリを静かに壊す代わりに std::out_of_range を投げます。

バグを追っている間は at() を使い、インデックスが正しいと証明できたら、ホットループでは [] に戻しましょう。

ダングリングポインタと解放後使用(use-after-free)

指している対象のオブジェクトより長く生き残るポインタや参照はダングリングです。それを使うのはUBです。メモリは再利用されたか、解放されたか、そもそも存在しなかったかもしれません。これはスマートポインタ(前の章)が避けるのを助けてくれる罠ですが、生ポインタは依然としてあなたをそこへ落とし込みます。

最も鋭い形は、ローカル変数のアドレスを返すことです。ローカル変数は関数が返ると死ぬので、呼び出し側は何も指さないポインタを抱えることになります。

int* makeNumber() {
    int n = 42;
    return &n;   // ローカル変数のアドレスを返す - return 後には消えている
}
// 結果をデリファレンスするのは未定義動作。

同じことが delete の後や、vector が再確保してそれを指すイテレータやポインタを無効化したときにも起こります。

int* p = new int(5);
delete p;
cout << *p;   // 解放後使用:未定義動作

vector<int> v = {1, 2, 3};
int* first = &v[0];
v.push_back(4);   // 再確保するかもしれない - 'first' はもうダングリング
cout << *first;   // 未定義動作

防御策は、あなたがすでに知っているものです。どれかのポインタが必要としている限りオブジェクトを生かしておく、所有権を持つ生ポインタよりも参照やスマートポインタを優先する、そしてコンテナのサイズを変えうる操作の後はポインタ/イテレータを取り直す、ということです。

未初期化変数と符号付きオーバーフロー

値を与える前に変数を読むのは、組み込み型ではUBです。既定の 0 などありません。変数はそのメモリにもともとあったビットを保持し、最適化器はあなたが未初期化のまま読むことは決してないと仮定してよいのです。

もし sum がただの int sum; と宣言されていたら、sum += i のたびにまず不定値を読むことになります。これはUBであり、しばしば動いているように見えるため、悪名高いほど厄介なバグです。初期化を習慣にしましょう。int x = 0; あるいは int x{}; です。

もうひとつの静かな違反者が符号付き整数オーバーフローです。符号付き int をその最大値を越えて押し上げるのはUBです(符号なし型は予測可能に折り返しますが、符号付き型はそうではありません)。

int big = 2147483647;   // 32ビット int の INT_MAX
int oops = big + 1;     // 符号付きオーバーフロー:未定義動作

「負の数に折り返す」ことを当てにしないでください。コンパイラはオーバーフローが起こり得ないと仮定し、それを前提に最適化してよいのです。定義された折り返しが必要なら、符号なし型を使うか、加算する前に範囲をチェックしましょう。

サニタイザーと警告でUBを捕まえる

UBについては、テストによって確信に到達することはできません。成功した実行は何も保証しないからです。効くのは、コンパイラのサニタイザー(GCCとClangで利用可能)を使って実行時にUBを大声で鳴らせることです。

// AddressSanitizer:範囲外、解放後使用、リーク
g++ -fsanitize=address -g -O1 main.cpp -o app && ./app

// UndefinedBehaviorSanitizer:符号付きオーバーフロー、ヌルデリファレンス、不正なキャスト
g++ -fsanitize=undefined -g main.cpp -o app && ./app

既存のテストをこれらのフラグの下で実行すれば、「ちゃんと動いた」範囲外読み取りや解放後使用、符号付きオーバーフローが、ファイルと行を名指しする正確なレポートに変わります。-Wall -Wextra と組み合わせれば、実行する前から(未初期化の読み取りらしきものなど)疑わしいコードもコンパイラが指摘してくれます。

==1234==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
READ of size 4 at 0x... thread T0
    #0 main.cpp:7 in main

サニタイザーのレポートはどれも、無視してよい警告ではなく、必ず直すべきバグとして扱いましょう。その行について標準は何も約束しない、と告げているのです。

まとめ

未定義動作はC++の安全レールが外れる部分です。範囲外アクセス、ダングリングポインタ、解放後使用、未初期化読み取り、符号付きオーバーフローはいずれも何の定まった意味も持たないコードを生み、「ちゃんと動いた」は決して正しさの証明にはなりません。安全でいるための方法は、守りを固めて書くこと(あらゆる変数を初期化し、コンテナの境界を守り、ヒープメモリの所有をスマートポインタに任せる)、そして -fsanitize=address-fsanitize=undefined-Wall -Wextra で検証し、静かなUBを大声で鳴る修正可能なレポートに変えることです。

これでエラーとデバッグの章は終わりです。例外、try/catch、そしてUBへの健全な恐れを身につけたあなたは、いまや、静かに偶然に失敗するのではなく、大声で意図的に失敗するC++を書くための道具を手にしています。

よくある質問

C++の未定義動作とは何ですか?

未定義動作(UB)とは、C++標準が明示的に何の定まった結果も与えていない操作のことです。たとえば配列の末尾を越えて読むことや、ダングリングポインタをデリファレンスすることなどです。コンパイラは何でもして構いません。クラッシュする、ゴミ値を返す、コードを最適化で消す、あるいは今日は動くように見えても再コンパイル後に壊れる、といった具合です。これは言語の機能ではなく、あなたのプログラムのバグです。

未定義動作があるのに、なぜ私のC++プログラムは動くのですか?

「ちゃんと動いた」はUBについて何も証明しません。標準はどちらの方向にも何の保証も与えないので、UBのバグは今日あなたのマシン・あなたのコンパイラでは期待どおりの結果を出し、別の最適化レベル・プラットフォーム・コンパイラのバージョンではクラッシュすることがあります。成功した実行をUBが無害である証拠と決して受け取らず、サニタイザーを使って実際にそれを捕まえてください。

C++で未定義動作はどうやって捕まえますか?

サニタイザーを付けてコンパイルしましょう。-fsanitize=address(AddressSanitizer)は範囲外の読み書きや解放後使用(use-after-free)を見つけ、-fsanitize=undefined(UndefinedBehaviorSanitizer)は符号付きオーバーフロー、ヌルデリファレンス、不正なキャストを指摘します。警告を有効にし(-Wall -Wextra)、これらのフラグの下でテストを実行してください。これらは静かなUBを実行時の明確なレポートに変えてくれます。

Coddy programming languages illustration

Coddyでコードを学ぼう

始める