なぜf(i = -1、i = -1)が未定義の動作なのですか?


267

私は評価違反の順序について読んでいて、私を困惑させる例を示しています。

1)スカラーオブジェクトの副作用が同じスカラーオブジェクトの別の副作用と比較してシーケンスされていない場合、動作は未定義です。

// snip
f(i = -1, i = -1); // undefined behavior

このコンテキストでiは、は明らかにスカラーオブジェクトです

算術型(3.9.1)、列挙型、ポインター型、メンバー型へのポインター(3.9.2)、std :: nullptr_t、およびこれらの型のcv修飾バージョン(3.9.3)は、まとめてスカラー型と呼ばれます。

その場合、ステートメントがどのようにあいまいであるかはわかりません。最初または2番目の引数が最初に評価されるかどうかに関係なく、i最終的に-1、および両方の引数もであるように思え-1ます。

誰かが明確にしてもらえますか?


更新

私はすべての議論に本当に感謝しています。これまでのところ、@ harmicの回答非常に気に入っています。一見しただけではわかりやすいにもかかわらず、このステートメントを定義する際の落とし穴と複雑さを明らかにしているからです。@ acheong87は、参照を使用するときに発生するいくつかの問題を指摘していますが、これは、この質問のシーケンスされていない副作用の側面とは直交していると思います。


概要

この質問はかなりの注目を集めたので、主なポイント/回答を要約します。まず、少し補足して、「なぜ」は密接に関連しているが微妙に異なる意味を持つ可能性があることを指摘します。つまり、「原因」、「理由」、および「目的」です。私は、答えが「なぜ」のそれらの意味のどれに対処したかによってグループ分けします。

何のために

ここでの主な答えは、Paul Draperからのもので、Martin Jが同様の貢献をしましたが、それほど広くはありません。ポール・ドレイパーの答えは要約されます

動作が定義されていないため、未定義の動作です。

答えは、C ++標準の内容を説明するという点で、全体的に非常に優れています。また、f(++i, ++i);やなど、UBのいくつかの関連するケースも扱いますf(i=1, i=-1);。関連する最初のケースでは、最初の引数が必要かどうかi+1、2番目の引数が必要かどうか、i+2またはその逆が明確ではありません。2番目でiは、関数呼び出しの後に1か-1 かが明確ではありません。これらのケースはどちらも次のルールに該当するため、UBです。

スカラーオブジェクトの副作用が同じスカラーオブジェクトの別の副作用と比較してシーケンスされていない場合、動作は未定義です。

したがって、f(i=-1, i=-1)プログラマの意図が(IMHO)明白で明確であるにもかかわらず、同じ規則に該当するため、UBもです。

ポール・ドレイパーはまた、彼の結論でそれを明確にしています

動作が定義されているのでしょうか?はい。定義されましたか?番号。

「どのような理由/目的がf(i=-1, i=-1)未定義の動作として残されたのか?」

どのような理由/目的で

C ++標準にはいくつかの見落とし(多分不注意)がありますが、多くの省略は十分な理由があり、特定の目的を果たします。目的は「コンパイラライターの仕事を簡単にする」または「コードを高速にする」のいずれかであることが多いことは承知していますが、主に UBのままにする正当な理由があるかどうかを知りたいと思っていましたf(i=-1, i=-1)

harmicsupercatは、UBの理由を提供する主な答えを提供します。Harmicは、見かけ上アトミックな割り当て操作を複数の機械語命令に分割する可能性のある最適化コンパイラーと、それらの命令をさらにインターリーブして最適な速度にする可能性があることを指摘しています。これはいくつかの非常に驚くべき結果につながる可能性があります:i彼のシナリオでは-2になります!したがって、harmicは、操作がシーケンスされていない場合に、変数に同じ値を複数回割り当てる悪影響を与える可能性があることを示しています。

supercatは、本来あるf(i=-1, i=-1)べき姿を実行しようとすることの落とし穴の関連説明を提供します。一部のアーキテクチャでは、同じメモリアドレスへの複数の同時書き込みに対して厳しい制限があると彼は指摘します。コンパイラは、ほど重要ではないものを扱う場合、これを把握するのに苦労する可能性がありf(i=-1, i=-1)ます。

