求值和副作用
表达式求值可以拆成两部分:一部分是“算出结果值”,另一部分是“改变程序状态”。这两个部分经常同时出现,但在语义上并不相同,区分它们是理解 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
可能的输出(示例):
<输出与输入或平台相关,请以实际运行为准>
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. 把副作用放在可观察边界
当副作用和纯计算交错出现时,建议把副作用集中到少量清晰边界,例如“先更新状态,再计算,再输出”。这能让每一步的语义职责更单一,也能减少优化与重构时的误读风险。只要读者能快速回答“这一步是否改变状态”,代码就更容易验证。
7. 读写同一对象要先建立顺序关系
只要一个完整表达式中同时涉及同一对象的读取与修改,就应先确认两者之间是否有标准保证的先后关系。若没有,最直接的修复仍然是拆分语句。把这条检查当作默认动作,可以在编码阶段提前规避大量未定义行为。
8. 把副作用封装在小接口里
当某类副作用频繁出现(例如计数更新、日志写入、状态推进),可考虑提炼为语义清晰的小函数,再由调用点按顺序组织。这样不仅让表达式更纯净,也让副作用边界更集中,后续排查更直观。
9. 习题
判断下列表达式是否一定产生副作用,并说明副作用是什么:
1 + 2i += 1i++