为什么`i = ++i + 1`是未指定行为?

45
考虑以下 C++ 标准 ISO/IEC 14882:2003(E) 引用(第5节,第4段):

Except where noted, the order of evaluation of operands of individual operators and subexpressions of individual expressions, and the order in which side effects take place, is unspecified. 53) Between the previous and next sequence point a scalar object shall have its stored value modified at most once by the evaluation of an expression. Furthermore, the prior value shall be accessed only to determine the value to be stored. The requirements of this paragraph shall be met for each allowable ordering of the subexpressions of a full expression; otherwise the behavior is undefined. [Example:

i = v[i++];  // the behavior is unspecified 
i = 7, i++, i++;  //  i becomes 9 

i = ++i + 1;  // the behavior is unspecified 
i = i + 1;  // the value of i is incremented 

—end example]

我很惊讶地发现,i = ++i + 1这个表达式会使得i的值变成未定义。 有没有人知道是否有某种编译器实现不会针对以下情况返回2

int i = 0;
i = ++i + 1;
std::cout << i << std::endl;

事实上,operator=有两个参数,第一个始终是i的引用。在这种情况下,评估顺序并不重要。我没有看到任何问题,除了C++标准的禁忌.

不要考虑那些参数顺序对评估很重要的情况。例如,++i + i显然是未定义的。请只考虑我的情况i = ++i + 1

为什么C++标准禁止这样的表达式?


2
operator= 不是整数类型的序列点。 - Andreas Brinck
@Alexey 抱歉,我最终在我的标准副本中找到了它。谢谢。 - Pascal Cuoq
3
你的例子中的“未指定”情况实际上应该是“未定义”,这是标准中的一个错误(现已修正)。请参见我的答案。 - mlvljr
我的问题不是关于标准中未指定和未定义之间的区别。 - Alexey Malistov
2
@Alexey Malistov 是的,但是这种区分使得你的问题(“为什么i= ++i +1是未指定行为?”)不准确。 - mlvljr
显示剩余2条评论
15个回答

62
你犯了认为operator=是一个有两个参数的函数的错误,其中必须在函数开始之前完全评估参数的副作用。如果是这样,那么表达式i = ++i + 1将具有多个序列点,并且++i将在赋值开始之前完全评估。但事实并非如此。在内在赋值运算符中进行的评估,而不是用户定义的运算符。该表达式中只有一个序列点。 ++i的结果在赋值(以及加法运算符)之前进行评估,但是其副作用不一定立即应用。 ++i +1的结果始终与i+2相同,因此这是作为赋值运算符的一部分分配给i的值。 ++i的结果始终是i+1,因此这就是作为递增运算符的一部分分配给i的值。没有序列点来控制应首先分配哪个值。
由于代码违反了“在上一个和下一个序列点之间,标量对象的存储值应最多通过表达式的评估修改一次”的规则,因此行为未定义。 但是,实际上,很可能首先分配i + 1i + 2,然后再分配另一个值,最后程序将继续正常运行。没有鼻妖或爆炸的马桶,也没有i + 3

3
我认为这个答案最直接地解决了提问者困惑的根源。由于i是整数类型(而不是具有重载=运算符的类),所以对于operator =,不存在序列点,因为operator =不是函数调用。 - Charles Salvia
1
Alexey,我建议你把这个问题单独问一下。这涉及到内置内容和用户定义内容之间的一般主题平等性。(固有的不需要在标准中出现,该词就有意义。对于编译器已知但未被告知的事物来说,它只是内置的同义词。) - Rob Kennedy
4
如果一款[高科技]厕所废物处理系统的固件在某个地方的压力系数上得到了'2'而不是'1'会怎样? - mlvljr
+1 很好的回答,我想我终于明白了序列点。然而,还有一个小疑问。给定 int xint ix = ++i + 1 是未定义的吗? - Vojislav Stojkovic
@VojislavStojkovic 当然不会。在那个语句中,没有任何东西被修改超过一次。 - Seth Carnegie
显示剩余2条评论

37

这是未定义行为,而不仅仅是未指定行为,因为在i没有中间序列点的情况下有两个写入。 根据标准规定,这就是它的定义方式。

标准允许编译器生成延迟写回存储或从另一个角度来看,重新排列实现副作用的指令,只要它符合序列点的要求。

此语句表达式的问题在于它意味着在没有中间序列点的情况下对i进行了两次写入:

i = i++ + 1;