davidfは、harmicと非常によく似たインターリーブ命令の例も提供します。

harmic、supercat、davidfの例はそれぞれ多少工夫されていますが、これらを組み合わせても、f(i=-1, i=-1)未定義の動作となる具体的な理由を提供するのに役立ちます。

ポール・ドレイパーの回答が「何のために」の部分をよりよく扱っていたとしても、それが理由のすべての意味に対処するのに最善の仕事をしたので、私はハーミックの回答を受け入れました。

他の答え

JohnBは、(単なるスカラーではなく)多重定義された代入演算子を検討すると、問題が発生する可能性があることを指摘しています。


1
スカラーオブジェクトは、スカラー型のオブジェクトです。3.9 / 9を参照:「算術型(3.9.1)、列挙型、ポインター型、メンバー型へのポインター(3.9.2)std::nullptr_t、およびこれらの型のcv修飾バージョン(3.9.3)は、まとめてスカラー型と呼ばれます。 」
ロブ・ケネディ

1
おそらくページにエラーがあり、それらは実際にf(i-1, i = -1)または類似したものを意味しています。
Mr Lister

この質問を見てください: stackoverflow.com/a/4177063/71074
Robert S. Barnes

@RobKennedyありがとう。「算術型」にはブールが含まれますか?
Nicu Stiurca、2014

1
SchighSchagh更新は回答セクションにあるはずです。
Grijesh Chauhan

回答:


343

操作はシーケンスされていないため、割り当てを実行する命令をインターリーブできないことは言うまでもありません。CPUアーキテクチャーによっては、そうするのが最適な場合があります。参照されたページはこれを述べています:

AがBの前にシーケンスされておらず、BがAの前にシーケンスされていない場合、2つの可能性があります。

  • AおよびBの評価は順序付けされていません。それらは任意の順序で実行でき、重複する可能性があります(単一の実行スレッド内で、コンパイラーはAおよびBを構成するCPU命令をインターリーブする場合があります)

  • AとBの評価は不規則に順序付けられます。これらは任意の順序で実行できますが、重複することはできません。AはBの前に完了するか、BはAの前に完了します。同じ式の次回の順序は逆になる場合があります評価されます。

それ自体は問題を引き起こすようには見えません-実行されている操作が値-1をメモリ位置に格納していると仮定します。しかし、コンパイラーが同じ効果を持つ別の命令セットにコンパイラーを最適化できないが、操作が同じメモリー位置で別の操作とインターリーブされた場合に失敗する可能性があることも言うまでもありません。

たとえば、値-1をロードするよりも、メモリをゼロにしてからデクリメントする方が効率的であると想像してください。

f(i=-1, i=-1)

になるかもしれません:

clear i
clear i
decr i
decr i

今私は-2です。

これはおそらく偽の例ですが、可能です。


59
シーケンスルールに準拠しながら、式が実際に予期しない動作を行う方法の非常に良い例。はい、少し工夫されていますが、そもそも私が尋ねているコードは省略されています。:)
Nicu Stiurca、2014

10
また、割り当てがアトミック操作として行われる場合でも、両方の割り当てが同時に行われ、メモリアクセスの競合が発生してエラーが発生するスーパースカラーアーキテクチャが考えられます。この言語は、コンパイラの作成者がターゲットマシンの利点を最大限に活用できるように設計されています。
2014

11
I実際には2つの割り当てがunsequencedあるためにも、両方のパラメータで同じ変数に同じ値を代入すると、予期しない結果が生じる可能性がどのようにあなたの例のように
マーティン・J.

1
+ 1e + 6(ok、+1)は、コンパイルされたコードが常に期待したものとは限らないためです。オプティマイザは、ルールに従わない場合にこれらの種類のカーブを投げるのに本当に優れています:P
Corey

3
Armプロセッサでは、32ビットのロードで最大4つの命令を実行できますload 8bit immediate and shift。最大で4回実行されます。通常、コンパイラーはこれを回避するために、テーブルから数値をフェッチするために間接アドレス指定を行います。(-1は1つの命令で実行できますが、別の例を選択することもできます)。
ctrl-alt-delor 2014

208

まず、「スカラーオブジェクトは、」のようなタイプを意味しintfloat(参照、またはポインタを?C ++でスカラオブジェクトである何)。


