关于 10 个自增典型例题中例 7 的解释

作者 Shilei Tian 日期 2016-09-30
C++
关于 10 个自增典型例题中例 7 的解释

这个题目是一个很典型的会因为不同编译器导致出现不同结果的例题,程序的代码如下:

1
2
3
4
5
6
int main() {
int x = 5;
int y = ++x + (++x) + x++;
cout << y << endl;
return 0;
}

如果在 Windows 下的 Code::Blocks 或者是 Visual Studio 来编译这个程序的话,得到的结果是 21,但是如果在 macOS 用 clang 编译这个程序的话,得到的结果会是 20


为什么会出现这种现象呢?本文就该问题尝试做出一些解释。

警告

不知道大家注意到 Xcode 中的警告了没有?它说的是

Multiple unsequenced modifications to ‘x’

即“多个对 x 的运算顺序未指定的修改”,这句话听起来很拗口,简单的讲,就是 y 这个表达式的运算顺序是不一定的!也就是说,int y = ++x + (++x) + x++; 这个表达式先执行哪个自增运算这个是未指定的。

这个警告在 Code::Blocks 也会给出的:

其实,C++ 并没有规定求值顺序。在 C++ Primer 5e 书中 4.1.3 节提到:

Precedence specifies how the operands are grouped. It says nothing about the order in which the operands are evaluated. In most cases, the order is largely unspecified. In the following expression int i = f1() * f2();, we know that f1 and f2 must be called before the multiplication can be done. After all, it is their results that are multiplied. However, we have no way of knowing whether f1 will be called before f2 or vice versa.

上述这一段的意思是,运算符的优先级规定了运算对象的组合方式,但是没有说明运算对象按照什么顺序求值。并且在大多数情况下,都不会明确指定求值的顺序。这里它举了一个例子,来求函数 f1 和函数 f2 返回值的乘积,但是到底是 f1 先执行还是 f2 先执行,C++ 的标准并没有规定。

为什么会出现不同的结果?

看了上面的警告,这下我们明白了,到底运算结果是 21 还是 20 这个是跟不同的求值顺序相关的。既然老师给出的答案是 21,那我们就看一下,为什么这个结果是 21。想要看 C++ 的代码在计算机中是如何执行的,最简单的办法就是看由 C++ 代码生成的汇编代码,你可以在 Code::Blocks 中在 return 0; 这一句上加一个断点,然后开始调试。当调试程序在断点处停止了时,点击菜单栏上 Debug - Debugging windows - Disassembly 就可以看到当前代码对应的汇编代码了,我们在这里截一下关键部分的图:

如果这一部分你暂时看不懂,不要紧,直接跳到这段话的最后。地址为 0x401356 位置的指令是 movl $0x5,-0xc(%bep),意思是将立即数 5 放到寄存器 ebp 中,接下来的两行 addl 指令就是我们的两个自增(这里指的是 ++x)运算了。那么我们的代码里面有两个 ++x,先执行哪一个呢?这个问题的答案我也不知道!之前我们说过,虽然括号的优先级最高,但是优先级只是规定了运算对象的组合方式,并没有说要先执行。这里两个前置运算自增,到底先算的是那一个无从知晓。这个就是我们说的未指定顺序的求值。好,我们继续看汇编,经过两次 addl 指令后,ebp 中的值应该是 7,然后它将 ebp 中的值放到寄存器 eax 中。下面这个 lea 指令非常关键,它执行的意思相当于 %ecx = %eax + %eax * 1,也就是 %ecx = 7 + 7 * 1,那么 ecx 中存放的值是 14。接下来,它又将 ebp 中的值挪到 eax 中,然后对 eax 进行一次自增运算放到 edx 中。这条语句相当于 %edx = 0x1 + %eax。看到了吧,这就是最后一次自增运算。这里能够看出来,这个自增运算并不是像之前两次自增直接在 ebp 上进行的,因为它是后置的自增运算,这也能看出来这两种自增运算符的不同之处。它将自增运算后的结果又通过 mov %edx,-0xc(%ebp) 写回 ebp 中。但是,下面的这条 add %ecx,%eax 是将 ecx 中的内容加到 eax 上。回想一下,ecx 中存的是多少?14。那么 eax 中是多少?是 7 呀!因为虽然我们之前执行过一次自增,但是我们并不是在 eax 寄存器本身上进行的,而是将它放到了 edx 中,因此 eax 中还是 7。所以,最终的结果是三个 7 加在一起,即 21。最后一条汇编指令是将 21 挪到 ebp 中。

为什么是三个 7 相加?

得到 21 结果的运算实际上是通过 7 + 7 + 7 得到的。猜测执行的顺序是,从左向右执行,++x 最先,接下来是 (++x),然后将两个 7 加起来,再执行 x++,由于这个是后置的自增,因此先将 x 值参与运算,再将 x 自增。
那么问题来了,为什么前两次是两个 7 相加呢?这个就涉及到自增运算符的特性了。C++ Primer 5e 在 14.6 节对自增运算符重载的时候讲到:

The prefix operators should return a reference to the incremented or decremented object.
The postfix operators should return the old (unincremented or undercremented) value. That value is returned as a value, not a reference.

就是说,前置运算符返回的是引用,后置运算符返回的是还未进行自增或自减值(或者说是拷贝)。所以这个 y 的表达式实际上可以写成 int y = ref(x) + ref(x) + val(x)。由于 x 先执行 (++x) 自增成 6,然后通过第一个自增变成 7,因此对于 x 的引用肯定得到的都是 7,接下来加上 x 还未递增之前的值 7 就得到了 21,然后再对 x 自增,这样 x 就变成 8

结论

看起来一个小小的表达式,但是却需要用到如此多 C++ 细节的知识,因此需要大家真正的好好掌握 C++ 的基础知识。
另外,对于 macOS 下编译器得到 20,是因为 clang 的求值顺序是从右向左,大家不妨自行尝试一下。
最后,希望大家能够好好理解上面这些,还可以课下结合 C++ Primer 这本书辅助学习。
如有问题,请随时沟通。