43

C++ (C++11) 標準の §1.9.15 では、評価の順序について説明していますが、次のコード例は次のとおりです。

void g(int i, int* v) {
    i = v[i++]; // the behavior is undefined
}

コード サンプルに示されているように、動作は未定義です。

(注: わずかに異なる構文の別の質問への回答i + i++Why is a = i + i++ undefined and not unspecified behaviorがここに適用される可能性があります: 答えは基本的に、動作が歴史的な理由で未定義であり、必要に迫られたものではないということです。ただし、標準は、これが未定義であることの正当化を暗示しているようです-すぐ下の引用を参照してください.また、そのリンクされた質問は、動作が未指定であるべきであるという合意を示していますが、この質問では、動作が明確に指定されていない理由を尋ねています.)

未定義の動作の標準によって与えられた理由は次のとおりです。

スカラー オブジェクトに対する副作用が、同じスカラー オブジェクトに対する別の副作用、または同じスカラー オブジェクトの値を使用した値の計算に対して順序付けされていない場合、動作は未定義です。

この例では、部分式が評価される前に部分式i++が完全に評価され、部分式の評価の結果が(インクリメントの前) であると考えますが、は、その部分式が完全に評価された後のインクリメントされた値です。評価した。その時点で(部分式が完全に評価された後)、評価が行われ、続いて代入が行われると思います。v[...]iii++v[...]i = ...

したがって、 のインクリメントは無意味ですが、それでもこれを定義iする必要があると思います。

この未定義の動作はなぜですか?

4

8 に答える 8

42

部分式v[...]が評価される前に、部分式i++が完全に評価されると思います。

しかし、なぜあなたはそれを思いますか?

このコードがUBである歴史的な理由の1つは、コンパイラの最適化によって副作用をシーケンスポイント間の任意の場所に移動できるようにすることです。シーケンスポイントが少ないほど、最適化する可能性が高くなりますが、プログラマーは混乱します。コードが言う場合:

a = v[i++];

この標準の目的は、発行されるコードが次のようになることです。

a = v[i];
++i;

これは2つの指示である可能性があります:

tmp = i;
++i;
a = v[tmp];

2つ以上になります。

a「最適化されたコード」は、の場合は壊れますが、標準ではi、がの場合は元のコードの動作が未定義であると言って、とにかく最適化を許可しています。ai

i++あなたが提案するように、標準はそれを割り当ての前に評価しなければならないと簡単に言うことができます。次に、動作が完全に定義され、最適化が禁止されます。しかし、それはCとC++がビジネスを行う方法ではありません。

また、これらの議論で提起された多くの例は、一般的なものよりも周りにUBがあることを簡単に見分けることができることに注意してください。これにより、動作を定義し、最適化を禁止する必要があることは「明らか」であると人々が言うことになります。しかし、考慮してください:

void g(int *i, int* v, int *dst) {
    *dst = v[(*i)++];
}

この関数の動作は、の場合i != dstに定義されます。その場合、取得できるすべての最適化が必要になります(これがrestrict、C89またはC ++よりも多くの最適化を可能にするためにC99が導入する理由です)。最適化を行うために、の場合の動作は未定義i == dstです。CおよびC++標準は、エイリアシングに関しては、プログラマーが予期しない未定義の動作と、特定の場合に失敗する望ましい最適化を禁止することの間で、微妙な境界線を踏みます。SOに関する質問の数は、質問者がもう少し最適化を減らし、もう少し明確な動作を好むことを示唆していますが、それでも線を引くのは簡単ではありません。

動作が完全に定義されているかどうかは別として、それがUBであるかどうか、または部分式に対応する特定の明確に定義された操作の実行の単に不特定の順序であるかどうかの問題です。CがUBを選択する理由は、すべてシーケンスポイントの概念と、次のシーケンスポイントまで、コンパイラが変更されたオブジェクト値の概念を実際に持つ必要がないという事実に関係しています。したがって、「the」値が不特定のポイントで変化すると言うことによってオプティマイザを制約するのではなく、標準は(言い換えると)次のように言います。(1)次のシーケンスポイントの前に変更されたオブジェクトの値に依存するコードは、 UB; (2)変更されたオブジェクトを変更するコードにはUBがあります。「変更されたオブジェクト」とは部分式の評価の1つ以上の法的な順序の最後のシーケンスポイント以降に変更されています。

他の言語(Javaなど)は、表現の副作用の順序を完全に定義しているため、Cのアプローチには間違いなく反対のケースがあります。C++はその場合を受け入れません。

于 2012-12-06T13:33:43.600 に答える
30

パソロジーコンピュータを設計します1。これは、バイトレベルの命令で動作するインスレッド結合を備えた、マルチコア、高レイテンシ、シングル スレッド システムです。したがって、何かが起こるように要求すると、コンピューターは (独自の「スレッド」または「タスク」で) バイトレベルの一連の命令を実行し、特定のサイクル数後に操作が完了します。

その間、実行のメイン スレッドは続行されます。

void foo(int v[], int i){
  i = v[i++];
}

疑似コードになります:

