「闇の魔術に対する防衛術 Advent Calendar 2020」が空きまくっているので急遽書きました。
ハリー・コーダーと謎のprintf
printf(""); // なぜかこの行がないと動かない
というのは定番のネタですが, 「未定義動作」を踏んでいる可能性が高いです。
未定義ってセグフォとかで異常終了するやつでしょ?
違います。
未定義動作はマジで未定義なのでなにが起こるかわかりません。
「典型的な」動作として, 以下のようなことが起こりえます。
- プログラムが異常終了します。 記事によっては「最悪の場合プログラムが異常終了します」みたいなことが書かれていますが正直なところこの結末は最悪どころかむしろだいぶマシな方です。
- 想定外の挙動を起こしながら走り続けます。 たとえば配列の範囲外アクセスを行った結果として周囲のメモリを破壊しつくすかもしれません。
- その部分が抹消されます。 コンパイラは未定義動作は決して起こらないとみなし, それにより生み出されるすべてを実行されないデッドコードだと解釈し, 最適化のために削除するかもしれません。
この 3 パターン目がキモです。 最適化の際, 以下のような「観測可能な動作」を変更することは許されませんので, 未定義動作を踏んでいるときprintf
の有無によって最適化後の挙動がまるで異なるということが起こりえます。
-
volatile
へのアクセス - プログラム終了時ファイルへ書き込まれるデータ
- インタラクティブデバイスの入出力とその順序 ← イマココ
どうしても未定義動作を入れ込んじゃだめ?
だめです。 「環境依存」なんて言葉でごまかす人もいますが, そういう話ではありません。 現実のお仕事は必ずしも理想通りにはいかないものですが, それでも「いま動いているから」で放置するのははっきり言って愚策です。
そもそも先程挙げたのは「典型的な」挙動であり, 仕様は未定義動作に対してなにも要求していません。 つまり, 極論ですが以下のような動作さえ許容されます。
- 「悪意のある」コンパイラは未定義動作全体を
rm -rf /
に書き換えるかもしれません。 仕様は倫理より重い……! - シェフの気まぐれサラダ。 コンパイルするごとにまったく違うコードを吐き出すかもしれません。 環境依存なんて言ったのはどこのどいつだ?
- プログラマの鼻から悪魔を出す。
コワイ! どんな動作が未定義なの?
以下に挙げるのは一例であり一覧ではありません。
配列の範囲外へのアクセス
上でも挙げた例ですが, 定義された配列の外側へのアクセスは未定義です。
int isin(int x) {
int arr[] = {12, 46, 102};
for (int i = 0; i <= 3; i++) { // 終了条件を間違えている
if (arr[i] == x) {
return 1;
}
}
return 0;
}
コンパイラは「arr[3]
にアクセスされることはありえないのだから, このループは常に終了前に関数を抜け出すはずだ」と解釈し, 以下のように書き換える可能性があります。
int isin(int) {
return 1;
}
NULL ポインタの参照外し
NULL ポインタに対する参照外しは未定義です。
int notnull(int *x) {
int y = *x; // 参照外し
if (!x) {
return 0;
}
return 1;
}
x
への参照外しが発生しているためコンパイラはx
が NULL ポインタである可能性を排除してしまいかねません。 すると if 文はデッドコードとなり以下のように最適化されます。
int notnull(int) {
return 1;
}
ゼロ除算
ゼロ除算は未定義です。
int divideByMe(int x) {
return x / x; // 0以外が来れば1, 0ならエラー…?
}
エラーを返す代わりに以下のように最適化してしまうかもしれません。
int divideByMe(int) {
return 1;
}
1
返してばっかすね。
C++でa == 1 && a == 2 && a == 3をtrueにしてみたい(クソ解法)
値を返さないvoid
以外の関数
値を返さずに関数を抜けるのは未定義です。
C++の未定義動作でa==1&&a==2&&a==3をtrueにしてみたい
無限ループ
無限ループは未定義です。 ループは必ず停止するという前提で最適化される恐れがあります。
未初期化の変数の利用
初期化前に変数を使うのは未定義です。 ぐちゃぐちゃの値が出る, と教わりましたか? 実はそれすら保証されません。
int lawOfExcludedmiddle() {
int x;
if (x || !x) { // 初期化前のxを使っている!!
return 1;
}
return 0;
}
未初期化前の変数は使われないはずなので以下のように最適化されてしまう可能性があります。 さよなら排中律。
int lawOfExcludedmiddle() {
return 0;
}
符号付き整数のオーバフロー
初代マリオでは無限 1UP のしすぎで即死したりしましたが, 符号付き整数のオーバフローは未定義なので負の値に還ると信じ込んでいると痛い目を見ます。
表現範囲外のシフト
整数型のサイズを超えたシフトや負のシフトは未定義です。
モダンな言語と同様にサイズとの剰余でシフトしてるって? だからそれを前提にしたコードはバグなんだって!
結局私たちはどうすればいいの?
以下の方法を取ることができます。 しかし, どれも過信はできません。
- サニタイザをオンにすることで, 実行時に未定義作動を検出したら落ちるようにします。
- コンパイルオプションをつけることで, コンパイル時に未定義動作を発見したらコンパイル失敗するようにします。
- lint ツールを使うことで, 静的解析時に未定義動作を可能な限り発見します。
- あなた自身が仕様書に精通することで, すべての未定義動作を生まれる前に消し去ります。 すべての宇宙, 過去と未来のすべての未定義動作を, この手で。