在实践中,为什么不同的编译器会计算不同的 int x = + + i + + i; 的值?

考虑下面的代码:

int i = 1;
int x = ++i + ++i;

对于编译器可能为此代码执行的操作,我们有一些猜测,假设它进行编译。

  1. 两个 ++i都返回 2,导致 x=4
  2. 一个 ++i返回 2,另一个返回 3,结果是 x=5
  3. 两个 ++i都返回 3,导致 x=6

在我看来,第二种可能性最大。使用 i = 1执行两个 ++操作符之一,i递增,并返回结果 2。然后使用 i = 2执行第二个 ++操作符,i递增,并返回结果 3。然后将 23加在一起得到 i = 10。

但是,我在 VisualStudio 中运行此代码,结果是 6。我试图更好地理解编译器,我想知道什么可能导致 6的结果。我唯一的猜测是,代码可以通过某种“内置”并发执行。调用了两个 ++操作符,每个操作符在返回另一个操作符之前递增 i,然后它们都返回 3。这将与我对调用堆栈的理解相矛盾,需要进行解释。

一个 C++编译器可以做哪些(合理的)事情来导致 4或者结果或者 6的结果?

注意

这个例子出现在未定义行为比雅尼·斯特劳斯特鲁普的《编程: 使用 c + + (c + + 14)的原则与实践》中。

参见 肉桂的评论

12681 次浏览

虽然这是 UB (正如 OP 所暗示的那样) ,但下面是编译器可以得到3个结果的假设方法。如果使用不同的 int i = 1, j = 1;变量而不是同一个 i变量,这三个变量都会得到相同的正确 x结果。

  1. Both + + i 返回2,结果是 x = 4。
int i = 1;
int i1 = i, i2 = i;   // i1 = i2 = 1
++i1;                 // i1 = 2
++i2;                 // i2 = 2
int x = i1 + i2;      // x = 4
  1. 一个 + + i 返回2,另一个返回3,结果是 x = 5。
int i = 1;
int i1 = ++i;           // i1 = 2
int i2 = ++i;           // i2 = 3
int x = i1 + i2;        // x = 5
  1. Both + + i 返回3,结果是 x = 6。
int i = 1;
int &i1 = i, &i2 = i;
++i1;                   // i = 2
++i2;                   // i = 3
int x = i1 + i2;        // x = 6

在我看来,第二种可能性最大。

我选择选项4: 两个 ++i同时发生。

较新的处理器朝着一些有趣的优化和并行代码计算方向发展,这里允许并行代码计算,这是编译器保持编写更快代码的另一种方式。我把它看作 实际执行,编译器正在向并行化发展。

我可以很容易地看到由于相同的内存争用导致的不确定性行为或总线错误的竞争条件——所有这些都是由于编码器违反了 C + + 协议而允许的——因此是 UB。

我的问题是: C + + 编译器可以做哪些(合理的)事情来得到4或者6的结果?

可以,但不计算在内。

不要使用 ++i + ++i,也不要期望合理的结果。

看起来 + + i 返回一个左值,但是 i + + 返回一个右值。
所以这个代码是可以的:

int i = 1;
++i = 10;
cout << i << endl;

这个不是:

int i = 1;
i++ = 10;
cout << i << endl;

上述两种说法与 VisualC + + 、 gCC7.1.1、 CLang 和 Embarcadero 的说法是一致的。
这就是为什么 VisualC + + 和 GCC7.1.1中的代码类似于下面的代码

int i = 1;
... do something there for instance: ++i; ++i; ...
int x = i + i;

当查看反汇编时,它首先增加 i,重写 i。当尝试添加它时,也会做同样的事情,增加 i 并重写它。然后加上 i。
enter image description here
我注意到 CLang 和 Embarcadero 的行为不同。因此它与第一个语句不一致,在第一个 + + i 之后,它将结果存储在一个 rvalue 中,然后将其添加到第二个 i + + 。

