浅谈C/C++中的顺序点和副作用
一.副作用(side effect)
表达式有两种功能:每个表达式都产生一个值( value ),同时可能包含副作用( side effect )。副作用是指改变了某些变量的值。
如:
1:20 //这个表达式的值是20;它没有副作用,因为它没有改变任何变量的值。
2:x=5 // 这个表达式的值是5;它有一个副作用,因为它改变了变量x的值。
3:x=y++ // 这个表达示有两个副作用,因为改变了两个变量的值。
4:x=x++ // 这个表达式也有两个副作用,因为变量x的值发生了两次改变。
二.求值顺序点
表达式求值规则的核心在于 顺序点( sequence point ) [ C99 6.5 Expressions 条款2 ] [ C++03 5 Expressions 概述 条款4 ]。
顺序点的意思是在一系列步骤中的一个“结算”的点,语言要求这一时刻的求值和副作用全部完成,才能进入下面的部分。在C/C++中只有以下几种存在顺序点:
1)分号;
2)未重载的逗号运算符的左操作数赋值之后(即','处)
3)未重载的'||'运算符的左操作数赋值之后(即'||'处);
4)未重载的'&&'运算符的左操作数赋值之后(即"&&"处);
5)三元运算符'? : '的左操作数赋值之后(即'?'处);
6)在函数所有参数赋值之后但在函数第一条语句执行之前;
7)在函数返回值已拷贝给调用者之后但在该函数之外的代码执行之前;
8)每个基类和成员初始化之后;
9)在每一个完整的变量声明处有一个顺序点,例如int i, j;中逗号和分号处分别有一个顺序点;
10)for循环控制条件中的两个分号处各有一个顺序点。
对于任意一个顺序点,它之前的所有副作用都已经完成,它之后的所有副作用都尚未发生。
在两个顺序点之间,子表达式求值和副作用的顺序是不同步的。如果代码的结果与求值和副作用发生顺序相关,称这样的代码有不确定的行为(unspecified behavior).而且,假如期间对一个内建类型执行一次以上的写操作,则是未定义行为. 即:标准规定,在两个序列点之间,一个对象所保存的值最多只能被修改一次。
故而如下语句,就是未定义的,也是错误的:
int x = 1, y;
y = x++ + x++;
上面这个例子中,x 的值在两个序列点之间被修改了两次。这显然是错误的!这段代码在不同的编译器上编译可能会导致 y 的值有所不同。比较常见的结果是 y 的值最后被修改为 2 或者 3。
任意两个顺序点之间的副作用的发生顺序都是未定义的.
如:
x=x++;
该表达式只有一个顺序点,在该顺序点之前有2个副作用,一个是自增,一个赋值,这两个副作用发生的顺序是未定义的,即自增运算和赋值运算哪一个先执行是没有被定义的(注意这个顺序跟运算符的优先级是无关的,注意理解运算符优先级的含义),这个执行次序交由编译器厂商去自行决定,因此对于不同的编译器可能会得出不同的结果。[问题存疑:为什么此处的顺序和优先级无关?++的优先级不应该比=要高么?,且该语句执行的结果始终都是x加1了啊] 【一种可能的解释:x=x++; 一个可能的编译过程是,先将x+1存入临时的temp里面:temp=x+1,然后再把x的值赋值给x,即:x=x;最后在下一个sequence point(此处是分号)到来之前,再把temp的值赋值给x; 还有一个可能的编译过程是:temp=x+1; x=temp; x=x; 这两个方式的最终x结果虽然一致,但是过程是不一样的】
而x=++x;则是确定的行为,因为,该语句里面前自增++不存在一个temp的变量,而是直接对x本身进行加1操作。
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char*argv[])
{
int i=0;
int m=(++i)+(++i)+(++i)+(++i);
printf("%d %d\n",m,i);
system("pause");
return0;
}
对于上述代码:
在gcc编译器中运行得到的结果是 11 4
而在Visual Studio 2008中运行得到的结果是 16 4【注意,已经经过我的实验验证,在VC6.0下面,debug和release版本也不同,分别是11,4和16,4】.
因为对于
int i=0;
int m=(++i)+(++i)+(++i)+(++i);
在两个分号之间有5个副作用,这5个副作用与子表达式的求值顺序是未定义的,对于不同的编译器会得出不同的结果。
并且在这期间对i进行了不止一次的写操作,这也是一个未定义的行为,可能会引起任何后果。
还比如:
x[i]=i++;
printf("%d %d\n",i++,i++);
function(x,x++);
这些都是未定义的行为。
因此我们平时在写代码时,尽量不要写出这样风格不好的代码,因为它不仅会给程序带来不确定性,可能会引起任何后果(比如程序崩溃),而且对于代码的移植性来说是致命的打击。
比如:
x[i]=i++;
可以用这段代码去代替:
x[i]=i;
i++;
function(x,x++);-> function(x,x);x=x+1;
这样的代码才是风格良好的代码。
尽量保证,在两个相邻顺序点之间同一个变量不可以被修改两次以上或者同时有读取和修改,否则,就会产生未定义的行为。
三. Side Effect与Sequence Point
如果你只想规规矩矩地写代码,那么基本用不着看这一节。本节的内容基本上是钻牛角尖儿的,除了Short-circuit比较实用,其它写法都应该避免使用。但没办法,有时候不是你想钻牛角尖儿,而是有人逼你去钻牛角尖儿。这是我们的学员在找工作笔试时碰到的问题:
int a=0; a = (++a)+(++a)+(++a)+(++a);
据我了解,似乎很多公司都有出这种笔试题的恶趣味。答案应该是Undefined,我甚至有些怀疑出题人是否真的知道答案。下面我来解释为什么是Undefined。
我们知道,调用一个函数可能产生Side Effect,使用某些运算符(++ -- = 复合赋值)也会产生Side Effect,如果一个表达式中隐含着多个Side Effect,究竟哪个先发生哪个后发生呢?C标准规定代码中的某些点是Sequence Point,当执行到一个Sequence Point时,在此之前的Side Effect必须全部作用完毕,在此之后的Side Effect必须一个都没发生。至于两个Sequence Point之间的多个Side Effect哪个先发生哪个后发生则没有规定,编译器可以任意选择各Side Effect的作用顺序。下面详细解释各种Sequence Point。
1、调用一个函数时,在所有准备工作做完之后、函数调用开始之前是Sequence Point。比如调用foo(f(), g())
时,foo
、f()
、g()
这三个表达式哪个先求值哪个后求值是Unspecified,但是必须都求值完了才能做最后的函数调用,所以f()
和g()
的Side Effect按什么顺序发生不一定,但必定在这些Side Effect全部作用完之后才开始调用foo
函数。
2、条件运算符?:、逗号运算符、逻辑与&&、逻辑或||的第一个操作数求值之后是Sequence Point。我们刚讲过条件运算符和逗号运算符,条件运算符要根据表达式1的值是否为真决定下一步求表达式2还是表达式3的值,如果决定求表达式2的值,表达式3就不会被求值了,反之也一样,逗号运算符也是这样,表达式1求值结束才继续求表达式2的值。
逻辑与和逻辑或早在第 3 节 “布尔代数”就讲了,但在初学阶段我一直回避它们的操作数求值顺序问题。这两个运算符和条件运算符类似,先求左操作数的值,然后根据这个值是否为真,右操作数可能被求值,也可能不被求值。比如例 8.5 “剪刀石头布”这个程序中的这几句:
ret = scanf("%d", &man); if (ret != 1 || man < 0 || man > 2) { printf("Invalid input!\n"); return 1; }
其实可以写得更简单(类似于[K&R]的简洁风格):
if (scanf("%d", &man) != 1 || man < 0 || man > 2) { printf("Invalid input!\n"); return 1; }
这个控制表达式的求值顺序是:先求scanf("%d", &man) != 1
的值,如果scanf
调用失败,则返回值不等于1成立,||运算有一个操作数为真则整个表达式为真,这时直接执行下一句printf
,根本不会再去求man < 0
或man > 2
的值;如果scanf
调用成功,则读入的数保存在变量man
中,并且返回值等于1,那么说它不等于1就不成立了,第一个||运算的左操作数为假,就会去求右操作数man < 0
的值作为整个表达式的值,这时变量man
的值正是scanf
读上来的值,我们判断它是否在[0, 2]之间,如果man < 0
不成立,则整个表达式scanf("%d", &man) != 1 || man < 0
的值为假,也就是第二个||运算的左操作数为假,所以最后求右操作数man > 2
的值作为整个表达式的值。
&&运算与此类似,a && b
的计算过程是:首先求表达式a
的值,如果a
的值是假则整个表达式的值是假,不会再去求b
的值;如果a
的值是真,则下一步求b
的值作为整个表达式的值。所以,a && b
相当于“if a then b”,而a || b
相当于“if not a then b”。这种特性称为Short-circuit,很多人喜欢利用Short-circuit特性简化代码。
3、在一个完整的声明末尾是Sequence Point,所谓完整的声明是指这个声明不是另外一个声明的一部分。比如声明int a[10], b[20];
,在a[10]
末尾是Sequence Point,在b[20]
末尾也是。
4、在一个完整的表达式末尾是Sequence Point,所谓完整的表达式是指这个表达式不是另外一个表达式的一部分。所以如果有f(); g();
这样两条语句,f()
和g()
是两个完整的表达式,f()
的Side Effect必定在g()
之前发生。
5、在库函数即将返回时是Sequence Point。这条规则似乎可以包含在上一条规则里面,因为函数返回时必然会结束掉一个完整的表达式。而事实上很多库函数是以宏定义的形式实现的(第 2.1 节 “函数式宏定义”),并不是真正的函数,所以才需要有这条规则。
还有两种Sequence Point和某些C标准库函数的执行过程相关,此处从略,有兴趣的读者可参考[C99]的Annex C。
现在可以分析一下本节开头的例子了。a = (++a)+(++a)+(++a)+(++a);
的结果之所以是Undefined,因为在这个表达式中有五个Side Effect都在改变a
的值,这些Side Effect按什么顺序发生不一定,只知道在整个表达式求值结束时一定都发生了。比如现在求第二个++a
的值,这时第一个、第三个、第四个++a
的Side Effect发生了没有,a
的值被加过几次了,这些都不确定,所以第二个++a
的值也不确定。这行代码用不同平台的不同编译器来编译结果是不同的,甚至在同一平台上用同一编译器的不同版本来编译也可能不同。
写表达式应遵循的原则一:在两个Sequence Point之间,同一个变量的值只允许被改变一次。仅有这一条原则还不够,例如a[i++] = i;
的变量i
只改变了一次,但结果仍是Undefined,因为等号左边改i
的值,等号右边读i
的值,到底是先改还是先读?这个读写顺序是不确定的。但为什么i = i + 1;
就没有歧义呢?虽然也是等号左边改i
的值,等号右边读i
的值,但你不读出i
的值就没法计算i + 1
,那拿什么去改i
的值呢?所以这个读写顺序是确定的。写表达式应遵循的原则二:如果在两个Sequence Point之间既要读一个变量的值又要改它的值,只有在读写顺序确定的情况下才可以这么写。