第二に、それはより明白に見えるかもしれません

f(++i, ++i);

未定義の動作が発生します。だが

f(i = -1, i = -1);

それほど明白ではありません。

少し異なる例:

int i;
f(i = 1, i = -1);
std::cout << i << "\n";

「最後」、、i = 1またはのどの割り当てが発生しましたかi = -1?標準では定義されていません。本当に、それはi可能性があります5(これがどのようになるかについての完全にもっともらしい説明については、harmicの回答を参照してください)。または、プログラムでセグメンテーション違反が発生する可能性があります。または、ハードドライブを再フォーマットします。

しかし今、あなたは尋ねます:「私の例はどうですか?私-1は両方の割り当てに同じ値()を使用しました。それについておそらく何が不明確なのでしょうか?」

あなたは正しい... C ++標準委員会がこれを説明した方法を除いて。

スカラーオブジェクトの副作用が同じスカラーオブジェクトの別の副作用と比較してシーケンスされていない場合、動作は未定義です。

彼らあなたの特別なケースのために特別な例外を作ったかもしれませんが、そうではありませんでした。(そして、なぜそれらを使用する必要があるのでしょうか。これまでにどのような用途があるのでしょうか?)したがって、iまだ使用できます5。または、ハードドライブが空である可能性があります。したがって、あなたの質問への答えは:

動作が定義されていないため、未定義の動作です。

(多くのプログラマーが「未定義」は「ランダム」または「予測不可能」を意味すると考えているため、これは強調に値します。標準では定義されていないという意味ではありません。動作は100%一貫していても、未定義である可能性があります。)

動作が定義されているのでしょうか?はい。定義されましたか?いいえ。したがって、「未定義」です。

言った、コンパイラは、ハードドライブをフォーマットすることを意味するものではありません...それはそれがあることを意味し、「未定義」可能性と、それはまだ標準に準拠コンパイラになります。現実的には、g ++、Clang、MSVCはすべて期待どおりの動作をするはずです。彼らは単に「しなければならない」ことはありません。


別の質問があるかもしれません。なぜC ++標準委員会はこの副作用をシーケンスしないようにすることを選んだのですか?。その回答には、委員会の歴史と意見が含まれます。または、C ++でシーケンスされていないこの副作用を使用することの利点は何ですか?これは、標準委員会の実際の推論であったかどうかにかかわらず、正当化を許可します。これらの質問は、ここか、programmers.stackexchange.comで行うことができます。


9
@hvd、はい、実際には、-Wsequence-pointg ++ を有効にすると警告が表示されることはわかっています。
Paul Draper 14

47
「g ++、Clang、およびMSVCはすべて期待どおりの動作をするはずです」現代のコンパイラは信用できません。彼らは悪だ。たとえば、これは未定義の動作であることを認識し、このコードに到達できないと想定する場合があります。彼らが今日そうしなければ、彼らは明日そうするかもしれない。どのUBも時限爆弾です。
CodesInChaos 14

8
@BlacklightShining "あなたの答えは良くないので悪いです"はあまり役に立たないフィードバックですよね?
Vincent van der Weele 2014

13
@BobJarvisコンパイルは、未定義の動作に直面しても、リモートで正しいコードを生成する義務は一切ありません。TItは、このコードが呼び出されることさえないと仮定して、全体をnopに置き換えることもできます(コンパイラーは実際にUBに直面してそのような仮定を行うことに注意してください)。したがって、そのようなバグレポートに対する正しい反応は、「クローズ、意図したとおりに機能する」ことしかできないと思います
Grizzly

7
@SchighSchagh時々、用語の言い換え(表面上でのみこれはトートロジーの答えのように見える)が人々に必要なものです。ほとんどの場合、技術仕様を初めて使用する人はをundefined behavior意味すると考えsomething random will happenています。
イズカタ2014

27

2つの値が同じであるという理由だけでルールから例外を作らない実用的な理由:

// config.h
#define VALUEA  1

// defaults.h
#define VALUEB  1

// prog.cpp
f(i = VALUEA, i = VALUEB);

これが許可された場合を考えてみましょう。

今、数か月後、変化する必要が生じます

 #define VALUEB 2