input variable i // = 0x00000000
input variable v // = &[0xBAADF00D, 0xABABABABAB, 0x10101010]
task get_i_value: GET_VAR_VALUE<int>(i)
reg indx = WAIT(get_i_value)
task write_i++_back: WRITE(i, INC(indx))
task get_v_value: GET_VAR_VALUE<int*>(v)
reg arr = WAIT(get_v_value)
task get_v[i]_value = CALC(arr + sizeof(int)*indx)
reg pval = WAIT(get_v[i]_value)
task read_v[i]_value = LOAD_VALUE<int>(pval)
reg got_value = WAIT(read_v[i]_value)
task write_i_value_again = WRITE(i, got_value)
(discard, discard) = WAIT(write_i++_back, write_i_value_again)

write_i++_backだから、私が待っていたのと同じ時間に、最後まで待っていなかったことに気付くでしょうwrite_i_value_again(どの値からロードしたかv[])。実際、これらの書き込みはメモリへの唯一の書き込みです。

メモリへの書き込みがこのコンピューター設計の非常に遅い部分であり、バイトごとに処理を行う並列メモリ変更ユニットによって処理されるもののキューにまとめられると想像してください。

したがって、write(i, 0x00000001)andwrite(i, 0xBAADF00D)は順不同で並列に実行されます。それぞれがバイトレベルの書き込みに変換され、ランダムに並べられます。

上位バイト、次のバイト、次のバイト、最後に下位バイトに書き込み0x00ます。i の結果の値は であり、これは予想されることはほとんどありませんが、未定義の操作に対して有効な結果になります。0xBA0xAD0x000xF0 0x000x0D 0x010xBA000001

今、私がそこで行ったのは、不特定の値という結果でした。システムをクラッシュさせていません。しかし、コンパイラは自由にそれを完全に未定義にすることができます.おそらく、同じ命令バッチで同じアドレスのメモリコントローラに2つのそのような要求を送信すると、実際にシステムがクラッシュします. それは依然として C++ をコンパイルする「有効な」方法であり、「有効な」実行環境です。

これは、ポインターのサイズを 8 ビットに制限することが依然として有効な実行環境である言語であることを思い出してください。C++ では、むしろウォンキー ターゲットへのコンパイルが可能です。

1 : 以下の @SteveJessop のコメントで指摘されているように、この病的なコンピューターは、バイトレベルの操作に取り掛かるまでは、最新のデスクトップ コンピューターのように動作するというジョークがあります。CPU による非アトミックintな書き込みは、一部のハードウェアではそれほど珍しいことではありません (たとえばint、CPU が整列させたい方法で が整列されていない場合など)。

于 2012-12-06T13:50:27.547 に答える
24

その理由は歴史的なものだけではありません。例:

int f(int& i0, int& i1) {
    return i0 + i1++;
}

さて、この呼び出しで何が起こりますか:

int i = 3;
int j = f(i, i);

この呼び出しの結果が明確に定義されるようにコードに要件を設定することは確かに可能ですf(Javaがこれを行います)が、CおよびC++は制約を課しません。これにより、オプティマイザにより多くの自由が与えられます。

于 2012-12-06T13:37:25.090 に答える
9

あなたは特にC++ 11標準を参照しているので、C ++ 11の回答で答えます。ただし、C++03 の回答と非常に似ていますが、シーケンスの定義は異なります。

C++11 は、1 つのスレッドでの評価間のシーケンスの前の関係を定義します。それは非対称で、推移的で、ペアワイズです。一部の評価 A が一部の評価 B の前にシーケンスされておらず、B も A の前にシーケンスされていない場合、2 つの評価はunsequencedです。

式の評価には、値の計算 (何らかの式の値の計算) と副作用の両方が含まれます。副作用の 1 つの例は、質問に答える上で最も重要なオブジェクトの変更です。他のものも副作用としてカウントされます。副作用が、同じオブジェクトに対する別の副作用または値の計算に対して順序付けされていない場合、プログラムは未定義の動作をします。

それでセットアップです。最初の重要なルールは次のとおりです。

完全な式に関連付けられたすべての値の計算と副作用は、評価される次の完全な式に関連付けられたすべての値の計算と副作用の前に並べられます。

したがって、完全な式は次の完全な式の前に完全に評価されます。あなたの質問では、1 つの完全な式、つまり のみを扱っているi = v[i++]ので、これについて心配する必要はありません。次に重要なルールは次のとおりです。

特に明記されていない限り、個々の演算子のオペランドおよび個々の式の部分式の評価は順不同です。

これはa + b、たとえば では、 と の評価ab順序付けされていないことを意味します (それらは任意の順序で評価される可能性があります)。最後の重要なルールは次のとおりです。

演算子のオペランドの値の計算は、演算子の結果の値の計算の前に並べられます。

そのためa + b、sequenced before 関係は、有向矢印が sequenced before 関係を表すツリーで表すことができます。

a + b (value computation)
^   ^
|   |
a   b (value computation)

2 つの評価がツリーの別々の分岐で発生する場合、それらは順序付けされていないため、このツリーは、 と の評価ab互いに相対的に順序付けられていないことを示しています。

