この記事は、インテル® デベロッパー・ゾーンに公開されている「The Ultimate Question of Programming, Refactoring, and Everything」の日本語参考訳です。
34. 未定義の動作は考えているよりも身近なもの
実際のアプリケーションからこの問題の例を示すことは困難です。しかし、以下に示す問題につながる可能性がある疑わしいコードを目にすることがよくあります。このエラーは、大きな配列サイズを扱う際に発生する可能性がありますが、私はこのサイズの配列を使用しているプロジェクトを知りません。ここでは、実際に 64 ビットのエラーを収集するわけではないので、簡単な例を作成しました。
次のコード例について考えてみます。
size_t Count = 1024*1024*1024; // 1 Gb if (is64bit) Count *= 5; // 5 Gb char *array = (char *)malloc(Count); memset(array, 0, Count); int index = 0; for (size_t i = 0; i != Count; i++) array[index++] = char(i) | 1; if (array[Count - 1] == 0) printf("The last array element contains 0.\n"); free(array);
説明
プログラムの 32 ビット・バージョンをビルドする場合、このコードは正常に動作します。64 ビット・バージョンをビルドする場合、状況はより複雑になります。
64 ビットのプログラムは、5GB のバッファーを割り当て、ゼロで初期化します。そして、ループが非ゼロ値 (ここでは “| 1”) でバッファーを更新します。
Visual Studio* 2015 を使用して x64 モードでコンパイルする場合、このコードがどのように動作するのか推測してみてください。推測できたら、次に進みます。
このプログラムのデバッグバージョンを実行すると、インデックスが境界外になりクラッシュします。ある時点でインデックス変数がオーバーフローして、値が ?2147483648 (INT_MIN) になります。
論理的であると思われるでしょう? 実際には、全然違います。これは未定義の動作で、何が起こるか分かりません。
より詳しい情報は、次のリンクを参照してください。
- 整数オーバーフロー (英語)
- C/C++ の整数オーバーフローの考察 (英語)
- C++ で符号付き整数オーバーフローは未定義の動作か? (英語)
面白いことに、これが未定義の動作の例であると言うと、異を唱える人がいます。なぜだか分かりませんが、彼らは C++ とコンパイラーの動作についてすべて把握していると考えているような気がします。
しかし実際には、彼らはそれらを把握していません。もし把握していれば、次のようなことは言わないでしょう (これは、私個人の意見ではなく、グループの意見です)。
これは論理的におかしいです。確かに、形式上 ‘int’ オーバーフローは未定義の動作を引き起こします。しかし、それは形式上のことです。実際には、常に何が起こるか分かります。INT_MAX に 1 を足せば、INT_MIN になります。一般的でないアーキテクチャーでは分かりませんが、私の Visual C++*/GCC コンパイラーでは不正な結果になります。
そして、魔法を使用することなく、私は、珍しくもなんともない一般的な Win64 プログラムで、単純な例を使用して未定義動作の例を示します。
上記の例をリリースモードでビルドして実行するだけで十分です。プログラムがクラッシュしなくなり、警告「最後の配列要素が 0 です」が出力されなくなります。
未定義の動作は、次のような形で表れます。int 型のインデックス変数はすべての配列要素をインデックスするのに十分な幅がないにもかかわらず、配列は完全に埋まった状態になります。まだ信じられない方は、アセンブリー・コードを確認してみてください。
int index = 0; for (size_t i = 0; i != Count; i++) 000000013F6D102D xor ecx,ecx 000000013F6D102F nop array[index++] = char(i) | 1; 000000013F6D1030 movzx edx,cl 000000013F6D1033 or dl,1 000000013F6D1036 mov byte ptr [rcx+rbx],dl 000000013F6D1039 inc rcx 000000013F6D103C cmp rcx,rdi 000000013F6D103F jne main+30h (013F6D1030h)
これが未定義の動作です。そして、使用したのは珍しいコンパイラーではなく VS2015 です。
int を unsigned に置き換えると、未定義の動作は発生しなくなります。配列は部分的に埋まった状態となり、最後に 「最後の配列要素が 0 です」というメッセージが出力されます。
以下は、unsigned に置き換えた後のアセンブリー・コードです。
unsigned index = 0; 000000013F07102D xor r9d,r9d for (size_t i = 0; i != Count; i++) 000000013F071030 mov ecx,r9d 000000013F071033 nop dword ptr [rax] 000000013F071037 nop word ptr [rax+rax] array[index++] = char(i) | 1; 000000013F071040 movzx r8d,cl 000000013F071044 mov edx,r9d 000000013F071047 or r8b,1 000000013F07104B inc r9d 000000013F07104E inc rcx 000000013F071051 mov byte ptr [rdx+rbx],r8b 000000013F071055 cmp rcx,rdi 000000013F071058 jne main+40h (013F071040h)
正しいコード
プログラムが適切に動作するように、適切なデータ型を使用する必要があります。大きなサイズの配列を扱う場合、int や unsigned ではなく、ptrdiff_t、intptr_t、size_t、DWORD_PTR、std::vector::size_type などが適切な型です。上記の例では、size_t を使用すべきです。
size_t index = 0; for (size_t i = 0; i != Count; i++) array[index++] = char(i) | 1;
推奨事項
C/C++ 言語規則で未定義の動作になると定義されている場合、それについて異を唱えたり、将来の動作を予測しようとすべきではありません。単純にそのような危険なコードは記述すべきではありません。
負数のシフト、this と null の比較、または符号付きの型のオーバーフローで、疑わしいものを認めない頑固なプログラマーがいます。
そうなるべきではありません。現在プログラムが動作しているからと言って、問題がないわけではありません。未定義の動作は、予測不可能な方法で表れます。予想されるプログラムの動作は、未定義の動作の可能性の 1 つに過ぎません。
コンパイラーの最適化に関する詳細は、最適化に関する注意事項を参照してください。