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,然后foo使用之前的值调用i. 但是,这段代码:

foo(i++, i++);

产生未定义的行为,因为第 1.9.16 段还说:

如果标量对象上的副作用相对于同一标量对象上的另一个副作用或使用同一标量对象的值的值计算是未排序的,则行为未定义。

于 2009-02-28T15:23:51.643 回答
15

克里斯托的回答为基础,

foo(i++, i++);

产生未定义的行为,因为计算函数参数的顺序是未定义的(在更一般的情况下,因为如果您在同时写入变量的表达式中读取变量两次,结果是未定义的)。您不知道哪个参数将首先递增。

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是否会增加一次或两次。 结果是不确定的! (给定i=0,这可能是bar(0,0)bar(1,0)bar(0,1)或一些非常奇怪的东西!)


鉴于:

foo(i++);

我们现在知道i将在foo()被调用之前递增。正如Kristo从C++ 标准第 1.9.16 节指出的那样:

调用函数时(无论该函数是否内联),与任何参数表达式或与指定被调用函数的后缀表达式相关的每个值计算和副作用都在执行主体中的每个表达式或语句之前进行排序称为函数。[注意:与不同参数表达式相关的值计算和副作用是无序的。——尾注]

虽然我认为第 5.2.6 节说得更好:

后缀 ++ 表达式的值是其操作数的值。[注:获得的值是原始值的副本——结束注]操作数应为可修改的左值。操作数的类型应为算术类型或指向完整有效对象类型的指针。操作数对象的值通过加 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);

必须执行f.bar("A",i)以获取用于object.bar("B",i++)的对象,以此类推"C""D"

所以我们知道i++在调用bar("B",i++)之前增加i(即使bar("B",...)是用i的旧值调用的),因此ibar( "C",i)bar("D",i)


回到j_random_hacker的评论:

j_random_hacker 写道:
+1,但我必须仔细阅读标准以说服自己这没问题。我是否正确地认为,如果 bar() 是一个返回say int 的全局函数,f 是一个 int,并且这些调用是通过say "^" 而不是 "." 连接的,那么 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.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(有效)

是合法的……它有三种表达方式:

  • 我 = 7
  • 我++
  • 我++

在每种情况下,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+=1theI是 1。而R是 0^0^1=1。

于 2009-03-01T09:43:56.083 回答
1

以 MarkusQ 的回答为基础:;)

或者更确切地说,比尔对此的评论:

编辑:哦,评论又消失了……哦,好吧)

它们被允许并行评估。它是否在实践中发生在技术上无关紧要。

但是,您不需要线程并行性来实现这一点,只需在第二步(递增 i)之前评估两者的第一步(取 i 的值)。完全合法,一些编译器可能认为它比在开始第二个之前完全评估一个 i++ 更有效。

事实上,我希望它是一种常见的优化。从指令调度的角度来看。您需要评估以下内容:

  1. 取 i 的值作为正确的论点
  2. 在正确的参数中增加 i
  3. 取 i 的值作为左参数
  4. 在左参数中增加 i

但是左右论点之间确实没有依赖关系。参数评估以未指定的顺序发生,也不需要按顺序进行(这就是为什么函数参数中的 new() 通常是内存泄漏,即使包装在智能指针中)它也未定义修改相同变量时会发生什么在同一个表达式中出现两次。但是,我们确实在 1 和 2 之间以及在 3 和 4 之间存在依赖关系。那么为什么编译器要等待 2 完成后再计算 3 呢?这会增加延迟,并且在 4 可用之前需要更长的时间。假设每个之间有 1 个周期延迟,从 1 完成到 4 的结果准备好需要 3 个周期,我们可以调用该函数。

但是如果我们对它们重新排序并按照 1、3、2、4 的顺序进行评估,我们可以在 2 个周期内完成。1 和 3 可以在同一个循环中启动(甚至可以合并到一条指令中,因为它是同一个表达式),然后可以计算 2 和 4。所有现代 CPU 每个周期都可以执行 3-4 条指令,一个好的编译器应该尝试利用这一点。

于 2009-02-28T16:27:36.433 回答
0

Sutter's Guru of the Week #55(以及“More Exceptional C++”中的相应文章)作为示例讨论了这个确切的案例。

According to him, it is perfectly valid code, and in fact a case where trying to transform the statement into two lines:

items.erase(i);
i++;

does not produce code that is semantically equivalent to the original statement.

于 2009-03-05T18:34:42.470 回答
-1

以蜥蜴比尔的回答为基础:

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

也可能导致函数调用

foo(1, 1);

(意味着并行评估实际值,然后应用 postops)。

——马库斯

于 2009-02-28T15:45:16.223 回答