さて、あなたのi = v[i++]例に同じことをしましょう。v[i++]が と同等であると定義されていることを利用し*(v + (i++))ます。また、後置インクリメントのシーケンスに関する追加の知識も使用します。

式の値の計算は++、オペランド オブジェクトの変更の前に順序付けられます。

では、次のようにします (副作用として指定されていない限り、ツリーのノードは値の計算です)。

i = v[i++]
^     ^
|     |
i★  v[i++] = *(v + (i++))
                  ^
                  |
               v + (i++)
               ^     ^
               |     |
               v     ++ (side effect on i)★
                     ^
                     |
                     i

iここで、 , ,に対する副作用が、代入演算子の前の ini++の使用法への別の分岐にあることがわかりますi(これらの評価のそれぞれに★を付けました)。したがって、私たちは間違いなく未定義の動作をしています! 評価の順序付けが問題を引き起こすかどうか疑問に思っている場合は、これらの図を描くことを強くお勧めします.

ここで、代入演算子の前の の値は問題にならないという事実について質問を受けiます。とにかく上書きするからです。しかし、実際には、一般的なケースではそうではありません。代入演算子をオーバーライドして、代入前のオブジェクトの値を利用できます。標準は、その値を使用しないことを気にしません。ルールは、値の計算をシーケンス化せずに副作用を起こすと、未定義の動作になるように定義されています。お尻はありません。この未定義の動作は、コンパイラがより最適化されたコードを発行できるようにするために存在します。代入演算子に順序付けを追加すると、この最適化を使用できなくなります。

于 2012-12-06T13:59:35.777 に答える
4

この例では、部分式v [...]が評価される前に部分式i++が完全に評価され、部分式の評価結果はi(増分前)であると思いますが、iの値はその部分式が完全に評価された後の増分値。

の増分は、i++インデックスvを作成する前、つまりに割り当てる前に評価する必要がありますがi、その増分の値をメモリ保存する前に行う必要はありません。ステートメントi = v[i++]には、変更する2つのサブオペレーションがありますi(つまり、レジスターから変数へのストアが発生しますi)。式i++は、と同等でx=i+1ありi=x、両方の操作を順番に実行する必要はありません。

x = i+1;
y = v[i];
i = y;
i = x;

その展開により、の結果はiの値とは無関係になりますv[i]。別の拡張では、割り当ては割り当てのi = x行われる可能性があり、結果は次のようになります。i = yi = v[i]

于 2012-12-06T13:33:35.633 に答える
4

2つのルールがあります。

最初のルールは、「書き込み-書き込みハザード」を引き起こす複数の書き込みに関するものです。2 つのシーケンス ポイント間で同じオブジェクトを複数回変更することはできません。

2 番目のルールは、「読み書きの危険性」に関するものです。つまり、オブジェクトが式で変更され、さらにアクセスされる場合、その値へのすべてのアクセスは、新しい値を計算するためのものでなければなりません。

のような表現i++ + i++とあなたの表現i = v[i++]は最初の規則に違反しています。オブジェクトを 2 回変更します。

like という表現i + i++は、2 番目の規則に違反しています。i左の部分式は、新しい値の計算に関与することなく、変更されたオブジェクトの値を観察します。

したがって、i = v[i++](悪い読み書き) とは異なるルール (悪い書き込み書き込み) に違反しi + i++ます。


ルールが単純すぎるため、不可解な式のクラスが発生します。このことを考慮:

p = p->next = q

これには、危険のない正常なデータ フローの依存関係があるように見えますp =。新しい値がわかるまで、割り当ては実行できません。新しい値は の結果ですp->next = q。値は、影響を受けるように、 q「先を争って」内部に入るべきではありません。pp->next

しかし、この式は 2 番目のルールを破っています:pは変更され、新しい値の計算に関係しない目的にも使用されます。つまり、値が配置されるストレージの場所を決定しますq!

したがって、逆に、コンパイラは部分的に評価p->next = qして結果がqであることを判断し、それを に格納してからp、戻ってp->next =代入を完了することができます。またはそう思われるでしょう。

ここでの重要な問題は、代入式の値は何かということです。C標準では、割り当て式の値は、割り当て後の左辺値の値であると述べています。しかし、それはあいまいです: 「割り当てが行われると左辺値が持つ値」または「割り当てが行われた後に左辺値で観察できる値」を意味すると解釈できます。C++ では、これは「[i]n all cases, assignment is sequenced after the value compute of the right and left operand, and before the value compute of the assignment expression.」という文言によって明確にされているため、p = p->next = q有効な C++ のように見えます。 、しかし疑わしいC.

于 2012-12-06T22:41:21.843 に答える
2

例が の場合は引数を共有しますv[++i]が、i++変更はi副作用として行われるため、値がいつ変更されるかは未定義です。標準では、おそらく何らかの方法で結果を強制することができますが、 の値がどうあるべきかを知る真のi方法はありません: (i + 1)or (v[i + 1]).

于 2012-12-06T13:25:53.347 に答える