编译器可以做的合理的事情是公共子表达式消除。这在编译器中已经是一种常见的优化: 如果类似 (x+1)的子表达式在较大的表达式中出现不止一次,那么它只需要计算一次。例如,在 a/(x+1) + b*(x+1)中,x+1的子表达可以计算一次。

当然,编译器必须知道哪些子表达式可以通过这种方式进行优化。调用 rand()两次应该会得到两个随机数。因此,非内联函数调用必须免于 CSE。正如您所注意到的,没有规则说明应该如何处理 i++的两个事件,因此没有理由将它们从 CSE 中豁免。

结果可能的确是 int x = ++i + ++i;被优化为 int __cse = i++; int x = __cse << 1。(CSE,其次是反复强度降低)

编译器将您的代码分割成非常简单的指令,然后重新组合并以它认为最佳的方式排列它们。

密码

int i = 1;
int x = ++i + ++i;

包括以下指示:

1. store 1 in i
2. read i as tmp1
3. add 1 to tmp1
4. store tmp1 in i
5. read i as tmp2
6. read i as tmp3
7. add 1 to tmp3
8. store tmp3 in i
9. read i as tmp4
10. add tmp2 and tmp4, as tmp5
11. store tmp5 in x

但是尽管这是我写的一个编号列表,这里只有少数 排序依赖关系:1-> 2-> 3-> 4-> 5-> 10-> 11和1-> 6-> 7-> 8-> 9-> 10-> 11必须保持它们的相对顺序。除此之外,编译器可以自由地重新排序,也许还可以消除冗余。

例如,您可以像下面这样对列表进行排序:

1. store 1 in i
2. read i as tmp1
6. read i as tmp3
3. add 1 to tmp1
7. add 1 to tmp3
4. store tmp1 in i
8. store tmp3 in i
5. read i as tmp2
9. read i as tmp4
10. add tmp2 and tmp4, as tmp5
11. store tmp5 in x

为什么编译器可以这样做?因为增量的副作用没有先后顺序。但是现在编译器可以简化: 例如,在4中有一个死存储: 值立即被覆盖。而且,tmp2和 tmp4实际上是一回事。

1. store 1 in i
2. read i as tmp1
6. read i as tmp3
3. add 1 to tmp1
7. add 1 to tmp3
8. store tmp3 in i
5. read i as tmp2
10. add tmp2 and tmp2, as tmp5
11. store tmp5 in x

现在所有与 tmp1相关的代码都是死代码: 它从未被使用过。我的重读也可以被删除:

1. store 1 in i
6. read i as tmp3
7. add 1 to tmp3
8. store tmp3 in i
10. add tmp3 and tmp3, as tmp5
11. store tmp5 in x

听着,这个代码要短得多。优化器很高兴。程序员不是,因为我只增加了一次。哎呀。

让我们看看编译器可以做的其他事情: 让我们回到最初的版本。

1. store 1 in i
2. read i as tmp1
3. add 1 to tmp1
4. store tmp1 in i
5. read i as tmp2
6. read i as tmp3
7. add 1 to tmp3
8. store tmp3 in i
9. read i as tmp4
10. add tmp2 and tmp4, as tmp5
11. store tmp5 in x

编译器可以这样重新排序:

1. store 1 in i
2. read i as tmp1
3. add 1 to tmp1
4. store tmp1 in i
6. read i as tmp3
7. add 1 to tmp3
8. store tmp3 in i
5. read i as tmp2
9. read i as tmp4
10. add tmp2 and tmp4, as tmp5
11. store tmp5 in x

然后再次注意,i 被读取了两次,所以删除其中之一:

1. store 1 in i
2. read i as tmp1
3. add 1 to tmp1
4. store tmp1 in i
6. read i as tmp3
7. add 1 to tmp3
8. store tmp3 in i
5. read i as tmp2
10. add tmp2 and tmp2, as tmp5
11. store tmp5 in x

