未定義動作

コンピュータプログラミングにおいて、未定義動作とは、コンピュータ言語が準拠する言語仕様において動作が予測できないと規定されたプログラムを実行した結果のことである。これに対して、言語仕様が動作結果を規定せず、プラットフォーム上の別のコンポーネントのドキュメントが処理系の実装を規定する動作のことを未規定動作と呼ぶ。

「未定義のコードを実行した結果コンパイラは何をしてもいい。鼻から悪魔が飛び出しても仕様に反しない」というcomp.std.cでの投稿から、C言語コミュニティではユーモアを込めて未定義動作のことを「鼻の悪魔」と呼ぶことがある。

1. 概要
-----

一部のプログラミング言語では、//プログラムの実行中に未定義動作が発生しないならば//、ユーザーから見える副作用が同じである限り、ソースコードと異なるプログラムの動作や制御フローを持つことを許容している。この意味において未定義動作とは、仕様においてプログラムが満たしてはならない一連の条件を指すといえる。

C言語の初期のバージョンにおいて未定義動作を設けることは、さまざまなマシンに対応したパフォーマンスの高いコンパイラを作成するために有利だった。特定の構成をマシン固有の機能にマッピングでき、コンパイラは言語によって課せられたセマンティクスに副作用が一致するよう、ランタイム用に追加のコードを生成する必要がなかった。これにより、ユーザーは特定のコンパイラとそれがサポートするプラットフォームさえ知っていればプログラムのソースコードを書くことが可能となった。

しかしながら、プラットフォームの標準化が進むにつれ、特に新しいバージョンのCでは、これは大きな利点ではなくなっていった。現在のプログラムにおける未定義動作は、配列の範囲外アクセスなど、コード内の明確なバグである可能性が高い。定義上、ランタイムシステムは未定義動作が発生しないと想定するため、このような無効な条件をチェックする必要がない。コンパイラからすると、これにより様々なプログラム変換をすることができるようになったり、プログラムの正当性の証明を単純化できるということでもある。これにより、さまざまな種類の最適化が可能になるが、逆に言えばプログラムの実行状態がそのような未定義の条件を満たしてしまった場合、誤動作につながってしまうこともある。また、コンパイラはプログラマーに通知することなくソースコードに含まれる明示的なチェックを削除することができるため、例えば、未定義の動作が発生したかどうかをテストして検出する、などということは、定義上保証されない。これにより、可搬性のあるフェイルセーフオプションをプログラムすることは事実上困難、あるいは不可能となる。

現在のコンパイラ開発では、通常、コンパイラのパフォーマンスを評価する際、マイクロ最適化を中心に実装されたベンチマーク結果によって比較する。これは、汎用デスクトップおよびラップトップ市場で主に使用されるプラットフォームでも同様である。したがって、未定義動作を定めることにより、特定のソースコードの記述を実行時に任意のものにマッピングできるため、コンパイラのパフォーマンスを向上させるための十分な余地を与えることができる。

CやC++の場合、コンパイラはコンパイル時に未定義動作のチェックを行うことができるが、これは必須ではない。論理式におけるドントケア項と同じように、コンパイラの実装では未定義動作が含まれる場合には何をしても正しいと見なされる。未定義動作を引き起こさないコードを作成するのはプログラマーの責任だが、コンパイラの実装側で未定義動作が発生したかどうかの診断を実行することも可能であり、特に最近のコンパイラには、そのような診断を有効にするフラグがある。たとえば、-fsanitizeオプションを使用すると、 gcc 4.9およびclangで「未定義動作サニタイザー」を有効にすることができる。ただし、このフラグはデフォルトではなく、有効にするかどうかはコードをビルドする人に委ねられている。

状況によっては、未定義動作の実装に特定の制限がある場合がある。たとえば、CPUの命令セットの仕様では、一部の命令形式の動作が未定義とされる場合があるが、CPUがメモリ保護をサポートしている場合、仕様には、ユーザーがアクセスできる命令がオペレーティングシステムのセキュリティに穴を開けてはいけないと規定する上位のルールが含まれている可能性がある。したがって、実際のCPUはそのような未定義の命令に応答してユーザーレジスタを破損することは許されるが、たとえば、スーパーバイザーモードに切り替えることは許可されない。