其中一个写操作是将变量 i 的原始值 "加一",而另一个写操作则是将该值 "再加一"。根据标准定义,这两个写操作可以以任何顺序执行,或者完全失败。理论上,这甚至使得实现可以自由地并行执行写回操作,而无需检查并发访问错误。


8
是的,它是未定义的。执行这行代码后,你的电脑可能会变成棉花糖。 - GManNickG
是的,这确实是一种可能性,尽管任何事情都有可能发生。不过,标准使用的示例有点不幸,它展示了子表达式求值的未指定顺序,并且调用了未定义的行为,却没有明确说明。 - CB Bailey
5
这个是实现定义。我们这里不发表意见。 :) 如果编译器编译的代码符合标准,那么可以称之为标准兼容的编译器。但由于这是未定义的行为,编译器可以做任何它想做的事情。它们甚至可以在将 i 设置为 123456789 之前格式化你的计算机。 - GManNickG
6
@GMan: 确实,一个将你的电脑变成棉花糖的C语言实现在执行 i = i++ + 1 时符合标准,但我认为这是实现质量的问题,除非有巧克力参与,否则我会对这个实现感到非常不满意。 - David Thornley
5
延迟写入并未在标准中明确提及,它是一种实现细节。如果您根据标准规定的所有要求设计了符合规范的实现,那么在程序正确运行时,它在内部执行什么操作并不重要。 - CB Bailey
显示剩余5条评论

15

C/C++ 定义了一个叫做序列点的概念,它指的是执行中的一个点,保证之前所有评估的效果都已经被执行。说 i = ++i + 1 是未定义的,因为它既增加了 i 的值,也将其赋值给了自身,这两个操作单独来看都不是一个定义好的序列点。因此,无法确定哪个操作会先执行。


8
@Andreas: 这并不否定这个信息是问题的答案。问题本身就包含着答案。 - Daniel Daranas
我很困惑为什么预增量可能是未指定的。后增量版本我能理解,但在给定的例子中,我本来以为顺序必须是[++i => i = 1][++i + 1 => 2][i=2]。你能详细说明为什么这是错误的吗? - Sebastian
2
@Daniel 我的理解是Alexsey已经知道这个,但想知道为什么标准是以这种方式编写的。 - Andreas Brinck
1
@Sebastion, 可能确信这是正确的顺序,但是任何编译器供应商可能会有不同的想法。关键是,即使我同意你的理解在直觉上是有道理的,但并不保证,因为在这种情况下,预增量不是一个序列点。 - Charles Salvia
1
@Andreas 我同意。我自己也是以这种解释为基础回答的。但是它没有表述得很清楚,所以那些只是重复现实的答案并不让我感到惊讶。 - Daniel Daranas

10

C++11更新(09/30/2011)

停止,在C++11标准中,这是定义良好的。在C++03中它是未定义的,但C++11更加灵活。

int i = 0;
i = ++i + 1;

执行那行代码后,i 的值将变为 2。这个改变的原因是...因为在实践中它已经有效了,而让它未定义会比只在 C++11 标准中保留它更加困难(实际上,这种做法现在能够工作更多的是一种意外而非有意的改变,所以请不要在你的代码中这样做!)。

权威解释

http://www.open-std.org/jtc1/sc22/wg21/docs/cwg_defects.html#637


1
不需要全大写,因为其他回答在发布时已经完全正确。 - Puppy
1
@DeadMG 我认为这是重要的消息! - Johannes Schaub - litb
1
也许你可以添加一个链接(或引用)到一些关于C++11排序规则(“sequenced before”)及其如何改变情况的描述,例如https://dev59.com/7m855IYBdhLWcg3wcz5h#4338764? - Suma
@Suma 我们在这里的评论中讨论了它:https://dev59.com/inA65IYBdhLWcg3wrgoa#3691469。 - Johannes Schaub - litb

9

在定义和未定义两个选择中,你会选择哪一个?

标准的作者有两个选择:定义行为或将其指定为未定义。

考虑到编写这样的代码本身就是不明智的,因此指定其结果没有任何意义。人们希望阻止这种代码的编写,而不是鼓励它。它对于任何事情都没有用处或必要性。

此外,标准委员会没有任何方法强制编译器编写者执行任何操作。如果他们要求特定的行为,很可能这个要求会被忽视。