这很好,但是它可以更进一步: 它可以重用 tmp1:

1. store 1 in i
2. read i as tmp1
3. add 1 to tmp1
4. store tmp1 in i
6. read i as tmp1
7. add 1 to tmp1
8. store tmp1 in i
5. read i as tmp2
10. add tmp2 and tmp2, as tmp5
11. store tmp5 in x

然后它可以消除重读的 i 在6:

1. store 1 in i
2. read i as tmp1
3. add 1 to tmp1
4. store tmp1 in i
7. add 1 to tmp1
8. store tmp1 in i
5. read i as tmp2
10. add tmp2 and tmp2, as tmp5
11. store tmp5 in x

如今,4家店铺已经关门大吉:

1. store 1 in i
2. read i as tmp1
3. add 1 to tmp1
7. add 1 to tmp1
8. store tmp1 in i
5. read i as tmp2
10. add tmp2 and tmp2, as tmp5
11. store tmp5 in x

现在3和7可以合并成一条指令:

1. store 1 in i
2. read i as tmp1
3+7. add 2 to tmp1
8. store tmp1 in i
5. read i as tmp2
10. add tmp2 and tmp2, as tmp5
11. store tmp5 in x

消除最后一个临时因素:

1. store 1 in i
2. read i as tmp1
3+7. add 2 to tmp1
8. store tmp1 in i
10. add tmp1 and tmp1, as tmp5
11. store tmp5 in x

现在你得到了 Visual C + + 给你的结果。

注意,在这两种优化路径中,只要指令没有因为什么都不做而被删除,重要的顺序依赖关系就被保留。

我认为一个简单而直接的解释(没有任何编译器优化或多线程)将是公正的:

  1. 增量 i
  2. 增量 i
  3. 加入 i + i

随着 i增加两次,它的值是3,当加在一起时,总和是6。

为了便于检查,请把它看作一个 C + + 函数:

int dblInc ()
{
int i = 1;
int x = ++i + ++i;
return x;
}

现在,下面是我使用旧版本的 GNU C + + 编译器(win32,gcc version 3.4.2(mingw-special))编译该函数得到的汇编代码。这里没有花哨的优化或多线程:

__Z6dblIncv:
push    ebp
mov ebp, esp
sub esp, 8
mov DWORD PTR [ebp-4], 1
lea eax, [ebp-4]
inc DWORD PTR [eax]
lea eax, [ebp-4]
inc DWORD PTR [eax]
mov eax, DWORD PTR [ebp-4]
add eax, DWORD PTR [ebp-4]
mov DWORD PTR [ebp-8], eax
mov eax, DWORD PTR [ebp-8]
leave
ret

注意,局部变量 i仅位于堆栈的一个位置: address [ebp-4]。该位置增加了两次(在汇编函数的第5-8行; 包括该地址在 eax中的明显冗余加载)。然后在第9-10行,将该值加载到 eax中,然后添加到 eax中(即计算当前的 i + i)。然后,它被冗余地复制到堆栈,并作为返回值(显然是6)返回到 eax

看看 C + + 标准(这里是一个旧标准: ISO/IEC 14882:1998(E))可能会感兴趣,它对表达式说,第5.4节:

除特别说明外,计算个体操作数的顺序 运算符和各个表达式的子表达式,以及顺序 副作用发生在哪里,没有说明。

附注:

不直接指定运算符的优先级,但可以 来源于句法。

在这一点上给出了两个未指定行为的示例,它们都涉及增量操作符(其中之一是: i = ++i + 1)。

现在,如果你愿意的话,你可以: 创建一个整数包装类(就像 Java Integer 一样) ; 重载函数 operator+operator++,让它们返回中间值对象; 然后写入 ++iObj + ++iObj,让它返回一个包含5的对象。(为了简洁起见,我在这里没有包含完整的代码。)

