C++ の未定義動作を書く人は、何が起こっても知ーらないっ!


プロローグ

先日、このような質問を受けました。

何故このコードは無限ループするのですか?配列外参照しているのはわかるけど、i は確かに増え続けてるし、while の条件に引っかからないのはおかしい!

コード

#include <iostream>
using namespace std;

int main() {
  int a[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
  int i = 0;
  while (i <= 10) {
    cout << "i = " << i << " a[i] = " << a[i] << endl;
    i++;
  }
}

出力

AtCoder のコードテスト で、C++ (GCC 9.2.1) を選択し上記のコードを提出すると、再現するかもしれません(この記事の落ちを先に書きますが、未定義動作なので再現しないかもしれません。)

i = 0 a[i] = 1
i = 1 a[i] = 2
i = 2 a[i] = 3
i = 3 a[i] = 4
i = 4 a[i] = 5
i = 5 a[i] = 6
i = 6 a[i] = 7
i = 7 a[i] = 8
i = 8 a[i] = 9
i = 9 a[i] = 10
i = 10 a[i] = -2143330816
i = 11 a[i] = -592414315
i = 12 a[i] = 0
i = 13 a[i] = 0
i = 14 a[i] = -1500878016
...

i = 10 のときに配列外参照が起こっています。
出力を見ると、明らかに i > 10 なので、while を抜けて欲しい気持ちにはなります。ですが、そんな甘い考えは、偉大なコンパイラ様には伝わりません。

未定義動作とは

私の尊敬する先輩の記事を読んでください。

配列外参照やゼロ除算などは未定義動作ですが、必ずしも実行時エラーになるとは限りません。
未定義動作をプログラマーが書いた場合、何が起こるかわからない というのが正しいです。

理由は、コンパイラが以下のような動作をすることが仕様で許されているからです。

  • 未定義動作は起こらないと仮定してよい
  • 起こらないのだから,無視して最適化してよい

今回の例で何が起こってるかの予想

今回の例では、配列外参照をしているので、未定義動作を書いていることになります。その時点で、プログラムが何をしても文句は言えないのです。(仮に、あなたの PC の全データを消去されても、文句は言えません。)

ただ、コンパイラはそこまで意地悪ではないし、むしろ親切です。親切なので、未定義動作なんて存在しないものと仮定し、最適化を試みます。

今回の例で、なぜ無限ループが起こってしまったのかを僕なりに予想して、コンパイラ君の気持ちを代弁してみると、

  • コンパイラ君「よし、コンパイルするぞ!」
  • コンパイラ君「配列 a の長さは 10 だね!」
  • コンパイラ君「while に初めて入るとき、i = 0 だから i <= 10true だね」
  • コンパイラ君「ia の添え字になってるね、配列外参照になることなんてないからcout << a[i] の時点で i < 10 が成り立つね」
  • コンパイラ君「次の行で、i++ されても、i <= 10 が常に成り立つね」
  • コンパイル君「よし、while (i <= 10) は常に true だから while (true) に置き換えよう!こうすることで、毎回条件式を判定するコストが減らせるね!!!さすが僕、最適化のプロ」

と、なっているのだと予想されます。(あくまで予想です。)

最適化後のコード(予想)

#include <iostream>
using namespace std;

int main() {
  int a[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
  int i = 0;
  while (true) {
    cout << "i = " << i << " a[i] = " << a[i] << endl;
    i++;
  }
}

エピローグ

コンパイラは未定義動作が起こらないと仮定して最適化を行います。
人間がうっかり未定義動作を書いてしまう可能性なんて微塵も考えていないわけですね。

コンパイラ君と少しでも分かり合うためには、普段から未定義動作を書かないように努力する必要がありそうです。

追記(5/22 01:56)

未定義動作を踏んではいるが、例えば、

i = 0 a[i] = -2143330816
i = 1 a[i] = -592414315
i = 2 a[i] = 0
i = 3 a[i] = 0
i = 4 a[i] = -1500878016
...

のような出力にはならないことが保証されているはずです。
C++ コンパイラには as-if rule1 というものがあり、外から見た動作 (observable behavior)2 については、コードに書かれた通りに実行するように振る舞うことが約束されています。

なので、配列外参照をするまでは、出力についてはまともな挙動をすることが保証されているわけです。

追記(5/22 14:23)

どうやら、タイムトラベルも認められるようです。読み物として面白いので、興味がある方は これ3 を読んでください。