实际上,为什么不同的编译器会计算不同的 int x = ++i + ++i; 值?
In practice, why would different compilers compute different values of int x = ++i + ++i;?
考虑这段代码:
int i = 1;
int x = ++i + ++i;
我们猜测编译器可能会为这段代码做些什么,假设它编译。
- 两个
++i
return 2
,导致 x=4
.
- 一个
++i
returns 2
和另一个 returns 3
,结果是 x=5
.
- 两个
++i
return 3
,导致 x=6
.
对我来说,第二种可能性最大。两个 ++
运算符之一用 i = 1
执行,i
递增,结果 2
为 returned。然后用 i = 2
执行第二个 ++
运算符,i
递增,结果 3
为 returned。然后 2
和 3
相加得到 5
.
然而,我运行这段代码在Visual Studio,结果是6
。我试图更好地理解编译器,我想知道什么可能导致 6
的结果。我唯一的猜测是代码可以以某种 "built-in" 并发执行。调用了两个 ++
运算符,每个运算符在另一个 returned 之前递增 i
,然后它们都 returned 3
。这将与我对调用堆栈的理解相矛盾,需要加以解释。
C++
编译器可以做哪些(合理的)事情会导致结果 4
或结果或 6
?
备注
此示例作为未定义行为的示例出现在 Bjarne Stroustrup 的编程:使用 C++ (C++ 14) 的原理和实践中。
参见 。
虽然这是 UB(如 OP 所暗示的),但以下是编译器可以获得 3 个结果的假设方式。如果与不同的 int i = 1, j = 1;
变量而不是一个相同的 i
.
一起使用,所有这三个都会给出相同的正确 x
结果
- both ++i return 2, resulting in 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
- one ++i returns 2 and the other returns 3, resulting in x=5.
int i = 1;
int i1 = ++i; // i1 = 2
int i2 = ++i; // i2 = 3
int x = i1 + i2; // x = 5
- both ++i return 3, resulting in x=6.
int i = 1;
int &i1 = i, &i2 = i;
++i1; // i = 2
++i2; // i = 3
int x = i1 + i2; // x = 6
To me, the second seems most likely.
我选择选项 #4:两者 ++i
同时发生。
较新的处理器转向一些有趣的优化和并行代码评估,在此处允许的情况下,是编译器不断生成更快代码的另一种方式。我认为这是一个 实用的实现 ,编译器正在向并行化发展。
我很容易看到由于相同的内存争用而导致不确定行为或总线故障的竞争条件 - 所有这些都是允许的,因为编码器违反了 C++ 合同 - 因此是 UB。
My question is: what (reasonable) things could a C++ compiler do that would lead to a result of 4 or a result or 6?
可以可以,但不算在内。
不要使用 ++i + ++i
也不要期望得到合理的结果。
看起来 ++i returns 是一个左值,但 i++ returns 是一个右值。
所以这段代码没问题:
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 添加到 i。
我注意到 CLang 和 Embarcadero 的行为不同。所以它与第一个语句不一致,在第一个 ++i 之后将结果存储在一个右值中,然后将其添加到第二个 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 相关的一切都是死代码:它从未被使用过。而且i的重读也可以去掉:
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
那么就可以消除6中i的重读:
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++ 给你的结果。
请注意,在两个优化路径中,重要的顺序相关性都得到了保留,只要指令没有因无所事事而被删除。
我认为一个简单直接的解释(没有任何编译器优化或多线程)就是:
- 递增
i
- 递增
i
- 添加
i
+ i
将i
递增两次,其值为3,加在一起,总和为6。
为了检查,将其视为 C++ 函数:
int dblInc ()
{
int i = 1;
int x = ++i + ++i;
return x;
}
现在,这是我使用旧版本的 GNU C++ 编译器(win32,gcc 版本 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
仅位于堆栈中的一个位置:地址 [ebp-4]
。该位置增加了两次(在汇编函数的第 5-8 行;包括将该地址明显冗余加载到 eax
中)。然后在第 9-10 行,该值被加载到 eax
,然后添加到 eax
(即计算当前 i + i
)。然后它被冗余复制到堆栈并返回到 eax
作为 return 值(显然是 6)。
查看 C++ 标准(这里是一个旧标准:ISO/IEC 14882:1998(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.
加上脚注:
The precedence of operators is not directly specified, but it can be
derived from the syntax.
此时给出了两个未指定行为的示例,都涉及增量运算符(其中一个是:i = ++i + 1
)。
现在,如果愿意,可以:制作一个整数包装器 class(如 Java 整数);重载函数 operator+
和 operator++
使得它们 return 中间值对象;从而写入 ++iObj + ++iObj
并将其获取到 return 一个包含 5 的对象。(为了简洁起见,我没有在此处包含完整代码。)
就我个人而言,如果有一个著名的编译器示例可以以不同于上述序列的方式完成这项工作,我会很感兴趣。在我看来,最直接的实现方式是在执行加法操作之前对基本类型执行两个汇编代码 inc
。
编译器没有合理的事情来获得结果 6,但这是可能且合法的。 4 的结果是完全合理的,我认为 5 分界线的结果是合理的。所有这些都是完全合法的。
嘿,等等!还不清楚必须发生什么吗?加法需要两个增量的结果,所以 显然 这些必须先发生。我们从左到右,所以... argh! 要是这么简单就好了。不幸的是,情况并非如此。我们不是从左到右,这就是问题所在。
将内存位置读入两个寄存器(或从同一个文字初始化它们,优化内存往返)对于编译器来说是一件非常合理的事情。这将有效地产生两个 不同 变量的效果,每个变量的值为 2,最终将添加到结果 4。这是 "reasonable" 因为它快速高效,并且符合标准和代码。
类似地,可以读取一次内存位置(或从文字初始化的变量)并递增一次,然后可以递增另一个寄存器中的卷影副本,这将导致 2 和 3 加在一起.这是,我会说,borderline 合理,虽然完全合法。我认为这是合理的边界,因为它不是其中之一。它既不是 "reasonable" 优化的方式,也不是 "reasonable" 完全迂腐的方式。有点在中间。
将内存位置递增两次(结果值为 3),然后将该值与自身相加以获得最终结果 6 是合法的,但不太合理,因为进行内存往返并不十分有效。尽管在具有良好存储转发功能的处理器上,也可能 "reasonable" 这样做,因为存储应该大部分是不可见的...
由于编译器 "knows" 它是相同的位置,它不妨选择在一个寄存器内将值递增两次,然后再将其也添加到自身。任何一种方法都会给你结果 6.
根据标准的措辞,编译器允许给你任何这样的结果,尽管我个人认为 6 几乎是来自令人讨厌的部门的 "fuck you" 备忘录,因为它是一个相当出乎意料的结果事情(合法与否,总是尽量减少惊喜是一件好事!)。虽然,看到如何涉及未定义的行为,遗憾的是人们不能真正争论 "unexpected",嗯。
那么,实际上,对于编译器而言,您那里的代码是什么?让我们问一下 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
在两个位置应用了前缀++
,这两个在树中的同一个节点下,恰好是一个很不特殊的节点运算符 (+),关于排序等没有什么特别之处。为什么这很重要?好吧,请继续阅读。
注意警告:"multiple unsequenced modifications to 'i'"。哦哦,这听起来不太好。这是什么意思? [basic.exec] 告诉我们副作用和排序,它告诉我们(第 10 段)默认情况下,除非另有明确说明,单个运算符的操作数和单个表达式的子表达式的计算是未排序的。好吧,该死,operator+
就是这种情况——没有别的说法,所以...
但是我们关心先序后序、不确定序后序还是未序序吗?无论如何,谁想知道?
同一段还告诉我们,未排序的计算 可能会重叠 并且当它们引用相同的内存位置时(就是这种情况!)并且那个计算可能不是并发的,那么行为是未定义的。这是它真正变得丑陋的地方,因为这意味着你什么都不知道,而且你无法保证成为 "reasonable"。不合理的事情其实是完全可以接受的"reasonable".
实际上,您正在调用未定义的行为。任何事情都可能发生,不仅仅是您认为 "reasonable" 的事情,而且经常发生 做 您认为不合理的事情。一切都符合定义 "reasonable"。
一个非常合理的编译是编译器观察到执行一条语句会调用未定义的行为,因此该语句永远不会被执行,因此它被翻译成一条故意使您的应用程序崩溃的指令。这是非常有道理的。毕竟,编译器知道这种崩溃永远不会发生。
实际上,您正在调用未定义的行为。任何事情都可能发生,不仅仅是您认为“合理”的事情,而且经常发生 do 您认为不合理的事情。根据定义,一切都是“合理的”。
一个非常合理的编译是,编译器观察到执行一条语句会调用未定义的行为,因此该语句无法执行,因此它被翻译成一条故意使您的应用程序崩溃的指令。很有道理。
反对者:GCC 强烈反对你。
有一个rule:
Between the previous and next sequence point a scalar object must have
its stored value modified at most once by the evaluation of an
expression, otherwise the behavior is undefined.
因此即使 x = 100 也是可能的有效结果。
对我来说,这个例子中最合乎逻辑的结果是 6,因为我们将 i 的值增加了两次,然后它们将它加到自身上。在“+”两边的计算值之前做加法比较困难。
但编译器开发人员可以实现任何其他逻辑。
我个人绝不会期望编译器在您的示例中输出 6。您的问题已经有了很好且详细的答案。我会尝试一个简短的版本。
基本上,++i
在这种情况下是一个两步过程:
- 增加
i
的值
- 读取
i
的值
在++i + ++i
的上下文中,可以根据标准以任何顺序评估加法的两侧。这意味着这两个增量被认为是独立的。此外,这两个术语之间没有依赖关系。因此,i
的递增和读取可能会交错。这给出了潜在的顺序:
- 左操作数增加
i
- 右操作数增加
i
- 为左操作数回读
i
- 为右操作数回读
i
- 将两者相加:得到 6
现在,我考虑一下,根据标准,6 最合理。对于 4 的结果,我们需要一个 CPU,它首先独立读取 i
,然后递增并将值写回到相同的位置;基本上是竞争条件。对于 5 的值,我们需要一个引入临时变量的编译器。
但是,标准说 ++i
在返回变量之前递增变量,即在实际执行当前代码行之前。求和运算符 +
需要在应用增量后对 i + i
求和。我会说 C++ 需要处理变量而不是值语义。因此,对我来说 6 现在最有意义,因为它依赖于语言的语义而不是 CPUs.
的执行模型
#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();
}
好吧,这取决于 compiler.Therefore 的设计,答案将取决于编译器解码 statements.Using 两个不同变量 ++x 和 ++y 的方式
创建逻辑将是更好的选择。
note:the 输出取决于 ms 中最新版本的语言版本 visual studio 如果它 updated.So 如果规则已经改变那么输出
试试这个
int i = 1;
int i1 = i, i2 = i; // i1 = i2 = 1
++i1; // i1 = 2
++i2; // i2 = 2
int x = i1 + i2; // x = 4
由此link order of evaluation :
Order of evaluation of the operands of any C operator, including the
order of evaluation of function arguments in a function-call
expression, and the order of evaluation of the subexpressions within
any expression is unspecified (except where noted below). The compiler
will evaluate them in any order, and may choose another order when the
same expression is evaluated again.
从引用中可以明显看出,C 标准未指定求值顺序。不同的编译器执行不同的求值顺序。编译器可以自由地以任何顺序评估此类表达式。这就是为什么不同的编译器为问题中提到的表达式提供不同的输出。
但是,如果 sequence point 出现在子表达式 Exp1 和 Exp2 之间,则 Exp1 的值计算和副作用都排在 Exp2 的每个值计算和副作用之前。
考虑这段代码:
int i = 1;
int x = ++i + ++i;
我们猜测编译器可能会为这段代码做些什么,假设它编译。
- 两个
++i
return2
,导致x=4
. - 一个
++i
returns2
和另一个 returns3
,结果是x=5
. - 两个
++i
return3
,导致x=6
.
对我来说,第二种可能性最大。两个 ++
运算符之一用 i = 1
执行,i
递增,结果 2
为 returned。然后用 i = 2
执行第二个 ++
运算符,i
递增,结果 3
为 returned。然后 2
和 3
相加得到 5
.
然而,我运行这段代码在Visual Studio,结果是6
。我试图更好地理解编译器,我想知道什么可能导致 6
的结果。我唯一的猜测是代码可以以某种 "built-in" 并发执行。调用了两个 ++
运算符,每个运算符在另一个 returned 之前递增 i
,然后它们都 returned 3
。这将与我对调用堆栈的理解相矛盾,需要加以解释。
C++
编译器可以做哪些(合理的)事情会导致结果 4
或结果或 6
?
备注
此示例作为未定义行为的示例出现在 Bjarne Stroustrup 的编程:使用 C++ (C++ 14) 的原理和实践中。
参见
虽然这是 UB(如 OP 所暗示的),但以下是编译器可以获得 3 个结果的假设方式。如果与不同的 int i = 1, j = 1;
变量而不是一个相同的 i
.
x
结果
- both ++i return 2, resulting in 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
- one ++i returns 2 and the other returns 3, resulting in x=5.
int i = 1;
int i1 = ++i; // i1 = 2
int i2 = ++i; // i2 = 3
int x = i1 + i2; // x = 5
- both ++i return 3, resulting in x=6.
int i = 1;
int &i1 = i, &i2 = i;
++i1; // i = 2
++i2; // i = 3
int x = i1 + i2; // x = 6
To me, the second seems most likely.
我选择选项 #4:两者 ++i
同时发生。
较新的处理器转向一些有趣的优化和并行代码评估,在此处允许的情况下,是编译器不断生成更快代码的另一种方式。我认为这是一个 实用的实现 ,编译器正在向并行化发展。
我很容易看到由于相同的内存争用而导致不确定行为或总线故障的竞争条件 - 所有这些都是允许的,因为编码器违反了 C++ 合同 - 因此是 UB。
My question is: what (reasonable) things could a C++ compiler do that would lead to a result of 4 or a result or 6?
可以可以,但不算在内。
不要使用 ++i + ++i
也不要期望得到合理的结果。
看起来 ++i returns 是一个左值,但 i++ returns 是一个右值。
所以这段代码没问题:
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 添加到 i。
我注意到 CLang 和 Embarcadero 的行为不同。所以它与第一个语句不一致,在第一个 ++i 之后将结果存储在一个右值中,然后将其添加到第二个 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 相关的一切都是死代码:它从未被使用过。而且i的重读也可以去掉:
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
那么就可以消除6中i的重读:
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++ 给你的结果。
请注意,在两个优化路径中,重要的顺序相关性都得到了保留,只要指令没有因无所事事而被删除。
我认为一个简单直接的解释(没有任何编译器优化或多线程)就是:
- 递增
i
- 递增
i
- 添加
i
+i
将i
递增两次,其值为3,加在一起,总和为6。
为了检查,将其视为 C++ 函数:
int dblInc ()
{
int i = 1;
int x = ++i + ++i;
return x;
}
现在,这是我使用旧版本的 GNU C++ 编译器(win32,gcc 版本 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
仅位于堆栈中的一个位置:地址 [ebp-4]
。该位置增加了两次(在汇编函数的第 5-8 行;包括将该地址明显冗余加载到 eax
中)。然后在第 9-10 行,该值被加载到 eax
,然后添加到 eax
(即计算当前 i + i
)。然后它被冗余复制到堆栈并返回到 eax
作为 return 值(显然是 6)。
查看 C++ 标准(这里是一个旧标准:ISO/IEC 14882:1998(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.
加上脚注:
The precedence of operators is not directly specified, but it can be derived from the syntax.
此时给出了两个未指定行为的示例,都涉及增量运算符(其中一个是:i = ++i + 1
)。
现在,如果愿意,可以:制作一个整数包装器 class(如 Java 整数);重载函数 operator+
和 operator++
使得它们 return 中间值对象;从而写入 ++iObj + ++iObj
并将其获取到 return 一个包含 5 的对象。(为了简洁起见,我没有在此处包含完整代码。)
就我个人而言,如果有一个著名的编译器示例可以以不同于上述序列的方式完成这项工作,我会很感兴趣。在我看来,最直接的实现方式是在执行加法操作之前对基本类型执行两个汇编代码 inc
。
编译器没有合理的事情来获得结果 6,但这是可能且合法的。 4 的结果是完全合理的,我认为 5 分界线的结果是合理的。所有这些都是完全合法的。
嘿,等等!还不清楚必须发生什么吗?加法需要两个增量的结果,所以 显然 这些必须先发生。我们从左到右,所以... argh! 要是这么简单就好了。不幸的是,情况并非如此。我们不是从左到右,这就是问题所在。
将内存位置读入两个寄存器(或从同一个文字初始化它们,优化内存往返)对于编译器来说是一件非常合理的事情。这将有效地产生两个 不同 变量的效果,每个变量的值为 2,最终将添加到结果 4。这是 "reasonable" 因为它快速高效,并且符合标准和代码。
类似地,可以读取一次内存位置(或从文字初始化的变量)并递增一次,然后可以递增另一个寄存器中的卷影副本,这将导致 2 和 3 加在一起.这是,我会说,borderline 合理,虽然完全合法。我认为这是合理的边界,因为它不是其中之一。它既不是 "reasonable" 优化的方式,也不是 "reasonable" 完全迂腐的方式。有点在中间。
将内存位置递增两次(结果值为 3),然后将该值与自身相加以获得最终结果 6 是合法的,但不太合理,因为进行内存往返并不十分有效。尽管在具有良好存储转发功能的处理器上,也可能 "reasonable" 这样做,因为存储应该大部分是不可见的...
由于编译器 "knows" 它是相同的位置,它不妨选择在一个寄存器内将值递增两次,然后再将其也添加到自身。任何一种方法都会给你结果 6.
根据标准的措辞,编译器允许给你任何这样的结果,尽管我个人认为 6 几乎是来自令人讨厌的部门的 "fuck you" 备忘录,因为它是一个相当出乎意料的结果事情(合法与否,总是尽量减少惊喜是一件好事!)。虽然,看到如何涉及未定义的行为,遗憾的是人们不能真正争论 "unexpected",嗯。
那么,实际上,对于编译器而言,您那里的代码是什么?让我们问一下 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
在两个位置应用了前缀++
,这两个在树中的同一个节点下,恰好是一个很不特殊的节点运算符 (+),关于排序等没有什么特别之处。为什么这很重要?好吧,请继续阅读。
注意警告:"multiple unsequenced modifications to 'i'"。哦哦,这听起来不太好。这是什么意思? [basic.exec] 告诉我们副作用和排序,它告诉我们(第 10 段)默认情况下,除非另有明确说明,单个运算符的操作数和单个表达式的子表达式的计算是未排序的。好吧,该死,operator+
就是这种情况——没有别的说法,所以...
但是我们关心先序后序、不确定序后序还是未序序吗?无论如何,谁想知道?
同一段还告诉我们,未排序的计算 可能会重叠 并且当它们引用相同的内存位置时(就是这种情况!)并且那个计算可能不是并发的,那么行为是未定义的。这是它真正变得丑陋的地方,因为这意味着你什么都不知道,而且你无法保证成为 "reasonable"。不合理的事情其实是完全可以接受的"reasonable".
实际上,您正在调用未定义的行为。任何事情都可能发生,不仅仅是您认为 "reasonable" 的事情,而且经常发生 做 您认为不合理的事情。一切都符合定义 "reasonable"。
一个非常合理的编译是编译器观察到执行一条语句会调用未定义的行为,因此该语句永远不会被执行,因此它被翻译成一条故意使您的应用程序崩溃的指令。这是非常有道理的。毕竟,编译器知道这种崩溃永远不会发生。
实际上,您正在调用未定义的行为。任何事情都可能发生,不仅仅是您认为“合理”的事情,而且经常发生 do 您认为不合理的事情。根据定义,一切都是“合理的”。
一个非常合理的编译是,编译器观察到执行一条语句会调用未定义的行为,因此该语句无法执行,因此它被翻译成一条故意使您的应用程序崩溃的指令。很有道理。
反对者:GCC 强烈反对你。
有一个rule:
Between the previous and next sequence point a scalar object must have its stored value modified at most once by the evaluation of an expression, otherwise the behavior is undefined.
因此即使 x = 100 也是可能的有效结果。
对我来说,这个例子中最合乎逻辑的结果是 6,因为我们将 i 的值增加了两次,然后它们将它加到自身上。在“+”两边的计算值之前做加法比较困难。
但编译器开发人员可以实现任何其他逻辑。
我个人绝不会期望编译器在您的示例中输出 6。您的问题已经有了很好且详细的答案。我会尝试一个简短的版本。
基本上,++i
在这种情况下是一个两步过程:
- 增加
i
的值
- 读取
i
的值
在++i + ++i
的上下文中,可以根据标准以任何顺序评估加法的两侧。这意味着这两个增量被认为是独立的。此外,这两个术语之间没有依赖关系。因此,i
的递增和读取可能会交错。这给出了潜在的顺序:
- 左操作数增加
i
- 右操作数增加
i
- 为左操作数回读
i
- 为右操作数回读
i
- 将两者相加:得到 6
现在,我考虑一下,根据标准,6 最合理。对于 4 的结果,我们需要一个 CPU,它首先独立读取 i
,然后递增并将值写回到相同的位置;基本上是竞争条件。对于 5 的值,我们需要一个引入临时变量的编译器。
但是,标准说 ++i
在返回变量之前递增变量,即在实际执行当前代码行之前。求和运算符 +
需要在应用增量后对 i + i
求和。我会说 C++ 需要处理变量而不是值语义。因此,对我来说 6 现在最有意义,因为它依赖于语言的语义而不是 CPUs.
#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();
}
好吧,这取决于 compiler.Therefore 的设计,答案将取决于编译器解码 statements.Using 两个不同变量 ++x 和 ++y 的方式 创建逻辑将是更好的选择。 note:the 输出取决于 ms 中最新版本的语言版本 visual studio 如果它 updated.So 如果规则已经改变那么输出
试试这个
int i = 1;
int i1 = i, i2 = i; // i1 = i2 = 1
++i1; // i1 = 2
++i2; // i2 = 2
int x = i1 + i2; // x = 4
由此link order of evaluation :
Order of evaluation of the operands of any C operator, including the order of evaluation of function arguments in a function-call expression, and the order of evaluation of the subexpressions within any expression is unspecified (except where noted below). The compiler will evaluate them in any order, and may choose another order when the same expression is evaluated again.
从引用中可以明显看出,C 标准未指定求值顺序。不同的编译器执行不同的求值顺序。编译器可以自由地以任何顺序评估此类表达式。这就是为什么不同的编译器为问题中提到的表达式提供不同的输出。
但是,如果 sequence point 出现在子表达式 Exp1 和 Exp2 之间,则 Exp1 的值计算和副作用都排在 Exp2 的每个值计算和副作用之前。