就我个人而言,我很好奇是否有一个著名的编译器的例子,除了上面看到的序列以外,还有其他任何方式来完成这项工作。在我看来,最直接的实现就是在执行加法操作之前对原语类型执行两个汇编代码 inc

编译器无法通过 合情合理得到6的结果,但它是可能的并且是合法的。4的结果是完全合理的,我认为5的结果是合理的。他们都是完全合法的。

嘿,等等!难道还不清楚接下来会发生什么吗?加法需要两个增量的结果,因此 很明显必须首先发生。我们从左到右,所以... 啊!如果这么简单就好了。不幸的是,事实并非如此。我们做 没有从左到右,这就是问题所在。

将内存位置读入两个寄存器(或者从相同的文字初始化它们,优化到内存的往返过程)是编译器要做的一件非常合理的事情。这将有效地产生两个 与众不同变量的效果,每个变量的值为2,最终将被添加到4的结果中。这是“合理的”,因为它的快速和高效,它是符合 都有的标准和代码。

类似地,内存位置可以读取一次(或者从文本初始化的变量)并递增一次,在此之后,另一个寄存器中的影子副本可以递增,这将导致2和3被加在一起。这是,我会说,临界点合理,虽然完全合法。我认为这是合理的,因为它不是一个或另一个。它既不是“合理的”优化方式,也不是“合理的”确切的迂腐方式。有点在中间。

将内存位置增加两次(结果值为3) ,然后将该值添加到自身,最终结果为6,这是合理的,但并不完全合理,因为进行内存往返旅行并不完全有效。虽然在具有良好存储转发功能的处理器上,这样做也许是“合理的”,因为存储应该基本上是不可见的..。
因为编译器“知道”它是相同的位置,所以它也可以选择在寄存器中将值增加两次,然后将它也添加到自身中。无论哪种方法,结果都是6。

