48

この質問では、次のコードが合法的なC++であるかどうかについていくつかの議論が行われています。

std::list<item*>::iterator i = items.begin();
while (i != items.end())
{
    bool isActive = (*i)->update();
    if (!isActive)
    {
        items.erase(i++);  // *** Is this undefined behavior? ***
    }
    else
    {
        other_code_involving(*i);
        ++i;
    }
}

ここでの問題は、問題erase()のイテレータが無効になることです。それが評価される前に発生した場合、そのようにi++インクリメントiすることは、特定のコンパイラで機能しているように見えても、技術的には未定義の動作です。議論の一方は、関数が呼び出される前にすべての関数の引数が完全に評価されると述べています。反対側は、「唯一の保証は、i++が次のステートメントの前とi++の使用後に発生することです。それがerase(i ++)が呼び出される前か後かは、コンパイラに依存します。」

私はこの質問を開いて、うまくいけばその議論を解決しました。

4

8 に答える 8

64

C ++標準1.9.16を引用します:

関数を呼び出すとき(関数がインラインであるかどうかに関係なく)、引数式、または呼び出された関数を指定する後置式に関連するすべての値の計算と副作用は、本体のすべての式またはステートメントの実行前にシーケンスされます。関数と呼ばれます。(注:さまざまな引数式に関連する値の計算と副作用は順序付けられていません。)

したがって、このコードは次のように思われます。

foo(i++);

完全に合法です。インクリメントしてから、前の値のでi呼び出します。ただし、このコード:fooi

foo(i++, i++);

1.9.16項にも次のように記載されているため、未定義の動作が発生します。

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

于 2009-02-28T15:23:51.643 に答える
15

クリストの答えに基づいて構築するには、

foo(i++, i++);

関数の引数が評価される順序が未定義であるため、未定義の動作が発生します(より一般的なケースでは、変数を書き込む式で変数を2回読み取ると、結果は未定義になります)。どの引数が最初にインクリメントされるかはわかりません。

int i = 1;
foo(i++, i++);

の関数呼び出しが発生する可能性があります

foo(2, 1);

また

foo(1, 2);

あるいは

foo(1, 1);

以下を実行して、プラットフォームで何が起こるかを確認します。

#include <iostream>

using namespace std;

void foo(int a, int b)
{
    cout << "a: " << a << endl;
    cout << "b: " << b << endl;
}

int main()
{
    int i = 1;
    foo(i++, i++);
}

私のマシンでは

$ ./a.out
a: 2
b: 1

毎回ですが、このコードは移植性がないので、コンパイラが異なれば異なる結果が得られると思います。

于 2009-02-28T15:31:30.517 に答える
5

標準では、呼び出しの前に副作用が発生すると述べているため、コードは次のようになります。

std::list<item*>::iterator i_before = i;

i = i_before + 1;

items.erase(i_before);

ではなく:

std::list<item*>::iterator i_before = i;

items.erase(i);

i = i_before + 1;

list.erase() は、消去されたイテレータ以外のイテレータを具体的に無効にしないため、この場合は安全です。

とは言っても、それは悪いスタイルです - すべてのコンテナの消去関数は具体的に次のイテレータを返すので、再割り当てによるイテレータの無効化について心配する必要はありません。したがって、慣用的なコードは次のとおりです。

i = items.erase(i);

ストレージを変更したい場合は、リストに対して安全であり、ベクトル、デキュー、およびその他のシーケンス コンテナーに対しても安全です。

また、元のコードを警告なしでコンパイルすることはできません。次のように記述する必要があります。

(void)items.erase(i++);

未使用の返品に関する警告を避けるためです。これは、何か奇妙なことをしている大きな手がかりになります。

于 2009-02-28T17:04:25.347 に答える
3

それは完全に大丈夫です。渡される値は、増分前の「i」の値になります。

于 2009-02-28T15:23:58.880 に答える
3

