表达式求值这件事,很多人写了两三年代码都没真正搞明白。a + b * c为什么先算乘法?x = y = z为什么从右往左赋值?i++ + ++i到底算不算未定义行为?这些问题的答案全都指向同一个底层机制——操作符的优先级和结合性。我见过太多人靠"背口诀"来写表达式,结果遇到稍微复杂一点的场景就翻车,要么加一堆括号把代码搞得像天书,要么写出编译器能过但逻辑完全不对的隐蔽 bug。这篇内容就是想把操作符属性这件事从根上讲透:它是什么、编译器怎么用它、哪些地方容易踩坑、实际写代码时该怎么处理。不管你是刚学编程的新手,还是已经写过几万行代码但没系统梳理过这块的老手,看完都能对表达式求值有一套清晰的判断框架,而不是靠感觉。
1. 优先级和结合性到底在解决什么问题
1.1 从"先乘除后加减"说起
几乎所有人在小学就学过"先乘除后加减",这其实就是优先级最朴素的形态。但编程语言里的表达式比四则运算复杂得多,一个表达式里可能同时出现算术运算、比较运算、逻辑运算、赋值运算、位运算,甚至函数调用和成员访问。编译器需要一个明确的规则来决定"先算谁、后算谁",这套规则就是优先级和结合性共同构成的求值顺序体系。
拿一个最简单的例子:2 + 3 * 4。如果从左往右无脑算,结果是(2+3)*4 = 20;但实际结果是2+(3*4) = 14,因为乘法的优先级高于加法。再看10 - 3 - 2,减法优先级相同,这时候就要靠结合性来决定:左结合意味着(10-3)-2 = 5,而不是10-(3-2) = 9。所以优先级解决的是"不同级别的操作符谁先算",结合性解决的是"同一级别的操作符从左算还是从右算"。两者配合,才能让任何一个表达式有唯一确定的求值顺序。
这里有个容易被忽略的点:优先级和结合性只决定操作符和操作数的分组方式,也就是先构建出什么样的表达式树,并不完全等同于"运行时一定按这个顺序执行"。比如f() + g()里两个函数谁先被调用,C/C++ 标准并没有规定,编译器可以自由决定。这个区别在后面讲副作用的时候会展开,先记住这个分层:分组顺序是语法层面的,求值顺序是语义层面的,两者不是一回事。
1.2 表达式树:把优先级"画"出来
理解优先级最直观的方式是画表达式树。以a = b + c * d为例,编译器解析的过程是这样的:先找到优先级最低的操作符(赋值=),把它作为根节点;左边是a,右边是b + c * d;右边这个子表达式里,+的优先级低于*,所以+是子树的根,*又是c * d的根。最终得到的树形结构是:
= / \ a + / \ b * / \ c d这棵树一旦建好,求值顺序就确定了:先算c * d,再算b + 那个结果,最后赋值给a。你写的每一个表达式,编译器内部都会先转成这样的树(或者等价的抽象语法树 AST),然后才生成目标代码。所以"优先级"本质上是在告诉解析器:遇到多个操作符时,谁应该成为更内层的节点。
我个人的经验是,当你对某个表达式的求值顺序拿不准时,别急着查表,先在纸上画树。画树的过程会强迫你逐个判断操作符之间的关系,比死记优先级表可靠得多。尤其是涉及?:、逗号表达式、赋值这些优先级很低的操作符时,画树几乎不会出错。
1.3 优先级表为什么不能死记
网上流传的优先级表动辄十几二十行,从()[]到逗号表达式排得密密麻麻。很多人试图把它背下来,我的建议是:别背,理解分组逻辑就够了。原因有三:
第一,不同语言的优先级表并不完全一样。C 和 Java 大体相似,但 Python 的**是右结合且优先级高于一元负号(-2**2在 Python 里是-(2**2) = -4),而很多语言里-2^2的解读完全不同。JavaScript 的??和||不能混用不加括号。你背的那张表未必适用于你正在写的语言。
第二,真正需要你精确判断的场景其实很少。日常写代码时,稍微复杂一点的表达式加括号就完事了,可读性还更好。真正需要你搞清楚优先级的,是读别人代码、做代码审查、或者处理一些历史遗留的紧凑写法。
第三,优先级表里真正高频的只有那么几组:算术运算高于比较运算,比较运算高于逻辑运算,逻辑运算高于赋值,成员访问和函数调用最高,逗号最低。把这几个大层级记住,细节靠查或靠括号兜底,完全够用。
下面这张表是我自己整理的高频层级,按从高到低排列,覆盖了日常 90% 以上的场景:
| 层级 | 操作符类别 | 典型操作符 | 结合性 |
|---|---|---|---|
| 1 | 后缀 | ()[].->++--(后缀) | 左结合 |
| 2 | 一元 | !~++--(前缀)+-(正负号)*(解引用)&(取地址) | 右结合 |
| 3 | 乘除模 | */% | 左结合 |
| 4 | 加减 | +- | 左结合 |
| 5 | 移位 | <<>> | 左结合 |
| 6 | 关系 | <<=>>= | 左结合 |
| 7 | 相等 | ==!= | 左结合 |
| 8 | 位与/异或/或 | &^| | 左结合 |
| 9 | 逻辑与/或 | &&|| | 左结合 |
| 10 | 条件 | ?: | 右结合 |
| 11 | 赋值 | =+=-=等 | 右结合 |
| 12 | 逗号 | , | 左结合 |
注意表里第 8 层和第 9 层,位运算的优先级夹在相等运算和逻辑运算之间,这是 C 系语言一个非常反直觉的设计。a & b == c实际会被解析成a & (b == c),因为==的优先级高于&。这个坑我见过无数人踩,后面会专门讲。
2. 结合性:同级别操作符的"方向感"
2.1 左结合与右结合的本质区别
结合性回答的问题是:当多个相同优先级的操作符连续出现时,分组方向是从左往右还是从右往左。绝大多数二元操作符是左结合的,比如加减乘除、比较、逻辑与或。这意味着a - b - c等价于(a - b) - c,a / b / c等价于(a / b) / c。这个直觉和数学一致,所以大家一般不觉得有问题。
真正需要留意的是右结合的操作符。赋值是典型的右结合:a = b = c解析为a = (b = c),先执行b = c,把c的值赋给b,整个赋值表达式的值就是赋进去的值,再赋给a。这个设计让链式赋值成为可能,也是为什么a = b = c = 0能把三个变量都置零。
条件运算符?:也是右结合:a ? b : c ? d : e解析为a ? b : (c ? d : e)。这个特性让嵌套条件表达式可以写成链式,类似其他语言里的if-elif-else结构。一元操作符(前缀++、--、!、~、正负号、解引用、取地址)也是右结合,不过因为它们通常只作用于一个操作数,结合性体现得不如二元操作符明显,但在!!x、- -x、*&x这类连续写法里还是能看出来。
2.2 右结合带来的链式便利与陷阱
右结合最实用的场景就是链式赋值和嵌套条件。链式赋值在初始化多个同类型变量时很顺手:
int a, b, c; a = b = c = 0;但这里有个细节值得说:链式赋值的值传递依赖赋值表达式的返回值。在 C/C++ 里,赋值表达式返回的是被赋值的引用(左值),所以(a = b) = c这种写法在 C++ 里是合法的(虽然没人这么写),而在 Java 里赋值表达式返回的是值,(a = b) = c直接编译不过。这就是"结合性相同但语义不同"的典型例子,光看优先级表是看不出来的。
嵌套条件表达式也是右结合的受益者:
const level = score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 70 ? 'C' : 'D';因为?:右结合,这段代码等价于score >= 90 ? 'A' : (score >= 80 ? 'B' : (score >= 70 ? 'C' : 'D')),逻辑正确。如果它是左结合,解析结果会变成((score >= 90 ? 'A' : score >= 80) ? 'B' : ...),完全乱套。所以右结合在这里不是可有可无的细节,而是这种写法能成立的前提。
陷阱在于,右结合有时会让"看起来该报错"的代码悄悄通过。比如a = b += c,因为赋值右结合,解析为a = (b += c),先算b += c再赋给a,语法合法。但如果你本意是(a = b) += c,那就南辕北辙了。这类复合赋值的链式写法,我建议一律加括号或者拆成两行,别赌自己的记忆。
2.3 结合性不等于求值顺序
这是最容易混淆的一点,必须单独拎出来说。结合性决定的是分组,不是执行顺序。a - b - c分组为(a - b) - c,这没问题,但a、b、c三个操作数本身谁先被求值,标准并没有规定(C/C++ 里是未指定的)。对于没有副作用的操作数,这无所谓;但一旦操作数里带函数调用或自增自减,就可能出问题。
举个经典的例子:f() - g() - h()。分组是(f() - g()) - h(),但f()、g()、h()的调用顺序是不确定的。如果这三个函数有共享状态或者副作用,结果就可能依赖编译器实现。再比如i++ - i++,分组是(i++) - (i++),但两个i++谁先执行不确定,而且更严重的是,同一个变量在两个操作数里被多次修改且没有序列点分隔,这在 C/C++ 里是未定义行为,不是"结果不确定"那么简单,而是编译器可以做任何事。
所以正确的理解框架是三层:
- 优先级决定不同级别操作符的分组;
- 结合性决定同级别操作符的分组;
- 求值顺序(以及序列点规则)决定操作数实际执行的先后,这一层由语言标准单独规定,和优先级、结合性无关。
很多人把这三层混为一谈,写出i = i++ + ++i这种表达式还觉得"我知道优先级所以没问题",实际上它触犯的是序列点规则,跟优先级一点关系都没有。
3. 那些年我们踩过的优先级坑
3.1 位运算与相等运算的"错位"
前面提过,C 系语言里==的优先级高于&、^、|。这意味着下面这行代码:
if (flags & MASK == 0) { ... }实际被解析成flags & (MASK == 0),而不是(flags & MASK) == 0。MASK == 0先算出一个布尔值(0 或 1),再和flags做按位与,结果几乎肯定不是你想要的。正确写法必须加括号:
if ((flags & MASK) == 0) { ... }这个坑的阴险之处在于它能编译通过,而且很多时候运行结果"看起来正常",直到某个特定输入才暴露。我印象很深的一次排查,某段权限判断代码在测试环境一直没问题,上线后偶发越权,最后定位就是这类位运算优先级问题。编译器其实会给警告(-Wparentheses之类),但很多人把警告当噪音关掉了。
同样的错位还出现在|和==、^和==之间。记忆方法很简单:位运算的优先级整体偏低,夹在相等运算和逻辑运算之间,所以只要位运算和比较运算混用,一律加括号,不要犹豫。
3.2 移位运算与加减法的微妙关系
移位<<>>的优先级低于加减法,高于关系运算。所以a + b << 2解析为(a + b) << 2,这个还算符合直觉。但a << 2 + b解析为a << (2 + b),因为加法优先级更高。如果你本意是(a << 2) + b,那就错了。
更隐蔽的是移位和比较混用:a << 1 < b解析为(a << 1) < b,因为移位优先级高于关系运算,这个是对的。但a < b << 1解析为a < (b << 1),也对。真正容易错的是把移位当成"乘 2 的幂"来用时的复合表达式,比如x << 1 + 1,你以为在算x * 2 + 1,实际是x << 2,也就是x * 4。这种错误在性能敏感的代码里偶尔能见到,因为有人为了"优化"把乘除换成移位,结果优先级没处理好,逻辑全错。
我的建议是:移位运算只要不是单独出现,就加括号。它的优先级位置太尴尬,夹在算术和比较之间,靠记忆判断性价比极低。
3.3 自增自减与复杂表达式的未定义行为
i++和++i单独使用时很清晰,但一旦塞进复杂表达式,问题就来了。先看一个"结果确定但反直觉"的例子:
int i = 5; int j = i++ + i++;很多人以为这是5 + 6 = 11,或者5 + 5 = 10。实际上这是未定义行为,因为两个i++之间没有序列点,同一个变量被修改了两次且中间没有确定的求值顺序,标准对此不作任何保证。不同编译器、不同优化级别下结果可能不同,甚至同一编译器不同版本都不一样。
再看i = i++ + 1,同样是未定义行为。i++修改了i,而i又在同一个表达式的其他地方被读取,读写之间没有序列点,标准说"行为未定义"。注意,这里的问题不是优先级或结合性,而是序列点规则。优先级告诉你i++ + 1怎么分组,但分组完了之后,i的读取和i++的写入谁先谁后,标准不管,于是整个表达式就悬空了。
C++17 之后对求值顺序做了一些收紧(比如a = b里b先于a求值),但i++ + i++这类仍然是未定义行为。Java 和 C# 的规则更严格,规定了从左到右的求值顺序,所以i++ + i++在 Java 里结果是确定的(先取i再加,再取再加),但这不代表你应该这么写——可读性极差,而且换个语言就废。
实操建议只有一条:不要在同一个表达式里对同一个变量做多次修改,也不要在修改它的同时读取它。需要多个自增,拆成多行。这不是保守,是避免把时间浪费在排查"为什么这段代码换个编译器结果就变了"上。
3.4 逗号表达式与函数参数的混淆
逗号在 C/C++ 里有两个身份:逗号操作符和参数分隔符。f(a, b)里的逗号是分隔符,不是操作符;而a = (b, c)里的逗号是操作符,先算b再算c,整个表达式的值是c。逗号操作符的优先级是所有操作符里最低的,比赋值还低。
这个特性导致一些迷惑写法:
int x = (a, b); // x 等于 b,a 被求值但丢弃 int y = a, b; // 这是声明两个变量,不是逗号表达式int y = a, b;里逗号是声明符分隔符,y初始化为a,b是另一个未初始化的变量。如果你以为这是逗号表达式,就会对b的状态产生误判。函数调用f((a, b))里双括号才是把逗号表达式作为单个参数传进去,单括号f(a, b)是两个参数。这种细节在宏定义里尤其容易出问题,因为宏展开后括号层级可能变化。
逗号表达式在实际代码里用得不多,主要出现在for循环的更新部分(for (i = 0, j = n; i < j; i++, j--))和某些宏里。我的态度是:除了for循环这种惯用法,其他地方尽量别用逗号表达式,可读性太差,收益太低。
4. 不同语言里的优先级差异与迁移注意
4.1 Python 的幂运算与比较链
Python 的优先级体系和 C 系有几处关键不同,跨语言写代码时特别容易栽。最典型的是幂运算**:它是右结合的,而且优先级高于一元负号。所以-2 ** 2在 Python 里是-(2 ** 2) = -4,不是(-2) ** 2 = 4。这个设计在数学上更自然(负号是"对整个幂取负"),但和很多人的直觉相反。
另一个 Python 特色是比较链:a < b < c等价于a < b and b < c,而不是(a < b) < c。这是 Python 独有的语法糖,在 C 系语言里a < b < c会先算a < b得到布尔值,再和c比较,几乎总是错的。从 C 转到 Python 的人如果保留a < b < c的写法,在 Python 里反而"歪打正着"是对的,但反过来从 Python 转 C 就会踩坑。
还有not、and、or的优先级:not高于and高于or,和 C 系的!、&&、||对应,但 Python 里not a == b解析为not (a == b),因为比较运算优先级高于not。这个和 C 里!a == b解析为(!a) == b不同,需要留意。
4.2 JavaScript 的??与逻辑运算混用限制
JavaScript 在 ES2020 引入了空值合并操作符??,它和||的优先级关系有个特殊规定:??不能和||、&&直接混用而不加括号,否则语法错误。比如a || b ?? c会直接报 SyntaxError,必须写成(a || b) ?? c或a || (b ?? c)。
这个限制是语言设计者故意加的,因为??和||的语义太容易混淆:||在左操作数为假值(0、''、false、null、undefined、NaN)时取右边,而??只在null和undefined时取右边。混用时如果不加括号,读者根本猜不出你的意图,所以干脆禁止。这个设计我觉得很值得借鉴——当优先级规则无法清晰表达意图时,用语法强制加括号。
另外 JavaScript 的**也是右结合,且不允许一元操作符直接出现在左边:-2 ** 2是语法错误,必须写-(2 ** 2)或(-2) ** 2。这和 Python 的处理方式不同,跨语言时要注意。
4.3 一张跨语言对照表
为了把差异说清楚,我整理了几个高频操作符在主流语言里的行为对比:
| 表达式 | C/C++ | Python | JavaScript | Java |
|---|---|---|---|---|
-2 ** 2/-2 ** 2 | 不适用(无**) | -4 | 语法错误 | 不适用 |
a < b < c | (a<b)<c,通常错 | a<b and b<c | (a<b)<c,通常错 | (a<b)<c,通常错 |
a & b == c | a & (b==c) | 不适用 | a & (b==c) | a & (b==c) |
a ?? b || c | 不适用 | 不适用 | 语法错误 | 不适用 |
a = b = c | 右结合,合法 | 不支持链式赋值 | 右结合,合法 | 右结合,合法 |
这张表不是让你背,而是提醒你:优先级规则是语言规范的一部分,不是普适真理。换语言时,对拿不准的表达式,查该语言的官方文档或者直接加括号,别拿旧语言的直觉硬套。
5. 实战中怎么处理优先级问题
5.1 加括号的判断标准
"多加括号"是老生常谈,但加多了也烦,代码会变得很吵。我自己的判断标准是三条:
第一,跨层级混用时加括号。算术和比较混用、位运算和比较混用、逻辑和赋值混用,这些跨大层级的组合,括号能显著降低阅读成本。比如(a + b) * c虽然+优先级本来就低于*,不加括号也对,但加了之后读者一眼就知道你想先算加法,不用在脑子里过优先级表。
第二,同层级但结合性不直观时加括号。a - b - c左结合是常识,可以不加;但a = b = c右结合、a ? b : c ? d : e右结合,这些加括号能避免读者误判。尤其是条件表达式嵌套,加括号或者换行对齐,可读性提升明显。
第三,涉及副作用时拆行而不是加括号。i++ + i++加括号也没用,因为问题不在分组而在序列点。这种情况必须拆成多行,用临时变量承接中间结果。
反过来,有些括号是多余的,比如(a * b) + c里*本来就高于+,括号纯属噪音。但我的观点是:宁可多一个括号,不要少一个。代码是给人看的,编译器不在乎那点括号,读者在乎。
5.2 用编译器警告兜底
现代编译器对优先级问题有相当不错的检测能力。GCC 和 Clang 的-Wparentheses能抓出a & b == c、a = b = c这类可疑写法,-Wsequence-point能抓出部分序列点问题。MSVC 也有对应的警告。我的建议是:把这类警告当错误处理(-Werror),别让它们淹没在输出里。
具体配置上,GCC/Clang 至少开-Wall -Wextra,然后针对优先级单独确认-Wparentheses是开的(-Wall里包含)。如果项目允许,再加-Werror=parentheses把这类警告升级为错误。这样一旦有人写出flags & MASK == 0,CI 直接挂掉,比 code review 时靠人眼抓靠谱得多。
静态分析工具也能补位,比如 clang-tidy 的readability-parentheses检查、各种 linter 的no-mixed-operators规则(ESLint 里就有)。ESLint 的no-mixed-operators我特别推荐在 JavaScript 项目里开,它能强制你在&&和||混用、算术和位运算混用时加括号,从源头上消灭这类问题。
5.3 代码审查时的检查清单
做 code review 时,遇到复杂表达式我会快速过一遍这几个点:
- 有没有位运算和比较运算混用?有就要求加括号。
- 有没有在同一个表达式里对同一变量多次自增自减?有就要求拆行。
- 有没有链式赋值或嵌套三元?确认结合方向符合意图。
- 有没有依赖求值顺序的副作用?比如
f() + g()里两个函数有共享状态。 - 有没有跨语言的"惯性写法"?比如从 Python 带来的
a < b < c。
这几条过一遍,大部分优先级相关的隐患都能拦住。审查时别只说"这里加个括号",最好说清楚"因为==优先级高于&,不加括号会先算比较",让作者理解原因,下次自己就会注意。
6. 几个值得反复琢磨的边界案例
6.1*p++到底怎么解析
指针操作里*p++是个经典案例。后缀++的优先级高于解引用*,所以它解析为*(p++):先取p当前指向的值,然后p自增指向下一个元素。如果你想的是"取p指向的值然后值加一",那应该写(*p)++。这两个写法语义完全不同,但长得只差一对括号。
这个例子好在它能同时体现优先级(后缀高于一元)和副作用(p++的副作用在表达式求值后生效)。理解它的关键是记住:后缀操作符优先级最高(仅次于括号和成员访问),一元操作符次之。所以*p++、*p--、&a[i]++(这个其实不合法,因为a[i]++返回右值)这类组合,都是后缀先结合。
6.2a = b ? c : d = e的解析
这个表达式涉及赋值和条件运算符的优先级关系。赋值=的优先级低于?:,所以解析为a = (b ? c : (d = e))。注意d = e作为?:的第三个操作数,因为?:的右操作数可以是赋值表达式。整个表达式的意思是:如果b为真,a取c;否则先执行d = e,a取e的值。
这种写法在实际代码里很少见,但面试题里常出现。理解它的价值不在于会写,而在于遇到类似结构时能快速判断分组。判断方法还是画树:=最低,做根;右边是?:;?:的第三分支是d = e,因为?:的右结合和赋值的位置关系。
6.3 逻辑运算的短路与优先级配合
&&和||的短路求值是另一个常被和优先级混淆的概念。a && b里如果a为假,b根本不求值,这是语义层面的短路,和优先级无关。但因为&&优先级高于||,a || b && c解析为a || (b && c),短路行为也按这个分组走:先看a,为真则整个为真,b && c不求值;a为假才去算b && c,其中b为假则c不求值。
这个配合在写守卫条件时很有用,比如if (ptr != NULL && ptr->value > 0),靠&&的短路保证ptr非空才解引用。但要注意,短路只保证分组内的求值顺序,a || b && c里a和b && c谁先算,标准没规定(虽然实践中几乎都是从左到右),所以别在操作数里塞有副作用的表达式来赌顺序。
7. 把优先级意识变成肌肉记忆
写了这么多,核心其实就几句话:优先级管分组,结合性管方向,求值顺序是另一套规则;跨层级混用加括号,同层级不直观也加括号,有副作用就拆行;不同语言规则不同,别拿旧直觉套新语言;编译器警告和 linter 规则能兜底,别关掉。
我自己的习惯是,写表达式时先想"读者能不能一眼看出分组",看不出来就加括号。刚开始会觉得括号多,写久了会发现,那些真正需要精确控制求值顺序的地方,括号反而是最好的注释。至于优先级表,不用背,遇到拿不准的画个树、查个文档,几次下来高频的那几组自然就记住了。真正要警惕的是那些"能编译、能跑、但语义错了"的隐蔽问题,位运算和比较混用、自增自减塞进复杂表达式、跨语言惯性写法,这几个是重灾区,值得在 code review 清单里常驻。
最后分享一个我用了很久的小技巧:当你 review 一段复杂表达式时,试着把它改写成等价的、每步一个操作的临时变量版本。如果改写过程中发现某一步的语义和你原本理解的不一样,那大概率就是优先级或结合性在作祟。这个"改写验证法"比盯着表达式硬看有效得多,也能顺便提升代码可读性。