C 语言中,术语副作用是指对数据对象或者文件的修改。例如以下语句
var = 99;
的副作用是把 var 的值修改成 99。对表达式求值也可能产生副作用,例如:
se = 100
对这个表达式求值所产生的副作用就是 se 的值被修改成 100。
序列点是指程序运行中的一个特殊的时间点,在该点之前的所有副作用已经结束,并且后续的副作用还没发生。
C 语句结束标志——分号(;)是序列点。也就是说,C 语句中由赋值、自增或者自减等引起的副作用在分号之前必须结束。我们以后会说到一些包含序列点的运算符。任何完整表达式(full expression)运算结束的那个时间点也是序列点。所谓完整表达式,就是说这个表达式不是子表达式。而所谓的子表达式,则是指表达式中的表达式。例如:
f = ++e % 3
这整个表达式就是一个完整表达式。这个表达式中的 ++e、3 和 ++e % 3 都是它的子表达式。
有了序列点的概念,我们下面来分析一下一个很常见的错误:
int x = 1, y;
y = x++ + x++;
这里 y = x++ + x++ 是完整表达式,而 x++ 是它的子表达式。这个完整表达式运算结束的那一点是一个序列点,int x = 1, y; 中的 ; 也是一个序列点。也就是说,x++ + x++ 位于两个序列点之间。标准规定,在两个序列点之间,一个对象所保存的值最多只能被修改一次。但是我们清楚可以看到,上面这个例子中,x 的值在两个序列点之间被修改了两次。这显然是错误的!这段代码在不同的编译器上编译可能会导致 y 的值有所不同。比较常见的结果是 y 的值最后被修改为 2 或者 3。
C 语言标准对副作用和序列点的定义如下:
访问易变对象,修改对象或文件,或者调用包含这些操作的函数都是副作用,它们都会改变执行环境的状态。计算表达式也会引起副作用。执行序列中某些特定的点被称为序列点。在序列点上,该点之前所有运算的副作用都应该结束,并且后继运算的副作用还没发生。
在编译时可以加上“-Wsequence-point ”让编译器帮我们检查可能的关于检查点的错误。
/*
* test_sequence_point.c
* gcc -Wsequence-point test_sequence_point.c
*/
#include<stdio.h>
int main(void)
{
int i =12;
i = i--;
printf("the i is %d/n", i);
return 0;
}
在我们面试C语言程序员时总是遇到这样的问题:
int a = 0;
a = a++;
很多公司都出过这种笔试题。答案应该是Undefined,下面解释为什么是Undefined。
我们知道,调用一个函数可能产生副作用,使用某些运算符(++ -- = 复合赋值)也会产生副作用,如果一个表达式中隐含着多个副作用,究竟哪个先发生哪个后发生呢?C标准规定代码中的某些点是顺序点,当执行到一个顺序点时,在此之前的副作用必须全部作用完毕,在此之后的副作用必须一个都没发生。至于两个顺序点之间的多个副作用究竟哪个先发生哪个后发生则没有规定,编译器可以任意选择各副作用的作用顺序。下面详细解释各种顺序点。
1.调用一个函数时,在所有准备工作做完之后及函数调用开始之前是顺序点。比如调用foo(f(), g())时,foo、f()、g()这三个表达式哪个先求值哪个后求值是Unspecified,但是必须都求完值了才能做最后的函数调用,所以f()和g()的Side Effect按什么顺序发生不一定,但必定在这些副作用全部作用完之后才开始调用foo函数。
2.条件运算符?:、逗号运算符、逻辑与、逻辑或的第一个操作数求值之后是顺序点。我们刚讲过条件运算符和逗号运算符,条件运算符要根据表达式1的值是否为真决定下一步求表达式2还是表达式3的值,如果决定求表达式2的值,表达式3就不会被求值了,反之也一样,逗号运算符也是这样,表达式1求值结束后才继续求表达式2的值。
逻辑与和逻辑或这两个运算符和条件运算符类似,先求左操作数的值,然后根据这个值是否为真,决定是否求右操作数的值。比如下面的代码:
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("Invalidinput!\n");
return 1;
}
这个控制表达式的求值顺序是:先求scanf("%d",&man)! = 1的值,如果scanf调用失败,则返回值不等于1成立,逻辑或运算有一个操作数为真则整个表达式为真,这时直接执行printf语句,根本不会再去求man < 0或man > 2的值;如果scanf调用成功,则读入的数保存在变量man中,并且返回值等于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相当于"ifnot a then b"。这种特性称为Short-circuit,很多人喜欢利用Short-circuit特性简化代码。
3.在一个完整的声明末尾是顺序点,所谓完整的声明是指这个声明不是另外一个声明的一部分。比如声明int a[10], b[20];,在a[10]末尾是顺序点,在b[20]末尾也是。
4.在一个完整的表达式末尾是顺序点,所谓完整的表达式是指这个表达式不是另外一个表达式的一部分。所以如果有f(); g();这样两条语句,f()和g()是两个完整的表达式,则f()的Side Effect必定在g()之前发生。
5.在库函数即将返回时是顺序点。这条规则似乎可以包含在上一条规则里面,因为函数返回时必然会结束一个完整的表达式。而事实上很多库函数是以宏定义的形式实现的,并不是真正的函数,所以才需要有这条规则。
还有两种顺序点和某些C标准库函数的执行过程相关,有兴趣的读者可参考[C99]的Annex C。
现在分析一下本节开头的例子:int a =0; a = a++;。按照运算符优先级应该先计算a++,再把表达式a++的值赋给a。已知a的初值是0,则表达式a++的值是0。现在有两个Side Effect,一个是在计算表达式a++之后应该把a改成1,另一个是把表达式a++的值0赋给等号左边的a,哪个先发生不一定,只知道在整个表达式求值结束时这两个Side Effect一定都发生了,最后a的值可能是0也可能是1,所以结果是Undefined。这行代码用不同平台的不同编译器来编译结果可能是不同的,甚至在同一平台上用同一编译器的不同版本来编译结果也可能不同。
由于两个顺序点之间的多个副作用可以按任意顺序发生,所以在写代码时要注意,在两个顺序点之间,同一个变量的值最多只允许改变一次。但是做到这一点还不足以保证代码的执行结果是确定的,一个变量被改变了一次,就有改变之前和改变之后两个不同的值,如果这个变量在一个表达式中出现多次,它应该代表哪个值呢?比如在a[i++] = i;中变量i只改变了一次,但结果仍是Undefined,我们分析一下:设i的初值是0,则表达式i++的值是0,有一个副作用是把i改成1,但这个副作用什么时候发生不一定,如果在这个副作用发生之后才取等号右边i的值,则把a[0]赋值为1,如果在这个副作用发生之前就取等号右边i的值,则把a[0]赋值为0。再比如i = i + 1;,它的执行结果是确定的,它会读取i的值,也会产生副作用改写i的值,但必须先读再改写,所以读取到的i值一定是初值而不是改写之后的值。