C ++の未定義の動作

C ++プログラマにとってかなり複雑なトピックは、未定義の動作です。 経験豊富な開発者でさえ、その発生理由を明確に説明できないことがよくあります。 この記事は、この問題をもう少し明確にすることを目的としています。



この記事は、いくつかの記事の翻訳であり、このトピックに関する標準からの抜粋です。



「フォローポイント」とは何ですか?


標準は言う:

シーケンスポイント(シーケンスポイント)-既に実行されたコードのすべての副作用が終了し、実行されるコードの副作用がまだ実行されていない、プログラム実行のプロセス内のポイント。 (§1.9/ 7)




副作用? 「副作用」とは何ですか?


(標準による)副作用は、揮発性オブジェクトへのアクセス、オブジェクトの変更、I / Oライブラリからの関数の呼び出し、またはこれらのアクションの一部を含む関数の呼び出しの結果です。 副作用は、ランタイムの状態の変化です。


何らかの式を計算すると、何らかの出力が得られます。 結果に加えて、式を評価するとランタイムが変化する場合、式には副作用があると言われます。



例:



int x = y++; // «y»  int
      
      







変数「x」の初期化に加えて、変数「y」の値は、++演算子の副作用のために変更されました。

まあ、それは明らかです。 シーケンスのポイントにさらに。 「通過点」という用語の別の定義は、スティーブサミット(「質問と回答の言語C」、ブログ「comp.lang.c」の著者)によって与えられました。

フォローアップポイントは、「ダストが落ち着いた」時点であり、遭遇したすべての副作用が完了し、取り残されることが保証されています。



C ++標準で説明されている次のポイントは何ですか?


標準では、次のシーケンスポイントが説明されています。







「不定の動作」とは何ですか?


この規格では、§1.3.12で「不定の動作」というフレーズを定義しています。

未定義の動作 -誤ったソフトウェア構成または誤ったデータの使用の結果として発生する可能性のある動作であり、国際標準では要件を課していません。 未定義の動作は、規格に明示的に記載されていない状況でも発生する場合があります。


言い換えれば、不定の行動とは、鼻から落ちた鼻くそから彼女の妊娠で終わる、起こりうるあらゆることを意味します。



不定の動作とシーケンスポイントの関係は何ですか?


この質問に対する答えを知る前に、未定義の動作、未指定の動作、実装定義の動作の違いを理解する必要があります。

不特定の動作(標準に準拠)-標準が2つ以上の可能なオプションを提供し、特定の状況で選択すべき明確な要件を課さない動作。


不特定の動作は、次のような部分式の計算の結果です。



実装定義の動作(標準に準拠)は、正しいデータを持つ整形式のソフトウェア構成の動作であり、実装に依存します(実装ごとに文書化する必要があります)。


この動作の例は、ポインターのサイズです。 標準に従って、ポインターのサイズはコンパイラーの特定の実装に依存します。 特定の実装では、異なるタイプのポインターのサイズも異なる場合があります。

また、特定の演算子のオペランド、特定の式の部分式、および副作用が発生する順序を計算する手順が指定されていないことにも注意してください。



例:



 int x = 5, y = 6; int z = x++ + y++; //  ,    x++  y++
      
      







別の例:



 int Hello() { return printf("Hello"); } int World() { return printf("World !"); } int main() { int a = Hello() + World(); /**  «Hello World!»  «World! Hello» ^ |        **/ return 0; }
      
      







§5/ 4では、規格は次のように述べています:

連続する2つのポイント間で、スカラーオブジェクトは、式を1回しか計算しないときに、格納されている値を変更する必要があります。


これはどういう意味ですか?



簡単に言えば、2つの連続点の間の変数を複数回変更することはできません。 式では、通常、次のシーケンスポイントは閉じているセミコロンにあり、前のシーケンスポイントは前のステートメントの最後にあります。 式には、中間シーケンスポイントを含めることもできます。

上記に基づいて、次の式は未定義の動作を作成します。



 i++ * ++i; // i = ++i; // ++i = 2; // i   1  i = ++i +1 // i = (i,++i,++i); //      `++i`   `i` (`i`   1   2  )
      
      





