一つの誤った前提から、いくつもの異なる結果へ

10月25日に公開された新たなプログラミングの実演は、CとC++における、よく知られていながら誤解も多いリスクを浮き彫りにしている。プログラムが言語仕様で未定義とされる動作を引き起こすと、開発者は、コンパイラがソースコードの各文を文字どおりに実行するかのように結果を推論することはできなくなる。

プログラマーのThomas Habetsによるこの実演は、誤った操作をしてもセグメンテーションフォールトが起きるだけなので、未定義動作は無害だという考えに応えたものだ。実際に示された例では、ソースコードに到達不能であることを示すマーカーや不正なポインター操作が含まれると、コンパイラの出力が大きく変化している。ある例では、ソースコード上は呼び出されていないように見える関数が、生成されたプログラムの動作の一部になっている。

続いてHabetsは、到達不能を示すマーカーを、ヌルポインターを介して書き込むコードに置き換えた。公開された実験によると、生成されたプログラムは、ソースコードにある値99を保持せず、アドレス0に0を書き込もうとする。その後、不正な命令によって実行が終了する。その生成プログラムに定数99は存在しない。

わずかな変更で、また別の結果が生まれる。書き込みの前にヌルポインターを進めると、コンパイラは、ソースコードを素朴に1行ずつ解釈した場合により近い動作を生成する。Habetsは、GCCとClangでおおむね同じ傾向が見られたと報告している。この対比こそが実験の要点だ。表面的には似ている不正な操作でも、プログラマーが頼れる安定した代替動作が得られるわけではない。

最適化は言語仕様の保証を前提にする

コンパイラは、正しいプログラムがその言語の規則に従うという前提で最適化する。ある操作に到達すること自体が規則違反になる場合、最適化処理は、周辺のコードを変換する際に、その経路は実行され得ないものとして扱うことができる。プログラマーが望む異常終了の仕方を維持する必要はなく、バイナリを生成する前に、起こり得るあらゆる事例を診断する義務もない。

この実験は、テスト実行が成功したことを、不正なコードでも問題ない証拠として扱わないよう注意も促している。コンパイラ、最適化設定、対象アーキテクチャ、あるいは近くのソースコードが変われば、出力も変わり得る。今日観測したクラッシュは、同じバグが明日も、状態を破損させたりデータを露出させたりする前に、確実にクラッシュすることを保証しない。

実務上の結論は限定的だが重要だ。未定義動作は、プログラムの設計に組み込むのではなく、取り除くべきである。こうしたバグを調べる開発者は、サニタイザー、警告、問題を再現する最小限のテストケースを利用できる。ただし、これらの道具を使っても、不正な操作が、環境をまたいで通用する動作保証に変わるわけではない。Habetsの例は、規則をすでに破った後に特定のマシンが通常どう動くかという想定ではなく、正しいソースコードを出発点に考えなければならない理由を示している。