一見無害ですね。しかし、突然prog.cppはコンパイルできなくなります。しかし、コンパイルはリテラルの値に依存すべきではないと感じています。

結論:コンパイルの成功は定数の値(タイプではなく)に依存するため、ルールに例外はありません。

編集

@HeartWareは、0のA DIV B場合、フォームの定数式は一部の言語では許可されずB、コンパイルが失敗することを指摘しました。したがって、定数を変更すると、他の場所でコンパイルエラーが発生する可能性があります。私見、残念です。しかし、そのようなことを避けられないものに制限することは確かに良いことです。


もちろん、例で整数リテラルを使用しています。あなたにf(i = VALUEA, i = VALUEB);は間違いなく未定義の行動の可能性があります。識別子の背後にある値に対して実際にコーディングしていないことを願っています。
ウルフ

3
@Woldしかし、コンパイラはプリプロセッサマクロを認識しません。そして、これがそうでなかったとしても、int定数を1から2に変更するまでソースコードがコンパイルされるようなプログラミング言語の例を見つけるのは困難です。同じ値でもコードが壊れる理由。
インゴ、2014

はい、コンパイルはマクロを見ません。しかし、これは問題でしたか?
Wolf 14

1
あなたの答えは要点を欠いています。harmicの答えとOPのコメントを読んでください
ウルフ

1
それはできたSomeProcedure(A, B, B DIV (2-A))。いずれにせよ、CONSTはコンパイル時に完全に評価する必要があると言語が述べている場合、もちろん、私の主張はその場合有効ではありません。それはどういうわけか、コンパイル時と実行時の区別を曖昧にするからです。私たちが書いた場合にも気づきますCONST C = X(2-A); FUNCTION X:INTEGER(CONST Y:INTEGER) = B/Y; か?または機能は許可されていませんか?
インゴ

12

混乱は、定数値をローカル変数に格納することは、Cが実行されるように設計されているすべてのアーキテクチャで1つのアトミック命令ではないということです。この場合、コードが実行されるプロセッサはコンパイラよりも重要です。たとえば、各命令が完全な32ビット定数を運ぶことができないARMでは、intを変数に格納するには複数の命令が必要です。一度に8ビットしか格納できず、32ビットレジスタで動作する必要があるこの疑似コードの例、iはint32です。

reg = 0xFF; // first instruction
reg |= 0xFF00; // second
reg |= 0xFF0000; // third
reg |= 0xFF000000; // fourth
i = reg; // last

コンパイラが最適化したい場合、同じシーケンスを2回インターリーブする可能性があり、どの値がiに書き込まれるかわからないと想像できます。そして、彼はあまり賢くないとしましょう:

reg = 0xFF;
reg |= 0xFF00;
reg |= 0xFF0000;
reg = 0xFF;
reg |= 0xFF000000;
i = reg; // writes 0xFF0000FF == -16776961
reg |= 0xFF00;
reg |= 0xFF0000;
reg |= 0xFF000000;
i = reg; // writes 0xFFFFFFFF == -1

ただし、私のテストでは、gccは、同じ値が2回使用され、1回生成されて奇妙なことを何も行わないことを認識するのに十分親切です。私は-1、-1を取得します。しかし、定数でさえも見かけほど明白ではない可能性があることを考慮することが重要であるため、私の例は依然として有効です。


ARMでは、コンパイラはテーブルから定数をロードするだけだと思います。あなたが説明することは、MIPSに似ています。
2014

1
@AndreyChernyakhovskiyうん、でもそれが単純な場合-1(コンパイラがどこかに格納している)ではなく3^81 mod 2^32、むしろ一定である場合、コンパイラはここで行われたことを正確に実行する可能性があります。待つことを避けるために。
よ」

@tohecz、ええ、私はすでにそれをチェックしました。確かに、コンパイラーはスマートすぎて、テーブルからすべての定数をロードできません。とにかく、2つの定数の計算に同じレジスタを使用することは決してありません。これは、定義された動作と同じように確実に「未定義」になります。
2014

@AndreyChernyakhovskiyしかし、あなたはおそらく「世界中のすべてのC ++コンパイラプログラマ」ではありません。計算にのみ使用できる3つの短いレジスターを備えたマシンがあることに注意してください。
よ」