しかし同時に:



 i = (i, ++i, 1) + 1; //  i = (++i,i++,i) //  int j = i; j = (++i, i++, j*i); // 
      
      







さらに(標準に従って)-式の古い値(計算前)は、保存された値を決定するためにのみ使用可能です。

これは、値へのアクセスがその変更に先行する可能性のある式が間違っていることを意味します。



例:



 std::printf("%d %d", i,++i); // ,    –  (++i)    .
      
      







別の例:



 a[i] = i++ ; //  a[++i] = i ,  a[i++] = ++i  ..
      
      







C ++ 0xにはフォローポイントがないと聞きましたが、本当ですか?




はい、そうです。

「フォローアップポイント」の概念は、フォローアップ関係[BEFORE AFTER]の洗練された概念でISO C ++委員会に置き換えられました。



フォローアップ関係とは[BEFORE]?

Sequenced Beforeは、次の関係です。

  • 非対称に
  • 推移的に
  • 計算のペアとそれらから形成される部分的に順序付けられたセットの間に生じる




正式には、これは、与えられた2つの式AとBについて、Aが[DOに続く] Bの場合、実行Aは実行Bに先行する必要があることを意味します。 (順不同の計算は重複する場合があります)。

AおよびBの計算は、A [DOに続く] BまたはB [DOに続く] Aのいずれかである場合に不定に順序付けられますが、正確に指定されていない場合。 不確かな順序の計算は交差できませんが、いずれかを最初に実行できます。



C ++ 0xのコンテキストで「計算」という言葉は何を意味しますか?


C ++ 0xでは、式(または部分式)の評価には一般に以下が含まれます。





標準は私たちに伝えています(§1.9/ 14):

完全な式に関連付けられた各値のカウントと副作用[次の前に]計算される次の完全な式に関連付けられた値のカウントと副作用。


簡単な例:



 int x; x = 10; ++x;
      
      







この例では、式(++ x)に関連付けられた値と副作用の計算、値と副作用の計算([x = 10])の後[後に]。



結局のところ、上記のことと不定の振る舞いの間には何らかのつながりがあるはずですよね?




もちろん、接続があります。



§1.9/ 15では、次のことが言及されています。

前述の場合を除き、特定の演算子のオペランドまたは特定の式の部分式の計算が乱れます。


注:プログラム中に複数回評価される完全な式の順序付けられていない、または曖昧に順序付けられた部分式は、必ずしも同じ順序で毎回計算されるとは限りません。



例:



 int main() { int num = 19 ; num = (num << 3) + (num >> 3) ; }
      
      







1)「+」演算子のオペランドの計算が乱れています。

2)演算子「<<」および「>>」のオペランドの計算が乱れています。



§1.9/ 15特定の演算子のオペランドの値の計算[前に続く]演算子の結果の値の計算。


これは、式x + yで、値「x」および「y」の計算が[x + yの計算の前に続く]ことを意味します。



さて、もっと重要なことに:



§1.9/ 15次のいずれかのイベントに関して、スカラーオブジェクトの副作用の発生が乱れている場合:

  • 同じオブジェクトの別の副作用の発生
  • このオブジェクトの値を使用して値をカウントする


その場合、プログラムの動作は不確実になります。




例:

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







この表現について説明しましょう。 一見、関数の引数の無秩序な計算はあいまいさを伴いません。 ただし、そのような式を最適化するコンパイラが、同じメモリでの操作中に失敗する(意見では)アクションが似た一連の命令を作成しないという正確な可能性はありません。



「-1」を割り当てる最良の方法は、変数をゼロにしてデクリメントすることだとコンパイラが決定したと仮定します。

命令は次のように形成できます(コマンドは条件付きです):

 clear i decr i clear i decr i
      
      







または、次のことができます。

 clear i clear i decr i decr i
      
      







その後、値-2がiに保存されます。



関数が呼び出されると、値の各計算と、この関数の引数の式、または関数を呼び出す式に関連付けられた副作用[呼び出される前に]呼び出された関数の本体の式または演算子の実行。

異なる引数に関連付けられた値のカウントと副作用が乱れています。



プログラムの流れ




