1. 为什么运算符优先级值得单独拎出来讲
刚接触C语言那会儿,我总觉得运算符优先级这东西属于“查表就行”的知识点,没必要花时间深究。直到有一次写一个状态机的判断逻辑,代码大概长这样:
if (a & b == c) { // 期望是 (a & b) == c // 实际是 a & (b == c) }这个bug我调了整整一个下午,打印了各种中间变量才反应过来——==的优先级比&高。从那以后我就明白了,运算符优先级和结合性不是背下来应付考试的,而是实打实影响代码正确性的基本功。C语言一共有34种运算符(不同教材统计口径略有差异,有的算上sizeof、_Alignof等会更多),它们之间的优先级关系构成了表达式求值的“交通规则”。你不记住这些规则,编译器可不会帮你猜意图,它只会严格按照优先级表来执行。
这篇文章我打算把C语言运算符优先级和结合性彻底讲透。不是简单甩一张表让你背,而是从设计逻辑、记忆方法、实战踩坑三个维度展开。适合已经写过一些C代码、但对表达式求值顺序还不太有把握的朋友,也适合想系统梳理一遍基础知识的开发者。读完你至少能做到两件事:一是看到复杂表达式能快速判断求值顺序,二是在写代码时主动用括号消除歧义,而不是依赖记忆。
2. 运算符优先级与结合性的底层逻辑
2.1 优先级到底解决什么问题
C语言的表达式求值遵循一套严格的规则。当你写a + b * c的时候,编译器不会从左到右依次计算,而是先算b * c再算加法。这就是优先级在起作用——它决定了当多个运算符出现在同一个表达式中时,哪个先参与运算。
为什么需要优先级?本质上是数学传统的延续。加减乘除的优先级规则从小学数学就开始了,C语言继承了这套约定。但C语言远不止四则运算,它还有位运算、逻辑运算、赋值、逗号表达式等等,这些运算符之间也需要一套秩序,否则同一个表达式在不同编译器下可能得出不同结果,那就乱套了。
优先级的核心作用是消除歧义。比如*p++,到底是(*p)++还是*(p++)?C语言规定后置++优先级高于解引用*,所以是*(p++)——先取p的地址做后置自增,然后解引用原来的地址。这个规则如果不明确,不同编译器实现不一样,代码就没法移植了。
2.2 结合性:优先级相同时的裁决者
优先级解决的是“不同级”运算符的顺序问题,那同级运算符呢?比如a - b - c,减号优先级相同,到底先算哪个?这就轮到结合性出场了。
结合性分两种:左结合和右结合。左结合意味着从左往右算,a - b - c等价于(a - b) - c。右结合意味着从右往左算,比如赋值运算符a = b = c等价于a = (b = c)。
大部分运算符是左结合的,右结合的只有少数几个:赋值运算符(=及其复合形式)、三目运算符?:、单目运算符(如!、~、++前置、--前置、*解引用、&取地址、sizeof等)。这个规律记住之后,很多表达式的求值顺序就一目了然了。
2.3 优先级表的记忆框架
34种运算符如果死记硬背,今天背明天忘。我自己的方法是分层记忆,把优先级从高到低分成几个“梯队”:
| 梯队 | 运算符类别 | 典型代表 | 结合性 |
|---|---|---|---|
| 1 | 后缀运算符 | ()[]->.++--(后置) | 左结合 |
| 2 | 前缀/单目运算符 | !~++--(前置)*&sizeof+-(正负号) | 右结合 |
| 3 | 乘除类 | */% | 左结合 |
| 4 | 加减类 | +- | 左结合 |
| 5 | 移位运算符 | <<>> | 左结合 |
| 6 | 关系运算符 | <<=>>= | 左结合 |
| 7 | 相等运算符 | ==!= | 左结合 |
| 8 | 位与 | & | 左结合 |
| 9 | 位异或 | ^ | 左结合 |
| 10 | 位或 | | | 左结合 |
| 11 | 逻辑与 | && | 左结合 |
| 12 | 逻辑或 | || | 左结合 |
| 13 | 三目运算符 | ?: | 右结合 |
| 14 | 赋值运算符 | =+=-=*=/=%=<<=>>=&=^=|= | 右结合 |
| 15 | 逗号运算符 | , | 左结合 |
这个表看起来有15层,但实际记忆的时候可以压缩成几个关键节点。我个人的记忆口诀是:后缀优先于前缀,单目优先于双目,算术优先于移位,移位优先于关系,关系优先于相等,位运算低于相等但高于逻辑,逻辑低于位运算,三目低于逻辑,赋值低于三目,逗号最低。
这个口诀覆盖了从高到低的主干顺序,剩下的细节可以在实际写代码时查表确认。
3. 逐层拆解:34种运算符的优先级细节
3.1 最高优先级:后缀与成员访问
后缀运算符站在优先级金字塔的顶端。这包括函数调用()、数组下标[]、结构体成员访问->和.、后置自增++、后置自减--。它们的结合性都是左结合。
为什么这些运算符优先级最高?因为它们代表的是“最紧密的绑定关系”。比如arr[i].name,你肯定希望先取arr[i]这个元素,再访问它的name成员,而不是先算别的。再比如func(x).member,先调用函数拿到返回值,再访问成员,这也是符合直觉的。
这里有个容易混淆的点:()作为函数调用运算符和作为分组运算符是两回事。函数调用的()优先级最高,但分组的()是显式改变求值顺序的工具,它本身不参与优先级比较——括号里的内容永远先算。
int arr[5] = {1, 2, 3, 4, 5}; int *p = arr; // 后置++优先级高于* int val = *p++; // 等价于 *(p++),val = 1, p指向arr[1]3.2 单目运算符:右结合的特殊群体
单目运算符包括逻辑非!、按位取反~、前置自增++、前置自减--、解引用*、取地址&、sizeof、正号+、负号-。它们的优先级仅次于后缀运算符,且全部是右结合。
右结合意味着当多个单目运算符连在一起时,从右往左计算。比如!!x,先算右边的!x,再对结果取逻辑非。再比如-*p,先解引用p,再取负值。
sizeof是个比较特殊的存在。它是运算符不是函数,所以sizeof(int)和sizeof x都合法。但要注意sizeof的优先级:sizeof(int) * 2是先算sizeof(int)再乘2,因为sizeof优先级高于*。但如果是sizeof(arr) / sizeof(arr[0])这种常见写法,括号保证了先算两个sizeof再相除。
注意:
sizeof的操作数如果是类型名,必须加括号;如果是变量或表达式,括号可加可不加。但为了可读性,我建议统一加括号。
3.3 算术运算符:最熟悉的陌生人
算术运算符分两级:乘除类(*/%)和加减类(+-)。它们都是左结合,优先级关系和我们小学学的一样。
这里有个新手容易踩的坑:整数除法和取模。7 / 2结果是3不是3.5,因为两个操作数都是整数。7 % 2结果是1。如果想让结果是浮点数,至少要把一个操作数转成浮点类型:7.0 / 2或者(double)7 / 2。
取模运算符%只能用于整数,不能用于浮点数。这是C语言的规定,因为浮点数的取模在数学上定义不唯一(涉及舍入方式),所以标准直接禁止了。
int a = 10, b = 3; printf("%d\n", a / b); // 输出3 printf("%d\n", a % b); // 输出1 printf("%.2f\n", (double)a / b); // 输出3.333.4 移位运算符:容易被忽视的优先级陷阱
移位运算符<<和>>的优先级低于加减,高于关系运算符。这个位置很尴尬,因为很多人会直觉认为移位应该和乘除同级(毕竟左移一位相当于乘2),但实际上它的优先级比加减还低。
看这个例子:
int a = 1; int b = a << 2 + 3; // 实际是 a << (2 + 3),即 1 << 5 = 32很多人会以为是(a << 2) + 3,但+优先级高于<<,所以先算2 + 3。这种bug非常隐蔽,因为代码看起来“很合理”。
移位运算符也是左结合。a << 1 << 2等价于(a << 1) << 2,即左移3位。右移同理。
实操心得:只要表达式里出现移位运算符和加减运算符混用,一律加括号。不要相信自己能记住优先级,加括号是最稳妥的做法。
3.5 关系与相等运算符:连续比较的陷阱
关系运算符(<<=>>=)优先级高于相等运算符(==!=),两者都高于位运算。这个顺序经常让人栽跟头。
最经典的坑是a & b == c。因为==优先级高于&,所以实际是a & (b == c)。如果b == c为真(值为1),结果就是a & 1,只保留最低位。这几乎肯定不是你想要的结果。
另一个坑是数学里的连续比较。在数学中a < b < c表示b在a和c之间,但在C语言中,<是左结合的,a < b < c等价于(a < b) < c。a < b的结果是0或1,然后拿这个0或1去和c比较。这几乎永远不是你想要的。
int a = 1, b = 2, c = 3; // 错误写法 if (a < b < c) { // (1 < 2) < 3 => 1 < 3 => 真,但逻辑是错的 // ... } // 正确写法 if (a < b && b < c) { // ... }3.6 位运算与逻辑运算:优先级倒挂的重灾区
位运算的优先级从高到低是:&>^>|。逻辑运算的优先级是:&&>||。而位运算整体高于逻辑运算。
这个顺序和很多人的直觉相反。很多人以为&和&&优先级差不多,|和||优先级差不多,但实际上&高于^高于|,然后才轮到&&和||。
更坑的是,位运算的优先级低于相等运算符。也就是说a & 0xFF == 0实际是a & (0xFF == 0),即a & 0,结果永远是0。正确的写法是(a & 0xFF) == 0。
// 常见错误:判断最低位是否为1 if (flags & 0x01 == 1) { // 实际是 flags & (0x01 == 1) => flags & 1 // 这个例子碰巧结果对了,但逻辑是错的 } // 正确写法 if ((flags & 0x01) == 1) { // ... }逻辑运算符有个重要特性:短路求值。&&左边为假时右边不执行,||左边为真时右边不执行。这个特性经常被用来做安全检查,比如if (p != NULL && p->value > 0),如果p为空,右边不会执行,避免了空指针解引用。
3.7 三目、赋值与逗号:右结合三兄弟
三目运算符?:、赋值运算符、逗号运算符是C语言中仅有的几个右结合运算符(单目运算符也是右结合,但它们是另一类)。
三目运算符的优先级低于逻辑或,高于赋值。这意味着a = b ? c : d等价于a = (b ? c : d),这是符合直觉的。但a ? b : c = d就不合法了,因为赋值优先级低于三目,c = d不能作为三目的操作数(除非加括号)。
赋值运算符的右结合特性使得连续赋值成为可能:a = b = c = 0等价于a = (b = (c = 0))。这很符合直觉,因为赋值表达式的值就是被赋的值。
逗号运算符优先级最低,左结合。a = 1, b = 2等价于(a = 1), (b = 2)。逗号表达式的值是最后一个表达式的值,所以int x = (1, 2, 3)中x等于3。逗号运算符在实际代码中很少主动使用,主要出现在for循环的初始化和更新部分。
// 逗号表达式在for循环中的典型用法 for (int i = 0, j = 10; i < j; i++, j--) { // ... }4. 实战中的优先级陷阱与避坑指南
4.1 五个最常踩的优先级坑
根据我这些年代码review和调试的经验,下面这五个坑出现的频率最高:
坑一:位运算与相等运算混用。if (a & b == c)是重灾区。记住:相等运算符优先级高于位与。正确写法是if ((a & b) == c)。
坑二:移位与加减混用。a << 2 + 3实际是a << 5。正确写法是(a << 2) + 3或a << (2 + 3),取决于你的意图。
坑三:自增自减与解引用混用。*p++是*(p++),(*p)++是另一回事。前者移动指针,后者增加指针指向的值。
坑四:逻辑非与比较混用。!a == b实际是(!a) == b,因为!优先级高于==。如果你想表达!(a == b),必须加括号。
坑五:三目运算符嵌套。a ? b : c ? d : e是右结合的,等价于a ? b : (c ? d : e)。虽然合法,但可读性极差,建议加括号或改写为if-else。
4.2 用括号消除歧义的最佳实践
我的原则很简单:只要表达式中混合了不同类别的运算符,就加括号。不要依赖记忆,不要考验同事的读代码能力,不要给未来的自己挖坑。
具体来说,以下几种情况必须加括号:
- 位运算和比较运算混用:
(a & mask) == value - 移位和加减混用:
(a << n) + b - 逻辑运算和位运算混用:
(a & b) && (c | d) - 自增自减和其他运算符混用:
(*p)++或*(p++),明确意图 - 三目运算符嵌套:
a ? (b ? c : d) : e
加括号不会影响性能,编译器会生成同样的机器码。但会极大提升代码可读性和可维护性。
4.3 结合性在实际代码中的影响
结合性最直观的影响体现在减法和除法上。a - b - c是(a - b) - c,不是a - (b - c)。a / b / c是(a / b) / c,不是a / (b / c)。这两个如果搞错,结果可能天差地别。
赋值运算符的右结合性使得a = b = c合法且符合直觉。但如果赋值和其他运算符混用,就要小心了。比如a = b + c,因为+优先级高于=,所以先算b + c再赋值,这是符合直觉的。但a = b == c呢?==优先级高于=,所以先算b == c,把结果(0或1)赋给a。这可能是你想要的,也可能不是。
int a, b = 5, c = 5; a = b == c; // a = (b == c) = 1 // 如果你想表达 a = b, 然后比较 a == c,需要写成 (a = b) == c5. 常见问题速查与记忆技巧
5.1 优先级速查表(精简版)
| 优先级 | 运算符 | 结合性 | 记忆要点 |
|---|---|---|---|
| 1 | ()[]->.++--(后置) | 左 | 后缀最紧 |
| 2 | !~++--(前置)*&sizeof+- | 右 | 单目次之 |
| 3 | */% | 左 | 乘除取模 |
| 4 | +- | 左 | 加减 |
| 5 | <<>> | 左 | 移位 |
| 6 | <<=>>= | 左 | 关系 |
| 7 | ==!= | 左 | 相等 |
| 8 | & | 左 | 位与 |
| 9 | ^ | 左 | 位异或 |
| 10 | | | 左 | 位或 |
| 11 | && | 左 | 逻辑与 |
| 12 | || | 左 | 逻辑或 |
| 13 | ?: | 右 | 三目 |
| 14 | =及复合赋值 | 右 | 赋值 |
| 15 | , | 左 | 逗号 |
5.2 常见问题排查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 条件判断结果不符合预期 | 位运算与比较混用 | 检查是否有&|^与==!=混用 | 加括号明确优先级 |
| 数值计算结果偏差 | 移位与加减混用 | 检查<<>>周围是否有+- | 加括号或调整表达式 |
| 指针操作异常 | 自增自减与解引用混用 | 检查*p++类表达式 | 明确写成*(p++)或(*p)++ |
| 连续比较逻辑错误 | 关系运算符左结合 | 检查a < b < c类写法 | 改写为a < b && b < c |
| 赋值结果异常 | 赋值与比较混用 | 检查a = b == c类写法 | 加括号明确意图 |
5.3 我个人的记忆口诀与练习方法
口诀前面已经给了,这里补充一个练习方法:找一段自己以前写的代码,把所有复杂表达式标出来,逐个判断求值顺序,然后和编译器实际行为对比。如果一致,说明你掌握了;如果不一致,恭喜你发现了一个潜在bug。
另一个方法是写单元测试。对于每个容易混淆的表达式,写一个断言验证你的理解:
#include <assert.h> int main() { int a = 1, b = 2, c = 3; assert((a & b == c) == (a & (b == c))); // 验证优先级 assert((a < b < c) == ((a < b) < c)); // 验证结合性 assert((a = b = c) == (a = (b = c))); // 验证右结合 return 0; }这种测试驱动的方式比死记硬背有效得多,因为你在主动验证而不是被动记忆。
最后分享一个小技巧:如果你实在记不住某个优先级关系,就在代码里加括号。括号不花钱,但bug会花时间。我写了十几年代码,到现在遇到位运算和比较混用的时候还是会条件反射地加括号。这不是记性不好,这是职业习惯。