++クリスト!

C++ 標準 1.9.16 は、クラスに operator++(postfix) を実装する方法に関して非常に理にかなっています。その operator++(int) メソッドが呼び出されると、それ自体がインクリメントされ、元の値のコピーが返されます。C++ の仕様にあるとおりです。

基準が改善されるのを見るのは素晴らしいことです!


ただし、以前の (ANSI 以前の) C コンパイラを使用したことをはっきりと覚えています。

foo -> bar(i++) -> charlie(i++);

あなたが思うことをしませんでした!代わりに、次と同等にコンパイルされました。

foo -> bar(i) -> charlie(i); ++i; ++i;

そして、この動作はコンパイラの実装に依存していました。(移植を楽しくします。)


最新のコンパイラが正しく動作するようになったことをテストして検証するのは簡単です。

#define SHOW(S,X)  cout << S << ":  " # X " = " << (X) << endl

struct Foo
{
  Foo & bar(const char * theString, int theI)
    { SHOW(theString, theI);   return *this; }
};

int
main()
{
  Foo f;
  int i = 0;
  f . bar("A",i) . bar("B",i++) . bar("C",i) . bar("D",i);
  SHOW("END ",i);
}


スレッドのコメントに返信しています...

...そして、ほぼ全員回答に基づいて構築しています... (ありがとう!)


これをもう少し詳しく説明する必要があると思います。

与えられた:

baz(g(),h());

その場合、 g()がh()の前または後 に呼び出されるかどうかはわかりません。「未指定」です。

しかし、 g()h( )の両方がbaz()の前に呼び出されることはわかっています。

与えられた:

bar(i++,i++);

繰り返しますが、どのi++が最初に評価されるかはわかりません。おそらく、bar()が呼び出される 前にiが 1 回または 2 回インクリメントされるかどうかさえわかりません。結果は未定義です! ( i=0とすると、これはbar(0,0)またはbar(1,0)またはbar(0,1)または何か本当に奇妙なものになる可能性があります!)


与えられた:

foo(i++);

これで、 foo()が呼び出される前にiがインクリメントされることがわかりました。クリストが C++ 標準セクション 1.9.16から指摘したように:

関数を呼び出すとき (関数がインラインであるかどうかに関係なく)、すべての値の計算と、任意の引数式、または呼び出された関数を指定する後置式に関連付けられた副作用は、本体のすべての式またはステートメントの実行前に順序付けられます。と呼ばれる機能。[ 注: 異なる引数式に関連付けられた値の計算と副作用は順序付けされていません。-- 巻末注記】

セクション 5.2.6 の方が適切だと思いますが:

後置 ++ 式の値は、そのオペランドの値です。[ 注: 得られた値は元の値のコピーです -- 末尾の注 ] オペランドは変更可能な左辺値でなければなりません。オペランドの型は、算術型または完全な有効なオブジェクト型へのポインターでなければなりません。オペランド オブジェクトの値は、オブジェクトが bool 型の場合を除き、それに 1 を追加することによって変更されます。bool 型の場合は true に設定されます。[注: この使用は非推奨です。付録 D を参照してください。 -- 末尾の注記] ++ 式の値の計算は、オペランド オブジェクトの変更の前に順序付けられます。不定順序の関数呼び出しに関しては、後置 ++ の操作は単一の評価です。[ 注: したがって、関数呼び出しは、左辺値から右辺値への変換と、単一の後置 ++ 演算子に関連する副作用との間に介在してはなりません。-- 終わりの注]結果は右辺値です。結果の型は、オペランドの型の cv 非修飾バージョンです。5.7 および 5.17 も参照してください。

セクション 1.9.16 の標準には、(例の一部として) 次の項目もリストされています。

i = 7, i++, i++;    // i becomes 9 (valid)
f(i = -1, i = -1);  // the behavior is undefined

そして、これを次のように簡単に示すことができます。

#define SHOW(X)  cout << # X " = " << (X) << endl
int i = 0;  /* Yes, it's global! */
void foo(int theI) { SHOW(theI);  SHOW(i); }
int main() { foo(i++); }

そうです、iはfoo()が呼び出される前にインクリメントされます。


これはすべて、次の観点から非常に理にかなっています。

class Foo
{
public:
  Foo operator++(int) {...}  /* Postfix variant */
}

int main() {  Foo f;  delta( f++ ); }

ここで、 Foo::operator++(int)はdelta()の前に呼び出す必要があります。そして、インクリメント操作はその呼び出し中に完了する必要があります。


私の(おそらく過度に複雑な)例では:

f . bar("A",i) . bar("B",i++) . bar("C",i) . bar("D",i);

object.bar( "B",i++)に使用されるオブジェクトを取得するにはf.bar("A",i)を実行する必要があり、 "C"および"D"についても同様です。

したがって、 i++はbar("B",i++)を呼び出す前にiをインクリメントすることがわかっています (bar("B",...)はiの古い値で呼び出されますが)、したがってibar( "C",i)およびbar("D",i)


j_random_hackerのコメントに戻ります。

j_random_hacker は次
のように書いています。bar() が代わりに int を返すグローバル関数であり、f が int であり、それらの呼び出しが「.」ではなく「^」で接続されている場合、A、C、D のいずれかが可能であると考えるのは正しいですか? 「0」を報告しますか?

この質問は、あなたが思っているよりもずっと複雑です...

質問をコードとして書き直します...

int bar(const char * theString, int theI) { SHOW(...);  return i; }

bar("A",i)   ^   bar("B",i++)   ^   bar("C",i)   ^   bar("D",i);

これで、式が 1 つだけになりました。標準によると (セクション 1.9、8 ページ、pdf ページ 20):

注: 演算子は、演算子が実際に結合的または交換的である場合にのみ、通常の数学的規則に従って再グループ化できます。(7) たとえば、次のフラグメントでは: a=a+32760+b+5; 式ステートメントは、a=(((a+32760)+b)+5); とまったく同じように動作します。これらの演算子の結合性と優先順位によるものです。したがって、合計の結果 (a+32760) が次に b に加算され、その結果が 5 に加算され、結果として a に割り当てられる値になります。オーバーフローによって例外が発生し、int で表現できる値の範囲が [-32768,+32767] であるマシンでは、実装はこの式を a=((a+b)+32765) として書き換えることができません。a と b の値がそれぞれ -32754 と -15 の場合、a+b の合計は例外を生成しますが、元の式は生成しません。また、式を a=((a+32765)+b); のように書き換えることもできません。または a=(a+(b+32765)); a と b の値は、それぞれ 4 と -8 または -17 と 12 である可能性があるためです。ただし、オーバーフローが例外を生成せず、オーバーフローの結果が元に戻せるマシンでは、上記の式ステートメントは、同じ結果が発生するため、上記の方法のいずれかで実装によって書き換えることができます。-- 巻末注記】

したがって、優先順位により、式は次のようになると考えるかもしれません。

(
       (
              ( bar("A",i) ^ bar("B",i++)
              )
          ^  bar("C",i)
       )
    ^ bar("D",i)
);

ただし、 (a^b)^c==a^(b^c) はオーバーフローの可能性がないため、任意の順序で書き換えることができます...

しかし、bar() が呼び出されており、仮説的に副作用を伴う可能性があるため、この式は任意の順序で書き直すことはできません。優先ルールは引き続き適用されます。

これにより、 bar() の評価の順序が適切に決定されます。

では、そのi+=1はいつ発生するのでしょうか? bar("B",...)が呼び出される前に、まだ発生する必要があります。( bar("B",....)は古い値で呼び出されますが。)

したがって、bar(C)bar(D)の前、およびbar(A) の後に確定的に発生しています。

答え: いいえコンパイラが標準に準拠している場合、常に「A=0、B=0、C=1、D=1」になります。


しかし、別の問題を考えてみましょう:

i = 0;
int & j = i;
R = i ^ i++ ^ j;

R の値は何ですか?

i+=1がjの前に発生した場合、0^0^1=1 になります。しかし、i+=1が式全体の後に発生した場合、0^0^0=0 になります。

実際、R はゼロです。i+=1は、式が評価されるまで発生しません。


私が考える理由は次のとおりです。

i = 7、i++、i++; // i は 9 になります (有効)

合法です... 次の 3 つの表現があります。

  • 私は= 7
  • i++
  • i++

いずれの場合も、 iの値は各式の最後で変更されます。(後続の式が評価される前。)


PS: 考慮してください:

int foo(int theI) { SHOW(theI);  SHOW(i);  return theI; }
i = 0;
int & j = i;
R = i ^ i++ ^ foo(j);

この場合、foo(j)の前にi+=1を評価する必要があります。 Iは 1 で、Rは 0^0^1=1 です。

于 2009-03-01T09:43:56.083 に答える
1

MarkusQの答えに基づいて構築するには:;)