实际上还有其他原因,但我认为它们次于上述一般考虑因素。但是,值得记录的是,任何此类表达式和相关类型的所需行为都会限制编译器生成代码的能力,限制公共子表达式的分解,限制对象在寄存器和内存之间的移动等。C语言已经受到了可见性限制的影响。像Fortran这样的语言早就意识到别名参数和全局变量是优化杀手,因此他们干脆禁止了它们。

我知道您对某个特定表达式感兴趣,但任何给定结构的确切性质并不重要。预测复杂代码生成器将执行的操作并不容易,而且语言试图在愚蠢的情况下不要求这些预测。


“鉴于首先编写这样的代码显然是不明智的…” - Daniel Daranas

8

标准的重要部分是:

其存储的值最多只能通过表达式的计算修改一次

您使用++运算符和赋值运算符对值进行了两次修改。


我看到了两次。那又怎样?我想要两次。为什么是未定义的?为什么i等于2? - Alexey Malistov
3
@Alexey,你是在询问为什么标准规定它是未定义的吗?我不知道答案,你需要向标准委员会的某个人提问。 - Trent

7
请注意,您的标准副本已过时,并且包含已知(且已修复)错误,仅在示例的第1行和第3行中,参见: C++标准核心语言问题目录,修订版67,#351Andrew Koenig:序列点错误:未指定或未定义? 这个主题不容易通过阅读标准来理解(在这种情况下,标准相当晦涩 :( )。
例如,实际上是否良好定义、未指定或其他一般情况取决于语句结构以及执行时内存内容(具体而言,变量值),另一个例子:
++i, ++i; //ok

(++i, ++j) + (++i, ++j); //ub, see the first reference below (12.1 - 12.3)

请查看以下链接(内容清晰明确):
JTC1/SC22/WG14 N926 "Sequence Point Analysis" 此外,Angelika Langer也有一篇关于该主题的文章(不如上一篇那么清晰):
"Sequence Points and Expression Evaluation in C++" 还有一篇俄语讨论(其中评论和帖子本身似乎存在一些错误陈述):
"Точки следования (sequence points)"

1
谢谢,但要注意作者在 'int a,b,c; a = b = c = 0;' 这个问题上是错误的(赋值顺序严格由运算符优先级规则指定,并且实际上没有变量读取,就像 'int i = 0;' 中一样)。 - mlvljr

4
以下代码演示了如何得到错误(意外)的结果:
int main()
{
  int i = 0;
  __asm { // here standard conformant implementation of i = ++i + 1
    mov eax, i;
    inc eax;
    mov ecx, 1;
    add ecx, eax;
    mov i, ecx;

    mov i, eax; // delayed write
  };
  cout << i << endl;
}

它将打印1作为结果。

4
假设您的问题是“为什么这门语言会被设计成这样?”。
您说i = ++i + i是“明显未定义”的,但i = ++i + 1却应该留下一个有定义的值?实际上,这并不是很一致。我更喜欢要么所有东西都完全定义,要么所有东西都一致地未指定。在C++中,我有后者。这并不是非常糟糕的选择-首先,它可以防止您编写邪恶的代码,在同一“语句”中进行五到六次修改。

3
我不明白,“++i + 1”这个表达式不是一个排序问题。而“i = ++i + 1”的问题在于对i的赋值以及在同一序列点之间对i的增量。 - Trent
@Trent:你没理解是因为我表达得不好。你说得对,问题在于递增和赋值之间。我现在已经重新表述了它。 - Daniel Daranas

3
类比论证: 如果你把操作符看作是函数的类型,那么这种说法就有点道理了。如果你有一个带有重载的operator=的类,那么你的赋值语句将等同于以下内容:
operator=(i, ++i+1)

(第一个参数实际上是通过this指针隐式传递的,但这只是为了说明。)
对于普通函数调用,这显然是未定义的。第一个参数的值取决于何时评估第二个参数。但是对于原始类型,您可以使用它,因为原始值被简单地覆盖;它的值并不重要。但是,如果您在自己的operator=中进行了某些其他操作,则差异可能会浮出水面。
简而言之:所有运算符都像函数一样工作,因此应按照相同的概念进行行为。如果i + ++i是未定义的,则i = ++i也应该是未定义的。

第一个参数的值是有所依赖的。但是引用并不依赖于它。如果没有传递 i 的引用,就无法重载 operator=。 - Alexey Malistov
我不确定你的意思...是的,第一个参数是引用而不是值,我忽略了这一点,但在重载运算符中,您可以通过引用检索第一个参数的值,并执行某些依赖于它的计算。 - int3

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