求值过程中的副作用与顺序点

2014-09-12

C, C++, Compiler

事情要从一段代码说起:

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)。尽管ij自增先后不确定,但对结果是没有影响的。编译器可以这样拆分它:

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)未定义,这涉及到“顺序点”的概念。

“副作用”,即代码中对数据的改变,++ii = ...都会产生副作用。C/C++为了让编译器能更好地优化代码,定义了一些位置,编译器能保证这些位置之后的副作用开始前,之前的副作用完成。这些位置,也就是顺序点。

顺序点包括:

  • 两个语句之间,for循环的括号中包含了三个语句;
  • 函数调用的参数与函数返回之间(但是参数自身没有顺序保证);
  • 短路求值操作符前后;
  • 问号表达式的条件与结果之间;
  • 变量初始化之间、之后。

我们可以用一个例子来总结:

1 for (
2     int a = 1, b = ++a, c = ++a;
3     ++a || ++a ? ++a : ++a;
4     ++a = f(++a)
5 );

不过,再次强调:不要真的写这样的代码