3. 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。
调用一个函数时,在所有准备工作做完之后、函数调用开始之前是 Sequence Point。比如调用
foo(f(), g())时,foo、f()、g()这三个表达式哪个先求值哪个后求值是 Unspecified,但是必须都求值完了才能做最后的函数调用,所以f()和g()的 Side Effect 按什么顺序发生不一定,但必定在这些 Side Effect 全部作用完之后才开始调用foo函数。条件运算符
?:、逗号运算符、逻辑与&&、逻辑或||的第一个操作数求值之后是 Sequence Point。我们刚讲过条件运算符和逗号运算符,条件运算符要根据表达式 1 的值是否为真决定下一步求表达式 2 还是表达式 3 的值,如果决定求表达式 2 的值,表达式 3 就不会被求值了,反之也一样,逗号运算符也是这样,表达式 1 求值结束才继续求表达式 2 的值。逻辑与和逻辑或早在 第 3 节“布尔代数” 就讲了,但在初学阶段我一直回避它们的操作数求值顺序问题。这两个运算符和条件运算符类似,先求左操作数的值,然后根据这个值是否为真,右操作数可能被求值,也可能不被求值。比如 例 8.5“剪刀石头布” 这个程序中的这几句:
cret = scanf("%d", &man); if (ret != 1 || man < 0 || man > 2) { printf("Invalid input! Please input 0, 1 or 2.\n"); continue; }其实可以写得更简单(类似于 K&R 的简洁风格):
cif (scanf("%d", &man) != 1 || man < 0 || man > 2) { printf("Invalid input! Please input 0, 1 or 2.\n"); continue; }这个控制表达式的求值顺序是:先求
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 特性简化代码。在一个完整的声明末尾是 Sequence Point,所谓完整的声明是指这个声明不是另外一个声明的一部分。比如声明
int a[10], b[20];,在a[10]末尾是 Sequence Point,在b[20]末尾也是。在一个完整的表达式末尾是 Sequence Point,所谓完整的表达式是指这个表达式不是另外一个表达式的一部分。所以如果有
f(); g();这样两条语句,f()和g()是两个完整的表达式,f()的 Side Effect 必定在g()之前发生。在库函数即将返回时是 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 之间既要读一个变量的值又要改它的值,只有在读写顺序确定的情况下才可以这么写。