ツールチェーンまたはランタイムによって、ソースコード中の特定の未定義動作のコードが実行時に使用可能な特定のメカニズムに対応付けされると明示的に文書化することにより、ランタイムプラットフォームは未定義動作に対してある種の制約または保証を行うこともできる。たとえば、言語仕様では定義されていない操作の特定の動作について、その言語のインタプリタは文書化することしないことも可能である。未定義動作のコードに対して、コンパイラはABIの実行可能コードを生成し、その動作に対する制限を行うことができる。すなわち、コンパイラのバージョンに依存する方法で言語仕様のセマンティクスdate=2023年6月}}のギャップを埋めることが可能である。これらの実装の詳細に依存することでソフトウェアの移植性は失われてしまうが、そのソフトウェアが特定のランタイム以外で使用することが想定されない場合など、このような移植性が問題ではない場合もある。

未定義動作は、プログラムのクラッシュなどのほか、データのサイレントロスや誤った結果の生成など、検出することが難しく一見正常に動作しているように見える障害を引き起こす可能性がある。

2. 利点
-----

ある操作を未定義動作として文書化することにより、コンパイラは、そのような操作が仕様に準拠したプログラムでは絶対に発生しないと想定することができる。これにより、コンパイラはコードに関するより多くの情報を得ることができ、この情報によってより踏み込んだ最適化を行うことができる可能性がある。

C言語での例：

int foo { int value = 2147483600; /* 32ビット整数型と8ビット文字列型を仮定 */ value += x; if  bar; return value; }

xは符号なし整数であるため負になることはない。よって、符号付き整数型のオーバーフローがCでの未定義の動作であることを踏まえると、コンパイラはvalue < 2147483600の条件が常に偽であると仮定できる。したがって、if文の条件節は副作用を持たず、かつその条件が必ず満たされないため、コンパイラはif文とそれに含まれるbar関数の呼び出しを無視することができる。つまり、このコードは意味的には次のものと同等である。

int foo { int value = 2147483600 value += x; return value; }

もしも符号付き整数型のオーバーフローにラップアラウンド動作があると規定されている場合、上記の変換は正当ではなくなる。

コードがさらに複雑だったり、インライン化など他の最適化が行われたりすると、このような最適化は見つけるのが難しくなる。たとえば、別の関数が上記の関数を次のように呼び出した場合、

void run_tasks { int z; z = foo; while  { run_one_task; } }

foo関数を検査すると、ptrxが指す初期値が47を超えることはないことが保証されることがわかる。つまり仕様に準拠したプログラムでは*ptrx > 60の条件チェックは常にfalseになるため、コンパイラはwhileループを直ちに除去することができる。さらに、戻り値のzは使用されずfoo関数は副作用を持たないため、コンパイラはrun_tasksを最適化して、即時に終了する空の関数にすることができる。fooが既にコンパイルされた別のオブジェクトファイルで定義されている場合、このようにwhileループが消えることは予測することが難しいかもしれない。

符号付き整数オーバーフローを未定義動作とすることのもう1つの利点は、ソースコード内の変数のサイズよりも大きいレジスタに変数の値を格納・操作できることである。たとえば、ソースコードで指定されている変数の型がレジスタのサイズよりも小さい場合、コンパイラは動作を変更することなく、生成するマシンコード内の変数としてレジスタを安全に使用できる。もしプログラムが32ビット整数型のオーバーフローの動作に依存している場合、ほとんどのマシン命令のオーバーフロー動作はレジスタサイズに依存するため、コンパイラは64ビットマシン用にコンパイルするときに追加のロジックを挿入する必要がある。

3. リスク
------

CおよびC++の標準には、全体を通していくつかの未定義の動作が定められており、これによってコンパイラの実装とコンパイル時検証の自由度が増す一方、これらの未定義動作がプログラムに含まれていた場合、実行時に未定義なふるまいをすることになる。特に、C言語のISO規格には、未定義動作の一般的な要因をリストしたアペンディクスが存在する。さらに、コンパイラは未定義動作に依存したコードを検出する必要はないため、多くのプログラマーは、誤って、あるいは単に数百ページに及ぶ可能性のある言語仕様に精通していないという理由で、未定義動作に依存したコードを書いてしまうのが一般的である。これにより、異なるコンパイラまたは異なる設定が使用されたときに明らかになるバグが発生する可能性がある。 Clangサニタイザーなどの、動的な未定義動作のチェックを有効にしてテストまたはファジングを行うことにより、コンパイラまたは静的解析によって検出されていない未定義動作を検出するのに役立つ可能性がある。

