C语言中的Side Effect与Sequence Point

如果你只想规规矩矩地写代码,那么基本用不着看这个。此文章中的写法都应该避免使用。

我们来看下面这段代码:

1
2
int a = 0;
a = (++a) + (++a) + (++a) + (++a);

据我了解,似乎很多公司都有出这种笔试题的恶趣味。答案应该是 Undefined,我甚至有些怀疑出题的人是否真的知道答案。下面我来解释为什么是 Undefined。

我们知道,调用一个函数可能产生 Side Effect,使用某些运算符(++、—、=、复合赋值)也会产生 Side Effect,如果一个表达式中隐含着多个 Side Effect,究竟哪个先发生哪个后发生呢?C 标准规定代码执行过程中的某些时刻是 Sequence Point,当到达一个 Sequence Point 时,在此之前的 Side Effect 必须全部作用完毕,在此之后的 Side Effec t必须一个都没发生。至于两个 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 的值。

逻辑与和逻辑或这两个运算符和条件表达式类似,先求左操作数的值,然后根据这个值是否为真,右操作数可能被求值,也可能不被求值。比如下面这个程序中的这几句:

1
2
3
4
5
   ret = scanf("%d", &man);
if (ret != 1 || man < 0 || man > 2) {
printf("Invalid input! Please input 0, 1 or 2.\n");
continue;
}

其实可以写得更简单:

1
2
3
4
    if (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) b;,而 a || b 相当于 if (!a) b; 。这种特性称为 Short-circuit,很多人喜欢利用 Short-circuit 特性使代码更加简洁。

  1. 在一个完整的声明末尾是 Sequence Point,所谓完整的声明是指这个声明不是另外一个声明的一部分。比如声明 int a[10], b[20];,在 a[10] 末尾是 Sequence Point,在 b[20] 末尾也是。

  2. 在一个完整的表达式末尾是 Sequence Point,所谓完整的表达式是指这个表达式不是另外一个表达式的一部分。所以如果有 f(); g(); 这样两条语句,f() 和 g() 是两个完整的表达式,f() 的 Side Effect 必定在 g() 之前发生。

  3. 在库函数返回时是 Sequence Point。这似乎可以包含在上一条规则里面,因为函数返回必然会结束掉一个表达式,开始一个新的表达式。事实上以后我们会讲到,很多库函数是以宏定义的形式实现的,并不是真的函数,所以才需要有这条规则。

  4. 像 printf、scanf 这种带转换说明的输入/输出库函数,在处理完每一个转换说明相关的输入/输出操作时是一个Sequence Point。

  5. 库函数 bsearch 和 qsort 在查找和排序过程中的每一步比较或移动操作之间是一个 Sequence Point。

现在可以分析一下本节开头的例子了。a = (++a) + (++a) + (++a) + (++a); 的结果之所 Undefined, 是因为在这个表达式中对变量 a 的 Side Effect 有五次,这些 Side Effect 何时发生、按什么顺序发生是不一定的,只知道在整个表达式结束时一定都发生了,但在计算过程中要用到a的值时,能取出什么值就不确定了。这行代码用不同平台的不同编译器来编译,结果是不同的,甚至在同一平台上用同一编译器的不同版本来编译也可能不同。

写表达式应遵循的原则一:在两个 Sequence Point 之间,同一个变量的值只允许被改变一次。仅有这一条原则还不够,例如 a[i++] = i; 的变量 i 只改变了一次,但结果仍是 Undefined,因为等号左边改i的值,等号右边读 i 的值,到底是先改还是先读?这个读写顺序是不确定的。但为什么 i = i + 1; 就没有歧义呢?虽然也是等号左边改 i 的值,等号右边读i的值,但你不读出i的值就没法计算 i + 1,那拿什么去改i的值呢?所以这个读写顺序是确定的。所以,写表达式应遵循的原则二:如果在两个 Sequence Point 之间既要读一个变量的值又要改它的值,只有在读写顺序确定的情况下才可以这么写。

------本文结束感谢您的阅读 ------
0%