求值过程中的副作用与顺序点
2014-09-12
事情要从一段代码说起:
1 int i;
2 i = 1;
3 i = (++i) + (++i); // WTF?
作为一个合格的程序猿,你一定知道这触碰了C标准中的“未定义行为”。这个时候,编译器可以干任何事情。就算它决定让你玩汉诺塔提提神,也是符合标准的行为。在另一篇博客中,我们也提到了这一点。
显然,我们不应该在实际的工程中写出这样的代码。
但有趣的事情来了,我们现在就在再平常不过的x86+linux下,用一个符合标准的编译器编译它,你猜这个时候i
的值是多少?
++i
得2,另一个++i
得3,那么结果一定是5吧!你大概以为我会说这是错的——不,可能错也可能对,这得看编译器。
又一次,gcc和clang给出了不同的结果。clang给出了5,这很好理解;gcc却给出了6。
为什么是6
当时看到6这个结果,多少有些出乎意料,不过仔细想想却也在情理之中。
如果把i
换成不同的变量,我们能写出这样的代码:k = (++i) + (++j)
。尽管i
和j
自增先后不确定,但对结果是没有影响的。编译器可以这样拆分它:
1 ++i;
2 ++j;
3 k = i + j;
那么,我们把三个i
代入,就得到了:
1 ++i; // i = 2
2 ++i; // i = 3
3 i = i + i; // i = 3 + 3
因此结果是6。
为什么是5
那么得到5这个“看上去更自然”的结果时,编译器又做了什么呢?
我们继续看k = (++i) + (++j)
的例子:
1 a = ++i;
2 b = ++j;
3 k = a + b;
代入三个i
得到:
1 a = ++i; // i = 2, a = 2
2 b = ++i; // i = 3, b = 3
3 i = a + b; // i = 2 + 3
结果是5。
结果还可以是几
还可以是4。
如果我们允许编译器这样做:对于只有整数的运算,把_ + _
替换成_ * 2
(或者_ << 1
),其中_
是相同的表达式。这看上去很合理,事实上有一种叫“公共子表达式消除”的技术就做着这样的事情。
i = (++i) + (++i)
可以用这条规则化为i = (++i) << 1
。
求值得到:i = 2 << 1
,那么也就是4。
结果还……
再丧心病狂一点,只要编译器作者乐意,结果也有可能是2:
1 a = i + 1;
2 b = j + 1;
3 k = a + b;
4 i = a;
5 j = b;
1 a = i + 1; // a = 2
2 b = i + 1; // b = 2
3 i = a + b; // i = 2 + 2
4 i = a; // i = 2
5 i = b; // i = 2
还……
好吧,也有可能是1,或者未定义的乱数——如果编译器发现i
被另外的值覆盖,就把相关的代码树或者分配好的寄存器给去除了,结果可能是原来的值1、++i得到的值2、某个寄存器内的不明数字或者它加上1或2。
引发编译错误(如果你在使用特别严格的编译器或编译参数)或者运行时错误也是有可能的。
幕后的“顺序点”
之所以(++i) + (++i)
未定义,这涉及到“顺序点”的概念。
“副作用”,即代码中对数据的改变,++i
和i = ...
都会产生副作用。C/C++为了让编译器能更好地优化代码,定义了一些位置,编译器能保证这些位置之后的副作用开始前,之前的副作用完成。这些位置,也就是顺序点。
顺序点包括:
- 两个语句之间,
for
循环的括号中包含了三个语句; - 函数调用的参数与函数返回之间(但是参数自身没有顺序保证);
- 短路求值操作符前后;
- 问号表达式的条件与结果之间;
- 变量初始化之间、之后。
我们可以用一个例子来总结:
1 for (
2 int a = 1, b = ++a, c = ++a;
3 ++a || ++a ? ++a : ++a;
4 ++a = f(++a)
5 );
不过,再次强调:不要真的写这样的代码!