というか、ビルのコメント:

編集:ああ、コメントはまた消えた...まあ)

それらは並行して評価することができます。それが実際に起こるかどうかは、技術的には無関係です。

ただし、これを行うためにスレッドの並列処理は必要ありません。両方の最初のステップを評価し(iの値を取得)、2番目のステップ(iをインクリメント)の前に評価します。完全に合法であり、一部のコンパイラは、2番目のi++を開始する前に1つのi++を完全に評価するよりも効率的であると考える場合があります。

実際、私はそれが一般的な最適化であると期待しています。命令スケジューリングの観点から見てください。評価する必要があるのは次のとおりです。

  1. 正しい引数としてiの値を取ります
  2. 正しい引数でiをインクリメントします
  3. 左の引数にiの値を取ります
  4. 左の引数でiをインクリメントします

しかし、実際には、左と右の引数の間に依存関係はありません。引数の評価は不特定の順序で行われ、順番に実行する必要もありません(そのため、関数の引数のnew()は、スマートポインターにラップされている場合でも、通常はメモリリークになります)同じ変数を変更したときに何が起こるかも未定義です同じ式で2回。ただし、1と2の間、および3と4の間には依存関係があります。では、なぜコンパイラは2が完了するのを待ってから3を計算するのでしょうか。これによりレイテンシが追加され、4が利用可能になるまでに必要以上に時間がかかります。それぞれの間に1サイクルのレイテンシがあると仮定すると、1が完了してから4の結果が準備でき、関数を呼び出すことができるようになるまで、3サイクルかかります。

しかし、それらを並べ替えて1、3、2、4の順序で評価すると、2サイクルで実行できます。1と3は同じサイクルで開始でき(または同じ式であるため、1つの命令にマージすることもできます)、次では2と4を評価できます。最新のCPUはすべて、1サイクルあたり3〜4命令を実行でき、優れたコンパイラはそれを利用しようとする必要があります。

于 2009-02-28T16:27:36.433 に答える
0

Sutter's Guru of the Week#55(および「MoreExceptional C ++」の対応する部分)では、この正確なケースを例として説明しています。

彼によると、これは完全に有効なコードであり、実際、ステートメントを2行に変換しようとする場合です。

items.erase(i);
i ++;

元のステートメントと意味的に同等のコードを生成しません。

于 2009-03-05T18:34:42.470 に答える
-1

ビル・ザ・リザードの答えに基づいて構築するには:

int i = 1;
foo(i++, i++);

の関数呼び出しになる可能性もあります

foo(1, 1);

(つまり、actuals が並行して評価され、その後 postops が適用されます)。

-- マーカスQ

于 2009-02-28T15:45:16.223 に答える