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

cin*_*mon 162 c++ undefined-behavior

考虑这个代码:

int i = 1;
int x = ++i + ++i;
Run Code Online (Sandbox Code Playgroud)

我们对编译器可能会为这段代码做些什么有一些猜测,假设它可以编译。

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

对我来说,第二个似乎最有可能。两个++运算符之一用 执行i = 1i递增,并2返回结果。然后用++执行第二个运算符i = 2i递增,并3返回结果。然后23相加得到5

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

C++编译器可以做什么(合理的)事情来导致结果4或结果或6

笔记

此示例在 Bjarne Stroustrup 的编程:使用 C++ 的原则和实践 (C++ 14) 中作为未定义行为的示例出现。

肉桂的评论

Seb*_*edl 199

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

编码

int i = 1;
int x = ++i + ++i;
Run Code Online (Sandbox Code Playgroud)

由以下指令组成:

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
Run Code Online (Sandbox Code Playgroud)

但是尽管这是我写的一个编号列表,这里只有几个排序依赖项: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
Run Code Online (Sandbox Code Playgroud)

为什么编译器可以这样做?因为增量的副作用没有排序。但是现在编译器可以简化:例如,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
Run Code Online (Sandbox Code Playgroud)

现在与 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
Run Code Online (Sandbox Code Playgroud)

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

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

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
Run Code Online (Sandbox Code Playgroud)

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

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
Run Code Online (Sandbox Code Playgroud)

然后再次注意到 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
Run Code Online (Sandbox Code Playgroud)

这很好,但它可以更进一步:它可以重用 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
Run Code Online (Sandbox Code Playgroud)

那么它可以消除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
Run Code Online (Sandbox Code Playgroud)

现在 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
Run Code Online (Sandbox Code Playgroud)

现在 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
Run Code Online (Sandbox Code Playgroud)

消除最后一个临时:

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
Run Code Online (Sandbox Code Playgroud)

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