また未定義動作は、ソフトウェアセキュリティの脆弱性につながる可能性もある。たとえば、主要なWebブラウザのバッファオーバーフローやその他の脆弱性は、未定義動作が原因である。2038年問題も符号付き整数のオーバーフローに起因するバグの一つである。GCCの開発者が2008年にコンパイラの動作を修正して、未定義動作に依存する特定のオーバーフローチェックを省略した際、CERTは新しいバージョンのコンパイラを使うことに対して警告を行った。Linux Weekly Newsは、PathScale Cや、Microsoft Visual C++ 2005など複数のコンパイラで同じ動作が観察されたことを指摘したところ、CERTは警告の内容を修正し、対象のコンパイラをこれらのコンパイラに拡大した。

4. CおよびC++における例
---------------

パスカル・クオックとジョン・レガーによれば、C言語における未定義動作は、大きく次のような種類に分類できる。

spatial memory safety violations
temporal memory safety violations
integer overflow
strict aliasing violations
alignment violations
unsequenced modifications
data races
loops that neither perform I/O nor terminate C言語では、初期化される前に自動変数を使用すると、ゼロ除算、符号付き整数のオーバーフロー、配列の境界違反、またはヌルポインタのデリファレンスと同様の未定義動作が発生する。一般に未定義動作は、抽象化された実行マシンを不明な状態にするため、プログラム全体の動作を未定義にしてしまう。

文字列リテラルを変更しようとすると、未定義動作が発生する。

char *p = "wikipedia"; // C言語では許可、C++98/C++03では非推奨、C++11から不正 p[0] = 'W'; // 未定義動作

整数をゼロで除算すると、未定義動作が発生する。

int x = 1; return x / 0; // 未定義動作

特定の種類のポインタ操作は、未定義動作を引き起こす可能性がある。

int arr[4] = {0, 1, 2, 3}; int *p = arr + 5; // 未定義動作 p = 0; int a = *p; // 未定義動作

CおよびC++では、オブジェクトへのポインターの比較は、ポインターが同じオブジェクトのメンバーである、もしくは同じ配列の要素を指している場合にのみ厳密に定義される。

int main { int a = 0; int b = 0; return &a < &b; /* 未定義動作 */ }

<WtXmlEndTag />

return文に到達することなく値を返す関数の終わりに到達すると、関数呼び出しの値が呼び出し元によって使用される場合、未定義動作が発生する。

int f { } /* 未定義動作 */

2つのシーケンスポイントの間でオブジェクトを複数回変更すると、未定義動作が発生する。C++11の時点で、シーケンスポイントに関連して未定義動作を引き起こす要因にはかなりの変更が行われたが、次の例では、CとC++の両方で未定義動作が発生する。

i = i++ + 1; // 未定義動作

2つのシーケンスポイントの間でオブジェクトを変更する場合、格納する値を決定する以外の目的でオブジェクトの値を読み取ることも、未定義動作となる。

a[i] = i++; // 未定義動作 printf); // 同様に未定義動作

CとC++では、値のサイズ以上のビット数、あるいは負の数だけビットシフトすると、未定義動作が発生する。 ここで、コンパイラに関係なく最も安全な方法は、シフトするビット数を常に0からsizeof*CHAR_BIT - 1 までの範囲内にしておくことである。

int num = -1; unsigned int val = 1 << num; // 未定義動作

num = 32; // もしくは31より大きな任意の整数 val = 1 << num; // リテラル「1」は32ビット整数型であるため、31ビット以上のビットシフトは未定義動作となる

num = 64; // もしくは63より大きな任意の整数 unsigned long long val2 = 1ULL << num; // リテラル「1ULL」は64ビット整数型であるため、63ビット以上のビットシフトは未定義動作となる<WtXmlEndTag />

5. 関連項目
-------

コンパイラ

6. 参考文献 
--------

7. 関連項目
-------

Peter van der Linden, //Expert C Programming.// 0-13-177429-8}}ISBN   0-13-177429-8
[1], John Regehr 
[2]Pascal Cuoq  and John Regehr 

8. 外部リンク
--------

[3] #pragmaについてはセクション6.10.6を参照