在C++函数调用中使用递增运算符是否合法?

49

这个问题中,有一些讨论关于以下代码是否是合法的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
喜欢这个问题。在一种语言中,即使像 f(i++) 这样微不足道的代码也能引发有关语义的长时间辩论,多次引用标准文献,这真是令人着迷... :) - jalf
你在这里真的混淆了两个问题:(1)在函数调用参数中通常是否合法使用后增运算符,以及(2)特定用途是否会产生预期结果。它始终是合法的,但它是否会产生所需的结果取决于具体情况。 - nobody
8个回答

65

引用自C++标准 1.9.16:

在调用函数时(无论该函数是否为内联),与任何参数表达式相关的每个值计算和副作用,以及指定被调用函数的后缀表达式相关的每个值计算和副作用,都要先于被调用函数体中的每个表达式或语句执行。 (注意:与不同参数表达式相关的值计算和副作用是无序的)。

所以我觉得这段代码:

foo(i++);

这是完全合法的。它将增加i,然后使用i的先前值调用foo。但是,以下代码:

foo(i++, i++);

这段代码的行为未定义,因为1.9.16段落也说到:

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


这里不是未定义的。它将会执行两种操作,但顺序是未定义的。 - Migol
5
第二个是未定义的。在同一个表达式中两次修改变量不是明确定义的,而不仅仅是它们被评估的顺序。从实际角度来说,两个操作都可能会增加原始的i,而不是另一个操作更新后的i。第一个没有问题。 - jalf
foo(++i, ++i)会不会更加未定义? - Benoît
25
它怎么能“更加未定义”呢? - Ed S.
1
@Ed 哈哈,确实,0怎么可能比0还要0。 - Ólafur Waage
显示剩余3条评论

16

为了进一步延伸Kristo的答案

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

每一次执行时,但这段代码不具备可移植性,因此我预计在不同编译器上会看到不同的结果。


1
它也可能导致foo(2,2)。您不知道每个增量使用的i值,它可能在两个增量中都使用原始值。 理论上,它也可能导致foo(42,-263),但我们可能不太可能从现实世界的编译器中看到这个特定的结果。 ;) - jalf
哦,等等,所有的例子都不是从1开始吗?应该是foo(1,1),foo(1,2)和foo(2,1)吧? - jalf
@Steve Rowe:假设最左边的增量首先被评估,将1作为函数的第一个参数产生,并将i设置为2。然后评估最右边的增量,产生2作为第二个参数,并将最终的i设置为3。然后foo被调用,参数为(1,2),之后i=3。 - jalf
@Bill:不,它们不需要有不同的值,这是未定义的(http://www.research.att.com/~bs/bs_faq2.html#evaluation-order)。不能保证它们将被逐个评估(或者根据严谨要求可能根本没有评估)。 - jalf
我相信Steve是对的。永远不可能只有(2,2),也许只有(1,1)。 - Milan Babuškov
显示剩余5条评论

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()明确不会使除被删除的迭代器之外的任何迭代器失效。

尽管如此,这样写是不好的风格 - 所有容器的erase函数都返回下一个迭代器,以便您不必担心由于重新分配而使迭代器失效,因此惯用的代码应该是:

i = items.erase(i);

这段代码会对列表、向量、双端队列等任何序列容器的存储进行保护,使其更加安全。

如果您不进行修改,那么您将无法编译出原始代码而不带有警告信息,因此您需要进行以下操作:

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

为了避免未使用返回值的警告,这是一个很大的提示,表明你正在做一些奇怪的事情。

erase() 函数返回序列容器的迭代器,但不适用于 set 和 map 等关联容器。 - bk1e
将“sequence”添加到帖子中。一些实现确实具有返回值,但这不是标准。 - Pete Kirkham
在第一个例子中,应该是items.erase(i_before);。 - jmucchiello
你肯定需要将警告级别设置得非常高,才能收到关于未使用返回值的警告吗?必须承认我从来没有费心去做这件事——它需要在很多完全有效的代码前面添加“(void)”。或者我有什么遗漏吗? - j_random_hacker
可能没有太多有效的代码;我并不觉得它很繁琐,反而因此发现了隐藏的错误。 - Pete Kirkham

3

++Kristo!

C++标准1.9.16对于如何实现类的operator++(后缀)方法有很多合理之处。当调用该方法时,它会自增并返回原始值的一个副本。正如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() 之前或之后被调用。这是“未指定”的。

但我们知道,在 baz() 之前,同时会调用 g()h()

给定:

bar(i++,i++);

再次强调,我们不知道哪个 i++ 会先被计算,甚至不确定在调用 bar() 之前 i 是否会被增加一次或两次。 结果是未定义的!(假设 i=0,这可能是 bar(0,0)bar(1,0)bar(0,1) 或者一些非常奇怪的东西!)


给定:

foo(i++);

我们现在知道,在调用foo()之前,i将会被递增。正如KristoC++标准第1.9.16节中指出的:

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

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

后缀++表达式的值是其操作数的值。[注意:获得的值是原始值的副本--注释]操作数必须是可修改的左值。操作数的类型必须是算术类型或完整有效对象类型的指针。将1添加到操作数对象会修改操作数对象的值,除非对象的类型为bool,在这种情况下,它将设置为true。[注意:此用法已弃用,请参见附录D.--注释] ++表达式的值计算在修改操作数对象之前进行排序。关于一个不确定排序的函数调用,后缀++运算符的操作是单个评估。[注意:因此,函数调用不得介入从左值转换为右值的过程和任何单个后缀++运算符相关的副作用。--注释]结果是rvalue。结果的类型是操作数类型的cv-unqualified版本。另请参见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++); }

