新手刚开始写 C 语言,最容易卡住的地方可能不是指针,不是内存,而是那种看着很简单、一跑就出错的表达式。比如a & 0xFF == 0x80到底先算哪个,*p++到底取的是旧地址还是新地址,a = b == c又是把什么赋给了a。这些问题其实全都能在“运算符优先级和结合性”这张表里找到答案,只是大多数教程把这张表甩给你就完事了,根本没说清楚怎么记、怎么用。我写这篇总结的目的很简单:把这套规则彻底讲透,给你一张能直接对照的速查表,再配几套我亲测好用的记忆口诀和避坑思路,让新手不再靠猜,让老手能快速复查自己的代码。
1. 为什么要花时间背优先级表
1.1 一个看起来没毛病,结果跑偏的表达式
先看一个经常出现在新手作业里的场景:你想判断一个整型变量value的低 8 位是不是0x80,于是很自然地写下:
if (value & 0xFF == 0x80) { // 处理... }这个代码在你的编译器里可能不会报错,但逻辑上已经跑偏了。因为==的优先级高于&,所以编译器实际看到的是:
if (value & (0xFF == 0x80)) { // 0xFF == 0x80 先算,结果是 0 }0xFF == 0x80的结果是 0 或 1,再拿value去按位与这个 0 或 1。哪怕value低 8 位真的是0x80,只要0xFF == 0x80为假,整个表达式结果就是 0,条件永远不成立。这种错误编译期不报错,运行期也不崩溃,但就是结果不对,调试起来非常难受。类似的问题在实际项目里太常见了。
1.2 优先级和结合性到底是什么
优先级解决的是“不同运算符同时出现时,谁先算”的问题。比如a + b * c,因为*的优先级高于+,所以等价于a + (b * c)。
结合性解决的是“优先级相同的一组运算符连续出现时,按什么方向算”的问题。比如a - b - c,减法和减法优先级相同,结合性是从左到右,所以等价于(a - b) - c。而a = b = c,赋值的结合性是从右到左,所以等价于a = (b = c),先把c赋给b,再把结果赋给a。
你可以把优先级想象成命令的排队顺序:优先级高的人先拿到处理权,优先级低的人靠后;结合性则是同一层级里排队的方向,是从左边开始还是从右边开始。理解了这两点,再看任何表达式就不会只靠感觉去猜了。
2. C语言运算符优先级完整速查表
2.1 从高到低的15层结构
下面这张表我按 C 语言标准整理,从最高优先级往下排。我建议你把它保存下来,写代码的时候放在手边当参考,比临时去翻标准文档快得多。
| 优先级 | 运算符 | 说明 | 结合性 |
|---|---|---|---|
| 1(最高) | ()[]->. | 函数调用、数组下标、结构体成员访问 | 从左到右 |
| 2 | !~+-++--*&(类型)sizeof | 逻辑非、按位取反、正负号、自增自减、指针解引用、取地址、强制类型转换、求字节大小 | 从右到左 |
| 3 | */% | 乘法、除法、取余 | 从左到右 |
| 4 | +- | 加法、减法 | 从左到右 |
| 5 | <<>> | 左移、右移 | 从左到右 |
| 6 | <<=>>= | 关系比较 | 从左到右 |
| 7 | ==!= | 相等判断 | 从左到右 |
| 8 | & | 按位与 | 从左到右 |
| 9 | ^ | 按位异或 | 从左到右 |
| 10 | | | 按位或 | 从左到右 |
| 11 | && | 逻辑与 | 从左到右 |
| 12 | || | 逻辑或 | 从左到右 |
| 13 | ?: | 三目条件运算符 | 从右到左 |
| 14 | =+=-=*=/=%=<<=>>=&=^=|= | 赋值及复合赋值 | 从右到左 |
| 15(最低) | , | 逗号运算符 | 从左到右 |
需要特别提醒的是,第 2 组里的+、-、*、&是“一元运算符”,只作用于一个操作数,比如-a、*p、&x,它们和第 3、4 组的双目加减乘除完全不是一个东西。很多人刚开始容易混淆,以为*p里的*是乘法,结果在表达式里算错了优先级。
2.2 优先级细节与结合性交叉说明
表中第 2 层的结合性是从右到左,这一点非常重要。比如*p++,按照从右到左的结合方式,会先解析p++,再解析*,整体等价于*(p++),而不是(*p)++。如果你本来想取指针指向的值再自增,用(*p)++更稳妥;如果想移动指针取下一个位置,*p++是很多老代码里的写法,它移动了指针,返回的是移动前位置的值,这个行为需要心里有数。
第 13 层的三目运算符?:也是从右到左,所以a ? b : c ? d : e会被解析成a ? b : (c ? d : e)。很多语言的三目运算符结合性都是右结合,写嵌套条件时最好用括号明确,不然读代码的人容易产生误解。
第 14 层的赋值运算符从右到左,这正好对应了a = b = c的从右往左赋值。复合赋值同样如此,a += b *= c会先执行b *= c,再把结果累加到a上。
第 15 层的逗号运算符优先级最低。它常出现在for循环的初始化和更新部分,比如for (i = 0, j = 10; i < j; i++, j--)。如果你在普通表达式里写a = 1, b = 2, c = 3,结合性从左到右,整体结果是 3,但需要注意赋值给某个变量时到底赋的是哪个值,这个我在第 4 部分再拆解。
3. 记忆口诀与分组理解法
3.1 最容易上口的一句口诀
网上流传很多口诀,我自己总结了一个两句话版本,适合先建立整体框架:
“括号第一,单目第二,乘除加减再移位,关系相等位运算,逻辑三目赋值逗号垫底。”
这句话对应的是几个大分段:
- 括号第一:
()[]->. - 单目第二:所有一元运算符
- 乘除加减再移位:
*/%,+-,<<>> - 关系相等:
<<=>>=,==!= - 位运算:
&^| - 逻辑:
&&|| - 三目:
?: - 赋值:
=和复合赋值 - 逗号垫底:
,最低
如果想把更细的顺序也记住,可以用另一个版本:
“括号成员一级高,单目兄弟紧跟牢;乘除取余三目跑,加减移位关系找;相等位与异或或,逻辑与或莫混淆;三目赋值向右靠,逗号最低别忘掉。”
第三句“相等位与异或或”藏了一个很重要的细节:==和!=在关系比较之后,按位运算之前,所以==的优先级高于&,这正好呼应了第 1.1 节那个容易出错的例子。
3.2 按家族分组理解优先级
口诀能帮你快速回忆大概顺序,但真正想记牢,还是得理解运算符家族的“自然等级”。我的经验是:越贴近“单个值本身的运算”,优先级越高;越贴近“把多个值组合成一个结果的逻辑判断”,优先级越低。
后缀运算,比如数组下标、函数调用、结构体访问,最接近“取到一个值”的动作,所以优先级最高。一元运算,比如正负号、取反、取地址、解引用,围绕着单个值做变换,所以排第二。算术运算处理的是最基础的数值计算,拍在第三、第四。移位运算本质是位级算术操作,紧跟其后。关系比较和相等判断开始进入“比较两个值”的层面,优先级更低。按位与、异或、或又低于相等判断,因为它们会把两个值按二进制逐位加工。逻辑与、逻辑或主要面向条件判断,优先级再低一档。三目运算符是条件分支的简写,赋值是给变量写入结果,逗号运算符则是一种“按顺序执行多个子表达式”的工具,所以最后垫底。
这样分组有个好处:你不需要死记每一层的精确位置,只需要在脑子里有一条“取值 > 计算 > 比较 > 位加工 > 逻辑判断 > 条件选择 > 赋值 > 顺序执行”的链条,遇到具体表达式时,顺着链条定位就行。实际编程时,如果链条太复杂,直接加括号,我后面会专门说这一点。
3.3 结合性怎么用一句话记住
结合性不用背太多,绝大多数运算符都是从左到右,真正需要特别注意的是三类:
- 单目运算符:从右到左,比如
!*p、-a++ - 三目运算符:从右到左,比如
a ? b : c ? d : e - 赋值运算符:从右到左,比如
a = b = c
我把记忆方法概括成一句话:“高精度运算左到右,单目三目赋值得从右。”从左到右符合我们读文章的习惯,所以大部分运算符都好理解;从右到左的几种情况,本质上都是“先算后面的操作数或子表达式,再往前套操作”。
4. 实例拆解:常见表达式的真实求值过程
4.1 算术与类型转换的经典组合
先来一组基础但容易翻车的表达式。
int a = 5, b = 10, c = 2; double r; r = a + b * c / 2;按优先级,*和/同级别,先算b * c得到 20,再算20 / 2得到 10,最后算a + 10得到 15,所以r是 15.0。这里要注意,b * c和/ 2都是整型运算,20 / 2的结果是整型 10,不会因为r是double就自动在除法时变成浮点。如果你想让除法保留小数,至少要把其中一个操作数写成浮点类型,比如/ 2.0。
再看一个更微妙的例子:
int x = 3, y = 2; double z = x / y * 1.5;x / y因为两个都是整型,结果是 1,再乘以 1.5,结果是 1.5。很多人以为x / y * 1.5会得到 3 / 2 * 1.5 = 2.25,但实际上整型除法先截断了小数。这不算优先级问题,却常和优先级混在一起被搞混,所以我在排查表达式时,不光看运算符顺序,还会确认每个操作数的类型。
再看移位和加法混在一起的情况:
int n = 1; int result = n << 2 + 1;很多人会以为等价于(n << 2) + 1,也就是 4 + 1 = 5。但加法优先级高于移位,所以实际是n << (2 + 1),也就是1 << 3,结果是 8。这个例子非常经典,几乎每个 C 语言社区都会提到,算是检验是否真正理解优先级的一道送分题。
4.2 位运算和逻辑运算混搭的陷阱
位运算和关系、逻辑运算混搭时,优先级问题最头大。先看这个:
int flags = 0x05; // 0b101 int mask = 0x01; // 0b001 if (flags & mask == mask) { // 想判断低位是否为 1 }因为==的优先级高于&,实际表达式被解析成flags & (mask == mask)。mask == mask结果为 1,flags & 1,只有当flags最低位是 1 时结果才为 1。看起来碰巧也能工作,因为flags是0b101,最低位确实是 1,所以条件成立。但如果flags是0x04,你想判断0x04和掩码0x01的关系,直觉上不相等,条件应该不成立,而无括号的写法会是0x04 & (1 == 1)=0x04 & 1= 0,恰好也不成立。可这种“碰巧”很容易掩盖真实的表达式意图,换一个掩码立马翻车。比如:
int flags = 0x80; int mask = 0x08; if (flags & mask == mask) { ... }你想判断flags是不是包含mask,即0x80 & 0x08是否等于0x08。无括号版本会算成flags & (0x08 == 0x08),结果就是0x80 & 1,除非最低位是 1,否则结果 0,条件不成立。而正确的判断应该是(flags & mask) == mask,这两个表达式在逻辑上完全不是一回事。遇到位运算和比较运算混合,我的习惯是给位运算部分强制加括号,不要依赖优先级。
再看逻辑运算中的短路径问题:
int p = 1; if (p != 0 && 10 / p > 2) { // ... }&&的优先级低于!=和/、>,所以这个表达式能被正确解析为(p != 0) && ((10 / p) > 2)。因为逻辑与有短路特性,p != 0为假时,后面的10 / p不会执行,这样避免了除零错误。反过来,如果你把&&和赋值混在一起,比如if (a && b = 1),编译器直接报错,因为赋值优先级低于&&,而赋值左边要求是左值,(a && b)不是一个可用作左值的表达式,报错反而能提醒你加括号。这类编译错误只要多看几遍就能记住。
4.3 赋值、三目、逗号运算符的优先级边界
赋值运算符优先级很低,基本只比逗号高,这导致它经常被“困”在表达式末尾附近。
int a = 1, b = 2, c; c = a + b > 2 ? 10 : 20;这里的解析过程是:a + b优先算,得到 3;3 > 2为真;三目运算符根据真值返回 10;再把 10 赋给c。因为赋值运算符优先级最低,所以整个c = ...是最后才完成的。如果你不熟悉优先级,可能会困惑到底>和三目谁先,其实按“计算 > 比较 > 条件选择 > 赋值”的链条,思路就顺了。
三目运算符和赋值一起出现时,右结合性很容易表达嵌套选择:
int x = 1, y = 2, z = 3; int max = x > y ? x : (y > z ? y : z);虽然我习惯给嵌套三目加括号,但要知道没有括号时,右结合性也会把它解析成x > y ? x : (y > z ? y : z)。可读性角度,嵌套三目前我强烈建议加括号,不然同行 review 代码时会想揍你。
逗号运算符最容易出现在for循环里,但如果在普通表达式里用,容易掉坑:
int a = 1, b = 2; int c; c = (a++, b += a, a + b);逗号表达式从左到右依次执行:先a++,此时 a = 2;再b += a,b = 2 + 2 = 4;最后a + b得到 6,整体表达式的值是 6,赋给c。如果没有括号,c = a++, b += a, a + b就完全不同,因为赋值优先级高于逗号,c = a++会先执行,然后b += a,最后a + b,但c只拿到a++的结果 1。这就是第 2.2 节里“逗号最低”带来的具体表现。
5. 常见错误排查与避坑技巧
5.1 什么时候别信优先级,直接加括号
我在实际项目里有一条很硬的经验:可以背优先级,但不要在关键代码里依赖过于冷门的优先级关系。尤其是下面这些组合,必须加括号:
- 位运算和比较运算混用:
(flags & mask) == expected - 移位和算术运算混用:
(n << 2) + 1 - 赋值表达式嵌到条件里:
if ((a = get_value()) > 0) - 逗号表达式需要整体结果:
(a++, b++, a + b) - 三目表达式和逻辑或混在一起:
(a || b) ? c : d
原因很简单:编译器按标准规则解析,但读代码的人不一定记得住每个优先级。与其让读者折磨,不如用括号把表达式的边界画清楚。括号不会拖慢程序,因为编译器在优化阶段会直接处理掉多余括号,不会生成额外的指令,这点不用担心性能。项目规范里甚至可以约定“凡是包含位运算符、逻辑运算符、赋值运算符混合的表达式,必须显式加括号”。
5.2 几个让你一眼看出问题的检查方法
我在排错时,会按下面几个步骤快速判断一个表达式是否写对了:
- 先把表达式里所有后缀运算符、单目运算符找出来,从右到左分析单目运算符的作用对象。
*p++、-a--这类最容易在自增自减上翻车。 - 再圈出算术运算符,确认乘除取余优先于加减,移位有没有被算术运算干扰。看到
<<和+组合,立刻检查是否需要括号。 - 接着找关系、相等判断,检查是否被位运算拆散。
x & mask == y这类基本都要修正。 - 逻辑运算符要看短路范围。
&&和||两侧的表达式边界是不是自己想要的,尤其当右边有赋值、函数调用时,花括号范围容易失控。 - 最后看赋值、三目、逗号。赋值右结合导致
a = b = c会连续赋值,三目右结合让嵌套方向从右向左,逗号最低导致需要整体括起来。
如果你不确定某个复杂表达式怎么解析,还有一个土办法:用printf逐步输出中间值。比如先用临时变量拆分,再合并验证。这个方法虽然笨,但能很快确定是优先级问题还是数据问题。我在教初学者时,经常让他们先手写一步展开,再打印验证,时间长了自然就对优先级有感觉了。
5.3 常见错误速查表
| 错误写法 | 实际解析结果 | 正确写法 |
|---|---|---|
flags & mask == mask | flags & (mask == mask) | (flags & mask) == mask |
n << 2 + 1 | n << (2 + 1) | (n << 2) + 1 |
*p++ | *(p++) | (*p)++(如果想自增值) |
a = b == c | a = (b == c) | (a = b) == c(如果真想比较赋值结果) |
a, b = c | (a), (b = c) | (a, b) = c(但 C 里不能用逗号组合作为左值,需换写法) |
| `a | b && c` | |
if (a & 1 = 1) | 编译错误,赋值左边不是左值 | if ((a & 1) == 1) |
这个表里的错误我都实际见过,有些出现在笔试填空题里,有些出现在同事的代码评审里,有些是我自己早年调试到怀疑人生时发现的。最典型的就是第一个和第二个,属于“看着很顺眼,跑起来不对”的代表。
6. 我的实操总结与建议
优先级和结合性不是靠背一遍就能一劳永逸的,它更像一种手感,需要在读代码、写代码、查编译错误的过程中反复强化。我给你几个具体建议:
第一,把速查表打印出来贴工位或写在笔记软件里,写代码时不确定就查,不要凭着模糊记忆硬写。我自己接触 C 语言前两年,遇到位运算和比较运算混合也照样查表,这没什么丢人的。
第二,做一个小测试集,把常见易错表达式丢进本地环境里跑一遍,观察输出结果。比如*p++、a & 0xFF == 0x80、n << 2 + 1、a = b == c、a += b *= c,每个都打印结果,再和手推对比。这个过程能帮你建立直觉,以后写代码时一眼就看出问题。
第三,阅读开源代码时留意别人的写法。很多老练的 C 程序员会刻意避免写过于依赖优先级的表达式,他们更愿意多写一两个括号,让代码自己说话。你看到(flags & MASK) == EXPECTED这种写法,不是因为他们不懂优先级,而是因为他们希望读代码的人不需要懂优先级也能看明白。
第四,遇到编译警告不要忽视。现代编译器在打开-Wall后,会对你可能写错的表达式给出括号提示,比如 GCC 的-Wparentheses就会在类似a & b == c的表达式上警告。把警告当成免费的代码顾问,常年开启。我自己的习惯是,几乎任何项目都开-Wall -Wextra,宁可看上百条警告,也不想让一条隐藏 bug 等到运行期才暴露。
最后分享一个小技巧:如果你在改一段有复杂表达式的老代码,最快搞懂优先级的方式不是打开标准文档,而是把原表达式复制到本地,配上几组输入数据打印中间结果。比如先拆成temp1 = (flags & mask); temp2 = (temp1 == mask);,再合并对比,整个过程不到五分钟,比干看代码强太多。学习优先级最终是为了消灭“运行时才知道写错了”的尴尬,所以别怕用最笨的方法去验证,验证得多了,你自然就成了别人眼里“一看就会”的人。