@tohecz、とが2つの別々のオブジェクトf(i = A, j = B)である例を考えます。この例にはUBがありません。3つの短いレジスタを持つマシンは、の二つの値混合するコンパイラのための言い訳にはならないと、それはプログラムのセマンティクスを破るために、同じレジスタ内を(davidfの答え@に示すように)。ijAB
2014

11

「役立つ」とされていたコンパイラがまったく予期しない動作を引き起こす可能性のある動作を行う可能性があると考えられる理由がある場合、動作は一般に未定義として指定されます。

異なる時間に書き込みが発生することを保証するために何もせずに変数が複数回書き込まれる場合、ハードウェアの種類によっては、デュアルポートメモリを使用して複数の「保存」操作を異なるアドレスに同時に実行できる場合があります。ただし、一部のデュアルポートメモリは、書き込まれた値が一致するかどうか関係なく、2つのストアが同時に同じアドレスにヒットするシナリオを明示的に禁止します。そのようなマシンのコンパイラーが、同じ変数を書き込むための2つのシーケンスされていない試行に気付いた場合、コンパイルを拒否するか、2つの書き込みが同時にスケジュールされないようにします。ただし、一方または両方のアクセスがポインターまたは参照を介している場合、コンパイラーは、両方の書き込みが同じストレージの場所にヒットするかどうかを常に判別できるとは限りません。その場合、書き込みが同時にスケジュールされ、アクセス試行時にハードウェアトラップが発生する可能性があります。

もちろん、誰かがそのようなプラットフォームにCコンパイラを実装するかもしれないという事実は、アトミックに処理できるほど小さい型のストアを使用するときに、そのような動作がハードウェアプラットフォームで定義されるべきでないことを示唆していません。2つの異なる値をシーケンスされていない方法で格納しようとすると、コンパイラがその値を認識していないと、奇妙な結果になる可能性があります。たとえば、次の場合:

uint8_t v;  // Global

void hey(uint8_t *p)
{
  moo(v=5, (*p)=6);
  zoo(v);
  zoo(v);
}

コンパイラが「moo」の呼び出しをインライン化し、「v」を変更しないことがわかる場合、5をvに保存し、6を* pに保存し、5を「zoo」に渡してから、 vの内容を "zoo"に渡します。「zoo」が「v」を変更しない場合、2つの呼び出しに異なる値を渡す必要はありませんが、それは簡単に起こります。一方、両方のストアが同じ値を書き込む場合、そのような奇妙さは発生せず、ほとんどのプラットフォームでは、実装が何か奇妙なことをする賢明な理由はありません。残念ながら、一部のコンパイラ作成者は、「標準で許可されているため」という愚かな動作の言い訳を必要としないため、それらのケースでさえ安全ではありません。


9

この場合、ほとんどの実装で結果が同じになるという事実は偶発的です。評価の順序はまだ定義されていません。検討f(i = -1, i = -2):ここでは、順序が重要です。例で問題にならない唯一の理由は、両方の値がであるという偶然です-1

式が未定義の動作を伴うものとして指定されている場合、悪意を持って準拠するコンパイラはf(i = -1, i = -1)、実行を評価して中止するときに不適切な画像を表示する可能性がありますが、完全に正しいと見なされます。幸い、私が知っているコンパイラはありません。


8

関数の引数式のシーケンスに関する唯一のルールがここにあるように思えます:

3)関数を呼び出すとき(関数がインラインかどうか、および明示的な関数呼び出し構文が使用されているかどうか)、引数式、または呼び出された関数を指定する後置式に関連付けられているすべての値の計算と副作用は、呼び出された関数の本体内のすべての式またはステートメントの実行前に順序付けられます。

これは引数式間の順序付けを定義しないため、この場合は次のようになります。

1)スカラーオブジェクトの副作用が同じスカラーオブジェクトの別の副作用と比較してシーケンスされていない場合、動作は未定義です。

実際には、ほとんどのコンパイラーで、引用した例は問題なく実行されます(「ハードディスクの消去」やその他の理論的な未定義の動作の影響とは対照的です)。
ただし、割り当てられた2つの値が同じであっても、特定のコンパイラの動作に依存するため、これは責任です。また、明らかに、異なる値を割り当てようとした場合、結果は「本当に」未定義になります。

