45

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

除非另有说明,否则未指定单个运算符的操作数和单个表达式的子表达式的求值顺序,以及副作用发生的顺序。53) 在前一个和下一个序列点之间,一个标量对象的存储值最多只能通过表达式的评估修改一次。此外,只能访问先验值以确定要存储的值。对于完整表达式的子表达式的每个允许排序,都应满足本段的要求;否则行为未定义。[例子:

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 

——结束示例]

我很惊讶它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++ 标准禁止这样的表达?

4

15 回答 15

62

您错误地认为operator=是一个有两个参数的函数,其中参数的副作用必须在函数开始之前完全评估。如果是这种情况,则表达式i = ++i + 1将具有多个序列点,并且++i将在分配开始之前进行完全评估。但事实并非如此。内在赋值运算符中评估的是什么,而不是用户定义的运算符。该表达式中只有一个序列点。

结果++i赋值之前(和加法运算符之前)进行评估,但不一定会立即应用副作用。的结果++i + 1始终与 相同i + 2,因此这是i作为赋值运算符的一部分分配给的值。的结果++i是 always i + 1,所以这是i作为增量运算符的一部分分配的。没有顺序点来控制应该首先分配哪个值。

由于代码违反了“在前一个和下一个序列点之间,一个标量对象的存储值最多只能通过表达式的评估修改一次”的规则,因此行为是未定义的。但实际上,很可能先分配i + 1or i + 2,然后分配另一个值,最后程序将继续照常运行——没有鼻恶魔或爆炸厕所,也没有i + 3

于 2009-12-07T15:30:01.033 回答
37

i这是未定义的行为,而不是(只是)未指定的行为,因为在没有中间序列点的情况下进行了两次写入。就标准规定而言,根据定义就是这种方式。

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

此语句表达式的问题在于它暗示了两次写入而i没有中间序列点:

i = i++ + 1;

一次写入是针对“加一”的原始值的值,i另一次是针对该值再次“加一”的值。只要标准允许,这些写入可能以任何顺序发生或完全爆炸。从理论上讲,这甚至使实现可以自由地并行执行写回,而无需检查同时访问错误。

于 2009-12-07T15:09:15.907 回答
15

C/C++ 定义了一个称为序列点的概念,它指的是执行中的一个点,它保证先前评估的所有效果都已经执行。Sayingi = ++i + 1是未定义的,因为它会递增i并分配i给自身,这两者都不是单独定义的序列点。因此,不确定哪个会先发生。

于 2009-12-07T15:04:51.557 回答
10

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

Stop,这在 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

于 2011-09-30T21:41:36.747 回答
9

给定两个选择:已定义或未定义,您会做出哪个选择?

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

鉴于首先编写此类代码显然是不明智的,因此为其指定结果没有任何意义。人们会想要阻止这样的代码而不是鼓励它。它对任何事情都没有用或没有必要。

此外,标准委员会没有任何办法强制编译器编写者做任何事情。如果他们需要特定的行为,则很可能会忽略该要求。

也有实际原因,但我怀疑它们从属于上述一般考虑。但是为了记录,这种表达式和相关类型的任何类型的必需行为都会限制编译器生成代码、分解公共子表达式、在寄存器和内存之间移动对象等的能力。C 已经受到弱可见性的限制限制。像 Fortran 这样的语言很久以前就意识到别名参数和全局变量是优化杀手,我相信他们只是禁止它们。

我知道您对特定表达式感兴趣,但是任何给定构造的确切性质并不重要。预测一个复杂的代码生成器会做什么并不容易,并且该语言试图在愚蠢的情况下不需要这些预测。

于 2009-12-07T15:09:04.873 回答
8

该标准的重要部分是:

它的存储值最多被表达式的评估修改一次

您修改值两次,一次使用 ++ 运算符,一次使用赋值

于 2009-12-07T15:08:28.960 回答
7

请注意,您的标准副本已过时,并且仅在示例的第 1 和第 3 代码行中包含已知(且已修复)错误,请参阅:

C++ 标准核心语言问题目录,修订版 67,#351

Andrew Koenig:序列点错误:未指定或未定义?