根据标准的措辞,编译器允许给你任何这样的结果,虽然我个人认为6几乎是来自讨厌部门的“去你妈的”备忘录,因为它是一个相当意外的事情(不管合法与否,总是试图给出最少的惊喜是一件好事.不过,看看未定义行为是如何牵扯进来的,很遗憾,人们不能真正争论什么是“意外”,嗯。

那么,实际上,编译器的代码是什么?让我们询问 clang,它将显示我们是否友好地询问(使用 -ast-dump -fsyntax-only调用) :

ast.cpp:4:9: warning: multiple unsequenced modifications to 'i' [-Wunsequenced]
int x = ++i + ++i;
^     ~~
(some lines omitted)
`-CompoundStmt 0x2b3e628 <line:2:1, line:5:1>
|-DeclStmt 0x2b3e4b8 <line:3:1, col:10>
| `-VarDecl 0x2b3e430 <col:1, col:9> col:5 used i 'int' cinit
|   `-IntegerLiteral 0x2b3e498 <col:9> 'int' 1
`-DeclStmt 0x2b3e610 <line:4:1, col:18>
`-VarDecl 0x2b3e4e8 <col:1, col:17> col:5 x 'int' cinit
`-BinaryOperator 0x2b3e5f0 <col:9, col:17> 'int' '+'
|-ImplicitCastExpr 0x2b3e5c0 <col:9, col:11> 'int' <LValueToRValue>
| `-UnaryOperator 0x2b3e570 <col:9, col:11> 'int' lvalue prefix '++'
|   `-DeclRefExpr 0x2b3e550 <col:11> 'int' lvalue Var 0x2b3e430 'i' 'int'
`-ImplicitCastExpr 0x2b3e5d8 <col:15, col:17> 'int' <LValueToRValue>
`-UnaryOperator 0x2b3e5a8 <col:15, col:17> 'int' lvalue prefix '++'
`-DeclRefExpr 0x2b3e588 <col:17> 'int' lvalue Var 0x2b3e430 'i' 'int'

可以看到,相同的 lvalue Var 0x2b3e430在两个位置应用了前缀 ++,这两个位置在树中的同一个节点的下面,这碰巧是一个非常非特殊的操作符(+) ,对于排序或者其他没有什么特殊的说法。这有什么重要的?继续读。

注意警告: “对‘ i’进行多次无序修改”。听起来不妙。这是什么意思?[ basic.exec ]告诉我们关于副作用和顺序,它告诉我们(第10段) ,在默认情况下,除非明确说明不同,对各个运算符的操作数和各个表达式的子表达式的计算是无序的。可恶,operator+就是这种情况没有其他说法,所以..。

但是,我们关心排序之前,不确定排序,或未排序? 谁想知道,无论如何?

同一段还告诉我们,无序计算 可能会重叠和当它们引用相同的内存位置时(就是这种情况!)如果没有潜在的并发性,那么行为就是未定义的。这就是事情真正变得丑陋的地方,因为这意味着你什么都不知道,而且你不能保证自己是“合理的”。不合理的事情实际上是完全允许的,是“合理的”。

实际上,你是在调用未定义的行为。任何事情都可能发生,不仅仅是你认为“合理”的事情,而且经常发生你认为不合理的事情。从定义上来说,一切都是“合理的”。

一个非常合理的编译是,编译器注意到执行一个语句将调用未定义的行为,因此该语句永远不能被执行,因此它被转换为一条有意使应用程序崩溃的指令。这很合理。毕竟,编译器知道这种崩溃永远不会发生。

实际上,你是在援引未定义行为。任何事情都可能发生,不仅仅是你认为“合理”的事情,而且经常发生你认为不合理的事情。从定义上来说,一切都是“合理的”。

一个非常合理的编译是,编译器注意到执行一个语句将调用未定义行为,因此该语句不能被执行,因此它被转换为一个故意使应用程序崩溃的指令。这很合理。

海湾合作委员会强烈反对你的观点。

有一个 规则:

在上一个序列点和下一个序列点之间,标量对象必须具有 其存储值最多通过计算 表达式,否则行为是未定义的。

因此,即使 x = 100也是一个可能的有效结果。

对于我来说,这个例子中最符合逻辑的结果是6,因为我们将 i 的值增加了两倍,然后他们把它自己加上去。在“ +”两边的计算值之前很难做加法运算。

但是编译器开发人员可以实现任何其他逻辑。

我个人从来没有期望编译器在您的示例中输出6。你的问题已经有了很好的详细的答案。我就长话短说吧。

基本上,在这种情况下,++i是一个分为两个步骤的过程:

  1. 增加 i的值
  2. 读取 i的值

++i + ++i的上下文中,可以根据标准以任意顺序对加法的两侧进行评价。这意味着这两个增量被认为是独立的。此外,这两个术语之间没有依赖关系。因此,i的增量和读取可以交错进行。这给出了潜在的顺序:

  1. 增加左操作数的 i
  2. 增加右操作数的 i
  3. 读回左操作数的 i
  4. 为正确的操作数返回 i
  5. 两者相加得到6

现在,我想了一下,根据标准,6是最有意义的。对于4的结果,我们需要一个 CPU,它首先独立地读取 i,然后递增并将值写回相同的位置; 这基本上是一个竞争条件。对于值5,我们需要一个引入临时值的编译器。

但是,标准规定 ++i在返回变量之前(即在实际执行当前代码行之前)递增该变量。求和运算符 +在应用增量之后需要求和 i + i。我认为 C + + 需要处理变量而不是值语义。因此,对我来说,6现在最有意义,因为它依赖于语言的语义,而不是 CPU 的执行模型。

#include <stdio.h>




void a1(void)
{
int i = 1;
int x = ++i;
printf("i=%d\n",i);
printf("x=%d\n",x);
x = x + ++i;    // Here
printf("i=%d\n",i);
printf("x=%d\n",x);
}




void b2(void)
{
int i = 1;
int x = ++i;
printf("i=%d\n",i);
printf("x=%d\n",x);
x = i + ++i;    // Here
printf("i=%d\n",i);
printf("x=%d\n",x);
}




void main(void)
{
a1();
// b2();
}

这取决于编译器的设计。因此,答案将取决于编译器解码语句的方式。使用两个不同的变量 + + x 和 + + y 创造一个逻辑会是一个更好的选择。 注意: 输出取决于最新版本的语言在 ms 视觉工作室如果它更新。因此,如果规则已经改变,那么将输出

试试这个

int i = 1;
int i1 = i, i2 = i;   // i1 = i2 = 1
++i1;                 // i1 = 2
++i2;                 // i2 = 2
int x = i1 + i2;      // x = 4

从这个链接 评估次序评估次序:

任何 C 运算符操作数的求值顺序,包括 函数调用中函数参数求值的顺序 中子表达式的求值顺序 任何表达式都是未指定的(除非在下面指出)。 < strong > 编译器 将评估他们在任何顺序,并可以选择另一个顺序时, 再次计算同一表达式 .

从这些引文中可以清楚地看出,评价的顺序没有被 C 标准明确规定。不同的编译器实现不同的求值顺序。编译器可以按任意顺序计算这些表达式。这就是为什么不同的编译器为问题中提到的表达式提供不同的输出。

但是,如果在子表达式 Exp1和 Exp2之间存在 序列点,那么 Exp1的值计算和副作用都会被排序——排在 Exp2的每个值计算和副作用之前。

我很感激你想要一个明确的答案。我很欣赏你不想要一个参考,或一些重述,另一个问题。但大多数时候说到未定义行为。真的。唯一有用的答案就是“别那么做”

我理解你的好奇心。当你得到一个令人惊讶的结果时,你会自然而然地想知道这个结果是如何产生的。但是现在,优化编译器已经够复杂的了,你从一个给定的未定义行为实例中得到的结果可能是随机的,也可能是无法解释的,毕竟并不是那么有趣。

假设你高速驾驶汽车行驶在路上,然后闭上眼睛,双手离开方向盘,油门踩到底。一个预期的结果是,你漂移到道路的右侧和崩溃。一个预期的结果是,你漂移到左侧的道路和崩溃。一个 预期的结果是,你的汽车以某种方式飞越空气,并最终 卡在一个巨大的路边甜甜圈里

最后一个结果当然令人惊讶。这更有可能成为晚间新闻。但是什么样的特定因素组合使得这种情况发生呢?不管是什么,这都是一个极其罕见的机会,取决于几乎不可能确定的随机因素。

在极端情况下,试图解开灾难性意外结果背后的机制可能是有意义的。例如,100年前,当火车相撞时,有时乘客受伤但走开了,有时大量乘客死亡。人们最终注意到,木制汽车在一次碰撞中严重解体,油灯往往会把残骸点着。知道了这一点,人们就有动力用钢铁而不是木头制造火车车厢,并用电灯取代煤气灯,只是为了在发生碰撞时更安全。

同样的道理,如果你试图利用你想要破坏的程序中的某些未定义行为,或者如果你想让代码不那么容易被利用,那么弄清楚在什么样的编译器和其他情况下,++i + ++i是如何把1变成6的可能性是有用的。但是如果你是一个普通的 C 程序员,只是尝试编写能够工作的代码,不要担心。

就像那个老笑话说的,“医生,医生,我这样做的时候很痛!”不要在你的 C 程序中写 ++i + ++i。如果有人告诉你它可能会导致,不仅意外的结果是2或4,而且可能是更意外的结果是6,这并不会真正改变什么: 你仍然不想写它。如果你想知道你是怎么得到6的,并且你找到了一个美味而晦涩的原因,这也不会改变任何事情: 你仍然不想写它。如果你想知道到底是怎么得到6的,但是原因是如此的模糊以至于你无法发现它,那么 还是不会改变任何东西,因为你不想写它。