void f(int l, int r) {
    return l < -1;
}
auto b = f(i = -1, i = -2);
if (b) {
    formatDisk();
}

8

C ++ 17は、より厳密な評価規則を定義しています。特に、関数の引数を順序付けします(ただし、順序は指定されていません)。

N5659 §4.6:15
評価ABは、ABの前にシーケンスされている場合、またはBAの前にシーケンスされている場合、不確定にシーケンスされますが、どちらが指定されているかは不明です。[ :不規則にシーケンスされた評価はオーバーラップできませんが、最初に実行することもできます。— エンドノート ]

N5659 § 8.2.2:5
関連するすべての値の計算と副作用を含むパラメーターの初期化は、他のパラメーターの初期化に対して不確定にシーケンスされます。

以前はUBであったいくつかのケースが許可されています。

f(i = -1, i = -1); // value of i is -1
f(i = -1, i = -2); // value of i is either -1 or -2, but not specified which one

2
このc ++ 17の更新を追加していただき、ありがとうございます。;)
Yakk-Adam Nevraumont 2017年

この回答に感謝します。わずかなフォローアップ:fの署名がであった場合f(int a, int b)、C ++ 17はそれa == -1を保証b == -2し、2番目のケースのように呼び出された場合は?
Nicu Stiurca

はい。パラメータabがある場合、i-then- aは-1に初期化され、その後i-then- bは-2に初期化されるか、その逆になります。どちらの場合も、結果はa == -1およびになりb == -2ます。少なくともこれは、「関連するすべての値の計算と副作用を含むパラメーターの初期化は、他のパラメーターの初期化に対して不確定に順序付けられている」と私が読んだ方法です。
AlexD 2017年

Cでもずっと同じだと思います。
fuz 2018

5

代入演算子はオーバーロードされる可能性があり、その場合、順序が問題になる可能性があります。

struct A {
    bool first;
    A () : first (false) {
    }
    const A & operator = (int i) {
        first = !first;
        return * this;
    }
};

void f (A a1, A a2) {
    // ...
}


// ...
A i;
f (i = -1, i = -1);   // the argument evaluated first has ax.first == true

1
確かにそうですが、問題はスカラー型に関するものでした。他の人が指摘したように、これは本質的にintファミリ、floatファミリ、およびポインタを意味します。
Nicu Stiurca、2014

この場合の実際の問題は、代入演算子がステートフルであるため、変数を定期的に操作する場合でも、このような問題が発生しやすいことです。
AJMansfield、2014

2

これは、「intやfloat以外の "スカラーオブジェクト"の意味がわからない」と答えているだけです。

私は、「スカラーオブジェクト」を「スカラータイプオブジェクト」の省略形、または単に「スカラータイプ変数」と解釈します。次いで、pointerenum(定数)は、スカラ型です。

これは、スカラー型の MSDN記事です。


これは、「リンクのみの回答」に少し似ています。そのリンクからこの回答に関連するビットを(引用符で)コピーできますか?
Cole Johnson 14

1
@ColeJohnsonこれはリンクのみの回答ではありません。リンクは詳細な説明のみを目的としています。私の答えは「ポインタ」、「列挙型」です。
Peng Zhang

私はあなたの答えリンクのみの答えであるとは言いませんでした。私はそれを「[1]のように読む」と言いました。ヘルプセクションでリンクのみの回答が不要な理由を確認することをお勧めします。その理由は、MicrosoftがサイトのURLを更新すると、そのリンクが壊れるからです。
Cole Johnson

1

実際には、コンパイラがi同じ値が2回割り当てられていることをチェックするため、単一の割り当てに置き換えることができるという事実に依存しない理由があります。いくつかの表現がある場合はどうなりますか?

void g(int a, int b, int c, int n) {
    int i;
    // hey, compiler has to prove Fermat's theorem now!
    f(i = 1, i = (ipow(a, n) + ipow(b, n) == ipow(c, n)));
}

1
フェルマーの定理を証明する必要はありません:に代入1するだけiです。両方の引数が割り当て1、これが「正しい」ことを行うか、または引数が異なる値を割り当て、それが未定義の動作であるため、選択は許可されます。
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.