仅阅读标准并不容易获得该主题(在这种情况下,这非常晦涩:(在这种情况下)。

例如,它是良好(或未)定义、未指定还是在一般情况下实际上不仅取决于语句结构,还取决于执行时的内存内容(具体而言,变量值),另一个例子:

++i, ++i; //ok

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

请看一下(这一切都清楚而准确):

JTC1/SC22/WG14 N926《序列点分析》

此外,Angelika Langer 有一篇关于该主题的文章(虽然不如上一篇那么清晰):

“C++ 中的序列点和表达式求值”

还有一个用俄语进行的讨论(尽管评论和帖子本身中有一些明显错误的陈述):

“Точки следования(序列点)”

于 2009-12-07T16:10:57.363 回答
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 作为结果。

于 2009-12-08T07:30:44.830 回答
4

假设您要问“为什么语言是这样设计的?”。

您说那i = ++i + i是“显然未定义”,但i = ++i + 1应该留下i一个定义的值?坦率地说,这不会很一致。我更喜欢完美定义所有内容,或者始终未指定所有内容。在 C++ 中,我有后者。这本身并不是一个非常糟糕的选择 - 一方面,它可以防止您编写在同一个“语句”中进行五六次修改的邪恶代码。

于 2009-12-07T15:15:34.083 回答
3

类比论证:如果您将运算符视为函数的类型,那么它是有道理的。如果你有一个带有重载的类operator=,你的赋值语句就相当于这样:

operator=(i, ++i+1)

(第一个参数实际上是通过this指针隐式传入的,但这只是为了说明。)

对于普通的函数调用,这显然是未定义的。第一个参数的值取决于第二个参数的计算时间。但是,对于原始类型,您可以摆脱它,因为原始值i被简单地覆盖了;它的价值无关紧要。但如果你自己做一些其他的魔法operator=,那么差异就会浮出水面。

简而言之:所有运算符的行为都类似于函数,因此应该按照相同的概念运行。如果i + ++i是未定义的,那么i = ++i也应该是未定义的。

于 2009-12-07T15:29:17.893 回答
2

怎么样,我们都同意永远,永远,写这样的代码?如果编译器不知道你想做什么,你怎么指望后面跟着你的可怜的 sap 理解你想做什么?把 i++; 在它自己的线上不会杀了你。

于 2009-12-07T15:46:18.360 回答
1

i = v[i++]; // 行为未指定
i = ++i + 1; // 行为未指定

以上所有表达式都会调用未定义的行为。

i = 7, i++, i++; // i 变成 9

这可以。

阅读 Steve Summit 的 C-FAQ。

于 2009-12-07T15:14:50.213 回答
1

根本原因是编译器处理读取和写入值的方式。允许编译器将中间值存储在内存中,并且仅在表达式末尾实际提交该值。我们将表达式读++i作“加i一并返回”,但编译器可能会将其视为“加载 的值i,加一,返回,并在有人再次使用之前将其提交回内存。鼓励编译器尽可能避免读取/写入实际内存位置,因为这会减慢程序的速度。

在 的特定情况下i = ++i + 1,它主要是由于需要一致的行为规则而受到影响。许多编译器在这种情况下会做“正确的事情”,但如果其中一个is 实际上是一个指针,指向i? 如果没有这条规则,编译器必须非常小心,以确保它以正确的顺序执行加载和存储。此规则用于允许更多优化机会。

类似的情况是所谓的严格混叠规则。您不能通过不int相关类型的值(例如 a )分配值(例如 an )float,只有少数例外。这使编译器不必担心float *正在使用的某些内容会改变 a 的值int,并大大提高了优化潜力。

于 2009-12-07T15:31:21.030 回答
1

这里的问题是该标准允许编译器在执行语句时对其进行完全重新排序。但是,不允许对语句重新排序(只要任何此类重新排序会导致程序行为发生变化)。因此,表达式i = ++i + 1;可以通过两种方式计算:

++i; // i = 2
i = i + 1;

或者

i = i + 1;  // i = 2
++i;

或者

i = i + 1;  ++i; //(Running in parallel using, say, an SSE instruction) i = 1

当您混合使用用户定义的类型时,情况会变得更糟,其中 ++ 运算符可以对类型的作者想要的类型产生任何影响,在这种情况下,评估中使用的顺序非常重要。

于 2009-12-07T15:37:03.393 回答
0

++i,我必须分配“1”,但i = ++i + 1必须分配值“2”。由于没有中间序列点,编译器可以假设同一个变量没有被写入两次,因此这两个操作可以按任何顺序完成。所以是的,如果最终值为 1,编译器将是正确的。

于 2009-12-07T15:56:16.653 回答