前に復号化された用語を使用して、プログラム実行フローをグラフィカルに表すことができます。 次の図では、式(または部分式)の計算をE(x)として示し、シーケンスポイントは%、オブジェクト "e"の副作用 "k"はS(k、e)です。 計算のために名前付きオブジェクトから値を読み取る必要がある場合(「x」を名前にします)、V(x)で計算を示します。残りの場合-先ほど合意したE(x)。 式の左右に副作用を書き留めます。 2つの式の境界は、上の式が下の式に評価されることを意味します(多くの場合、下の式は上の式のprvalueまたは左辺値に依存するため)。

2つのi ++式の場合。 i ++; 図は次のようになります。



 E(i++) -> { S(increment, i) } | % | E(i++) -> { S(increment, i) } | %
      
      







ご覧のとおり、この場合、次の2つのポイントがあります。1つは「i」の2つの変更を分離します。

関数呼び出しも興味深いものですが、それらの図は省略しています。



 int c = 0; int d = 0; void f(int a, int b) { assert((a == c - 1) && (b == d - 1)); } int main() { f(c++, d++); }
      
      







関数fの本体が実行を開始するまでに、引数の計算によって生成されるすべての副作用が終了することが保証されているため、このコードは正しいです。「c」と「d」は1増加します。

ここで、式i ++ * j ++を考えます。

 { S(increment, i) } <- E(i++) E(j++) -> { S(increment, j) } \ / +--+--+ | E(i++ * j++) | %
      
      







2つのブランチはどこから来たのですか? シーケンスポイントは、発生する前に実行された計算を完了することを思い出してください。 乗算のすべての部分式は、乗算自体の前に計算されるため、この式にはシーケンスポイントがなくなります。したがって、オペランドの計算の理論的な「並列性」を考慮して、同じオブジェクトの競合変化が発生する場所を示唆する必要があります。 より正式には、これら2つのブランチは無秩序です。 フォローアップポイントは、いくつかの計算を順序付けし、他の計算を順序付けしない関係です。 T.O. 前述のように、シーケンスポイントは半順序です。



矛盾する副作用。


コンパイラにマシンコードを生成および最適化する自由を提供するために、上記で検討した乗算と同様の場合、部分式を計算する手順は確立されず、それらによって生成される副作用は分離されません(上記の場合を除く)。

これにより競合が発生する可能性があるため、標準は、シーケンスポイントを使用せずに同じオブジェクトを変更しようとすると、プログラムの動作を無期限に呼び出します。 これはスカラーオブジェクトに適用されます。残りのオブジェクトは不変(配列)であるか、単にこの規則(クラスオブジェクト)に該当しないためです。 未定義の動作は、式にオブジェクトの以前の値への呼び出しとその変更(i * i ++など)が含まれる場合にも発生します

 //    ! //  ,    'i'   «» : V(i) E(i++) -> { S(increment, i) }) \ / +---+---+ | E(i * i++) | %
      
      





例外として、新しい値を計算する必要がある場合は、オブジェクトの値を読み取ることができます。 コンテキストの例:i = i + 1

  V(i) E(1) \ / +---+---+ | E(i) E(i + 1) \ / +-------+-------+ | E(i = i + 1) -> { S(assign, i) } | %
      
      







ここで、右側に「i」へのアピールがあります。 両方の部分を計算した後、割り当てが行われます。 T.O. 副作用と「i」の呼び出しはシーケンスポイントを超えることなく発生しますが、格納された値を決定するためだけに「i」に切り替えたため、意見の相違はありません。

時々、値は変更後に読み取られます。 ケース用

a =(b = 0);

「b」にレコードがあり、シーケンスポイントを超えることなく「b」から読み取ることは事実です。 それにもかかわらず、新しい値「b」はすでに読み取られており、古い値にはアクセスされないため、これは正常です。 この場合、割り当て「b」の副作用は、シーケンスの次のポイントまでだけでなく、割り当て「a」に必要な値「b」を読み取る前に期限切れになります。 標準では、「割り当て操作の結果は、割り当てが完了した後、左オペランドに格納された値です(結果は左辺値)」と明示されています。 シーケンスポイントの概念を使用しないのはなぜですか? この概念には、読み取りが行われる左辺値を返す割り当ての副作用のみを考慮するのではなく、この状況で左右のオペランドのすべての副作用を完了する必要がないという要件が含まれているためです。



ソース:




All Articles