请注意,在两个优化路径中,重要的顺序相关性都被保留了下来,因为指令不会因为什么都不做而被删除。

  • 目前,这是唯一提到[排序](https://en.wikipedia.org/wiki/Sequence_point#Sequence_points_in_C_and_C++)的答案。 (36认同)
  • @philipxy“该标准对目标代码没有任何规定。” 该标准也没有提及此代码片段的行为 - 它是 UB。这是这个问题的一个前提。OP 想知道为什么在实践中编译器会得出不同且奇怪的结果。另外,我的回答甚至没有提到任何有关目标代码的内容。 (6认同)
  • @philipxy 我不明白你的反对意见。如前所述,问题是编译器在 UB 存在的情况下可能会做什么,而不是 C++ 标准。在探索假设的编译器如何转换代码时,为什么使用目标代码是不合适的?事实上,“除了*目标代码之外的任何东西”如何相关? (5认同)
  • -1 我认为这个答案并没有澄清。观察到的结果根本不依赖于任何编译器优化(请参阅我的答案)。 (3认同)
  • 这假设进行读-修改-写操作。某些 CPU(例如无处不在的 x86)具有原子增量操作,这使情况变得更加复杂。 (3认同)
  • @HagenvonEitzen 理论上是的。在实践中,据我所知,没有编译器可以自动执行多线程处理,因为复杂性很高,而且分配工作的开销很大,以至于优化器通常处理的小代码块不值得。 (2认同)

dxi*_*xiv 57

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

  1. ++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
Run Code Online (Sandbox Code Playgroud)
  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
Run Code Online (Sandbox Code Playgroud)
  1. ++i 都返回 3,导致 x=6。
int i = 1;
int &i1 = i, &i2 = i;
++i1;                   // i = 2
++i2;                   // i = 3
int x = i1 + i2;        // x = 6
Run Code Online (Sandbox Code Playgroud)

  • 这是比我希望的更好的答复,谢谢。 (2认同)

chu*_*ica 20

对我来说,第二个似乎最有可能。

我要选择#4:两者++i同时发生。

较新的处理器转向一些有趣的优化和并行代码评估,这里允许,这是编译器不断制作更快代码的另一种方式。我认为编译器朝着并行化方向发展是一个实际的实现

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

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

可以,但不要计入其中。

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

  • @UriRaz:处理器甚至可能没有注意到存在数据危险,具体取决于编译器的选择。例如,编译器可能将“i”分配给两个寄存器,递增这两个寄存器,然后将它们都写回。处理器没有办法解决这个问题。根本问题是 C++ 和现代 CPU 都不是严格顺序的。C++ 明确具有“发生之前”和“发生之后”排序,以默认允许同时发生。 (4认同)

Dan*_*ins 17

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

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

i递增两次,它的值是3,并且当加在一起时,和为6。

为了便于检查,请将其视为 C++ 函数:

int dblInc ()
{
    int i = 1;
    int x = ++i + ++i;
    return x;   
}
Run Code Online (Sandbox Code Playgroud)

现在,这是我使用旧版本的 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
Run Code Online (Sandbox Code Playgroud)

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

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

除非另有说明,否则未指定单个运算符的操作数和单个表达式的子表达式的计算顺序以及副作用发生的顺序。

附注:

运算符的优先级没有直接指定,但可以从语法派生。

在那一点给出了两个未指定行为的例子,都涉及增量运算符(其中之一是:)i = ++i + 1

现在,如果愿意,可以: 制作一个整数包装类(如 Java 整数);重载函数operator+operator++返回中间值对象;从而编写++iObj + ++iObj并获取它以返回一个包含 5 的对象。(为了简洁起见,我没有在此处包含完整代码。)

就我个人而言,我很想知道是否有一个著名的编译器的例子,它以不同于上面看到的序列的任何其他方式完成这项工作。在我看来,最直接的实现inc是在执行加法运算之前对原始类型执行两个汇编代码。

  • 增量运算符确实有一个非常明确定义的“返回”值 (2认同)
  • 这些不是“未指定行为的两个示例”,而是“未定义行为”的两个示例,这是一种非常不同的野兽,源自标准中的不同段落。我看到 C++98 过去在脚注示例的文本中说“未指定”,与规范文本相矛盾,但后来修复了这一点。 (2认同)

MSa*_*ers 6

编译器可以做的合理的事情是公共子表达式消除。这已经是编译器中常见的优化:如果像这样的子表达式(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,然后反复强度降低)


Dam*_*mon 5

编译器无法做任何合理的事情来获得 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'
Run Code Online (Sandbox Code Playgroud)

如您所见,相同的lvalue Var 0x2b3e430前缀++应用于两个位置,并且这两个位于树中的同一节点下方,这恰好是一个非常非特殊的运算符 (+),对排序等没有什么特别的说法。为什么这很重要?好吧,继续阅读。

请注意警告:“对 'i' 的多次未排序修改”。哦哦,这听起来不太好。这是什么意思?[basic.exec]告诉我们副作用和排序,它告诉我们(第 10 段),默认情况下,除非另有明确说明,否则对单个运算符的操作数和单个表达式的子表达式的评估是未排序的。好吧,该死的,情况operator+就是这样- 没有其他说法,所以......

但是我们关心先排序、不确定排序还是未排序?反正谁想知道?

同一段落还告诉我们,未排序的评估可能会重叠,并且当它们引用相同的内存位置时(就是这种情况!)并且一个不是潜在的并发,那么行为是未定义的。这就是它真正变得丑陋的地方,因为这意味着您一无所知,并且您无法保证“合理”。不合理的事情其实是完全可以允许的,也是“合理的”。


gna*_*729 5

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

一个非常合理的编译是编译器观察到执行语句会调用未定义的行为,因此该语句无法执行,因此它被转换为故意使您的应用程序崩溃的指令。这是非常合理的。

Downvoter:海湾合作委员会强烈反对你。