求值顺序
求值顺序讨论的是:一个表达式里多个子表达式谁先算、谁后算,以及哪些先后关系由标准保证。很多“偶发错误”并不是算术本身有问题,而是把本应拆开的读写挤在同一表达式里,结果触发了未定义行为。
1. 三种常见关系
第一种是顺序已确定,例如 &&、||、?: 和逗号运算符会给出明确先后;第二种是顺序未指定,编译器可在多个合法顺序间选择,例如函数实参求值次序;第三种是未排序冲突,即对同一标量对象发生冲突读写且无先后保证,这会进入未定义行为。
2. 两个典型例子
int i = 0;
int x = ++i + 1; /* 有效:对 i 只有一次修改 */2
运行结果:该代码块主要用于语法或结构说明,单独运行通常无终端输出。
int i = 0;
int x = i++ + i; /* 未定义行为:修改和读取对同一对象无先后保证 */2
运行结果:该代码块主要用于语法或结构说明,单独运行通常无终端输出。
判断这类表达式时,可以先问一句:同一对象是否在一个完整表达式内被“冲突访问”,而且我无法给出标准保证的先后关系。如果答案是“是”,最稳妥的修复就是拆语句。
3. 函数调用里的顺序陷阱
函数实参的求值顺序通常不应被假定。例如 f(i++, i++) 会形成对同一对象的未排序双重修改,属于未定义行为。把更新动作放到调用前单独完成,再把结果作为实参传入,会更清楚。
4. 短路运算符的顺序保证
&& 和 || 都按从左到右求值,并带有短路语义:左侧已足以决定结果时,右侧不会被求值。这一点既能减少不必要计算,也常被用来保护右侧表达式的前置条件。
if (ptr != NULL && *ptr == 'A') {
/* 只有 ptr 非空时才会解引用 */
}2
3
运行结果:该代码块主要用于语法或结构说明,单独运行通常无终端输出。
这里先判断 ptr != NULL,再决定是否访问 *ptr,顺序是有标准保证的。
5. 未指定顺序不等于未定义行为
有些表达式虽然子表达式先后未指定,但只要彼此不发生冲突读写,仍然是合法代码。例如独立对象的纯计算通常不会触发 UB。真正需要警惕的是“同一对象在同一完整表达式里出现无序冲突访问”。
6. 判定顺序问题的实用步骤
排查复杂表达式时,可以按三步检查:先找是否有同一对象的多次读写,再看这些访问之间是否有标准保证的先后关系,最后判断是否跨越完整表达式边界。只要第二步回答不出来,最稳妥做法通常就是拆成多条语句,让先后关系由语句顺序显式表达。
7. 实参求值不要承载状态推进
函数实参的求值顺序在很多场景下并无固定保证,因此不应把关键状态推进动作塞进多个实参表达式中。更稳妥的写法是先把每个实参结果落地,再调用函数。这样调用语义清楚,也便于日志和断点定位。
8. 用语句边界替代隐式顺序假设
当代码正确性依赖“先做 A 再做 B”时,建议直接写成两条语句,而不是在一个表达式里依赖运算符细节。语句边界天然提供清晰先后关系,比隐式顺序假设更稳定,也更适合后续重构。
9. 习题
判断下列代码是否是 UB;如果是,改写为无 UB 的版本:
int i = 0;
int x = ++i + i++;2
运行结果:该代码块主要用于语法或结构说明,单独运行通常无终端输出。