3.10.1 求值和副作用
表达式求值可以拆成两部分:一部分是“算出结果值”,另一部分是“改变程序状态”。这两个部分经常同时出现,但在语义上并不相同,区分它们是理解 UB 和优化行为的前提。
1. 值计算
值计算 (Value computation) 指的是为了得到表达式结果而进行的计算过程。例如 3 + 2 的结果是 5,这里仅发生值计算,不会改变外部状态。
2. 副作用
副作用 (Side effect) 是求值过程中对程序可观察状态的改变,典型包括修改对象内容、执行输入输出、调用可能修改全局状态的函数。下面这段代码里,++ 会修改对象,printf 会产生 I/O,这两类都属于副作用。
#include <stdio.h>
int main(void) {
int x = 0;
++x;
printf("%d\n", x);
return 0;
}2
3
4
5
6
7
8
输出:
1
3. 与完整表达式的关系
C 标准保证:前一个完整表达式的副作用先于后一个完整表达式发生。这个规则让“拆成多条语句”成为最可靠的写法。与其在单个复杂表达式里混合多次读写,不如先完成一次更新,再进入下一次计算,语义更清楚,也更容易验证。
4. 把副作用和计算分层
当一个表达式既要更新对象又要参与复杂计算时,建议先把更新动作拆出来,再写纯计算表达式。这样不仅更容易确认顺序关系,也更方便调试器逐步观察状态变化。
int i = 0;
/* 不推荐:更新与计算混在一起 */
/* int x = (i += 2) * (i + 3); */
/* 推荐:先更新,再计算 */
i += 2;
int x = i * (i + 3);2
3
4
5
6
7
8
5. 函数调用里的副作用
函数调用本身也可能带来副作用,即使返回值被丢弃。只要函数体会修改对象状态、执行 I/O 或触发同步动作,这次调用就是可观察变化的一部分。
tick(); /* 可能更新时间戳、计数器或日志 */因此判断“有没有副作用”时,不仅要看表达式语法,还要看被调函数语义。
6. 读写同一对象要先建立顺序关系
只要一个完整表达式中同时涉及同一对象的读取与修改,就应先确认两者之间是否有标准保证的先后关系。若没有,最直接的修复仍然是拆分语句。把这条检查当作默认动作,可以在编码阶段提前规避大量未定义行为。
一个典型例子是同一表达式中对同一标量对象执行两次修改:
int i = 1;
int x = i++ + i++; /* 未定义行为 */2
这里,i++既是值计算(取 i 当前值),又是副作用(将 i 加 1)。两个 i++ 的副作用之间没有序列点,标准不保证哪个先完成——编译器可以选择任意顺序甚至在寄存器中同时处理。因此,最终 x 的值、i 的值都没有语义保证,不能把某一次运行结果当成规则。
7. 习题
判断下列表达式是否一定产生副作用,并说明副作用是什么:
1 + 2i += 1i++