所以,是的,在调用foo()之前,i会被递增。
从以下角度来看,所有这些都有很多道理:
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 调用的),因此在调用 bar("C",i)bar("D",i) 之前,i 已经自增了。


回到 j_random_hacker 的评论:

j_random_hacker 写道:
+1,但我必须仔细阅读标准文件才能确信这是正确的。如果 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.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+=1j之前发生,我们将得到0^0^1=1。但如果i+=1在整个表达式之后发生,我们将得到0^0^0=0。

实际上,R为零。在表达式被评估之后,i+=1才会发生。


我想这就是为什么:

i = 7, i++, i++; // i变成9(有效)

是合法的... 它有三个表达式:

  • i = 7
  • i++
  • i++

而且在每种情况下,在每个表达式结束时都会改变i的值。(在评估任何后续表达式之前。)


附:

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

在这种情况下,i+=1必须在foo(j)之前被评估。 theI为1。而R为0^0^1=1。

+1,但我不得不仔细阅读标准,以确信这是可以的。 我想知道,如果bar()改为返回int的全局函数,f是int,并且这些调用由“^”而不是“。”连接,那么A,C和D中的任何一个都可以报告“0”,我的想法是正确的吗? - j_random_hacker
通过修改原始帖子进行了回复。(超过300个字符。) - Mr.Ree
由于读取和递增i而没有排序,因此R示例具有未定义的行为。 - Davis Herring

3

完全没有问题。 传递的值将是“i”在增量之前的值。


1

在 MarkusQ 的回答基础上:

或者说,根据 Bill 的评论:

(编辑:啊,评论又没了...好吧)

它们可以并行评估。无论实际上是否发生都是技术上无关紧要的。

但是,你不需要线程并行性来实现这一点,只需在第二步(增加 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条指令,良好的编译器应该尝试利用它。


1
分析编译器对未定义内容的处理有什么意义呢?如果你想要速度,不发出未定义表达式的代码会更有效吧。 - David Thornley
编译器对未定义的表达式的处理方式无论在速度上还是其他方面都没有意义,因为首先你的代码中不应该有任何未定义的内容。如果你的代码调用了未定义的行为,那么你已经输了。;) - jalf
我在帖子中的观点只是为了说明表达式可能产生“令人惊讶”的值的一种方式,以及编译器为什么会选择这样做而不是更可预测的结果。当然,依赖于这些内容是愚蠢的。 :) - jalf

0
Sutter在他的Guru of the Week #55(以及“More Exceptional C++”中相应的部分)中将此作为一个例子进行了讨论。
根据他的说法,这是完全有效的代码,事实上,试图将语句转换为两行的情况下:
items.erase(i);
i++;

不会产生与原始语句在语义上等效的代码。


-1

在 Bill the Lizard 的答案上进行补充:

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

这也可能导致一个函数调用

foo(1, 1);

(这意味着实际值是并行评估的,然后应用后操作)。

——MarkusQ


我认为应该是foo(1,1)。Bill之前犯了一个偏差错误,我认为MarkusQ在他的帖子中只是复制了那个错误。 - jalf
3
根据标准,这也可能导致计算机变成一个填充的熊猫。"未定义"就是未定义,争论某个特定实现可能会做什么是毫无意义的。 - David Thornley
@DavidThornley 希望有一种实现可以将电脑变成填充的熊猫的方法... - cerkiewny

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接