1. 为什么运算符优先级值得单独拎出来讲
很多人学C语言的时候,运算符优先级这张表通常是扫一眼就翻过去了。原因也很简单——写代码的时候多加几个括号不就完了?这个想法本身没错,但问题在于,你不可能给每一行代码都套上括号,而且当你读别人代码的时候,尤其是读一些底层库、宏定义、或者嵌入式驱动代码的时候,运算符优先级和结合性直接决定了你能不能看懂那行代码到底在干什么。
我见过太多这样的情况:一段代码逻辑看起来没问题,编译也不报错,但运行结果就是不对。排查半天,最后发现是*p++和(*p)++搞混了,或者是a & b == c这种表达式被编译器理解成了a & (b == c)。这类问题不是靠调试器能快速定位的,因为编译器完全按照优先级规则来解析,它觉得你的代码没问题,只是你的意图和它的理解不一致。
C语言一共有34种运算符(不同教材和标准的统计口径略有差异,有的算上sizeof、_Alignof等,有的把复合赋值拆开算,这里按常见的34种来梳理),它们的优先级从高到低分为15个层级。这个层级关系不是随便定的,背后有一套设计逻辑:越靠近“原子操作”的运算符优先级越高,越偏向“整体运算”的优先级越低。理解了这个逻辑,你就不需要死记硬背那张表了。
这篇文章适合所有写C语言的人看——不管你是刚学完基础语法的新手,还是写了几年代码但一直靠加括号规避问题的老手。我会把34种运算符的优先级和结合性拆开讲清楚,更重要的是告诉你哪些地方最容易踩坑、怎么快速判断一个复杂表达式的求值顺序、以及在实际编码中怎么用最少的括号写出最清晰的代码。
2. 从“原子”到“整体”:优先级层级的底层逻辑
2.1 最高优先级那一档:后缀运算符
优先级最高的四个运算符是:()(函数调用)、[](数组下标)、->(结构体指针成员访问)、.(结构体成员访问)。这四个加上后缀++和--,构成了优先级的第一梯队。
为什么它们最高?因为它们都是“取值”操作——从一个已有的对象中取出某个部分或者调用某个函数。你可以把它们理解成“原子操作”,就像化学里的原子一样,是构成更大表达式的基本单元。比如arr[i].name,先通过[]取出数组元素,再通过.取出成员,这两个操作都是“取值”,所以优先级最高,从左到右依次执行。
这里有一个容易忽略的点:后缀++和--的优先级和()、[]、->、.是同一档的,结合性也是从左到右。这意味着p->next++的意思是(p->next)++,而不是p->(next++)。同理,arr[i]++是(arr[i])++。这个规则在实际写链表操作的时候特别重要。
2.2 单目运算符:从右向左的“改造”操作
第二梯队是单目运算符:前缀++、前缀--、+(正号)、-(负号)、!(逻辑非)、~(按位取反)、*(解引用)、&(取地址)、sizeof、(type)(强制类型转换)。
这些运算符的特点是“单目”——只作用于一个操作数,而且结合性是从右向左。什么叫从右向左?就是!!x先算右边的!x,再算左边的!。*&x先算&x取地址,再算*解引用,结果就是x本身。
这里有一个非常经典的坑:*p++。很多人第一反应是“先取p指向的值,然后p自增”。但实际上,后缀++的优先级高于单目*,所以这个表达式等价于*(p++)——先执行p++(返回p的旧值,同时p指向下一个位置),然后对旧值解引用。也就是说,你取到的是p原来指向的那个值,但p本身已经移动了。如果你想取当前值然后移动指针,*p++是对的;如果你想取当前值但不动指针,应该写(*p)++或者直接*p。
再比如sizeof和强制类型转换。sizeof(int)*p的意思是(sizeof(int)) * p,因为sizeof的优先级高于*。而(int*)p是把p强制转换成int*类型,这里的(int*)是一个整体,优先级和单目运算符同级。
2.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),结果算出来的值差很多。
2.4 移位运算符:比算术低,比关系高
第四梯队是移位运算符:<<和>>。它们的优先级低于算术运算符,但高于关系运算符。
这意味着a + b << 2等价于(a + b) << 2,而不是a + (b << 2)。这个规则在设计位操作的时候很有用——你可以先做加法再做移位,不需要额外加括号。
但反过来,a << 2 + b等价于a << (2 + b),因为+的优先级高于<<。这个就很容易出问题了。我见过有人在写嵌入式代码的时候,想表达“a左移2位然后加b”,结果写成了a << 2 + b,实际执行的是“a左移(2+b)位”,结果完全不对。
2.5 关系运算符:比移位低,比相等高
第五梯队是关系运算符:<、<=、>、>=。第六梯队是相等运算符:==、!=。
为什么要把关系运算符和相等运算符分开?因为a < b == c < d这种表达式,按照优先级会解析成(a < b) == (c < d),也就是先比较大小,再比较两个比较结果是否相等。这个规则在写条件判断的时候偶尔会用到,但大多数情况下,这种表达式可读性很差,建议加括号。
这里有一个非常经典的坑:a & b == c。很多人想表达“a和b按位与的结果等于c”,但==的优先级高于&,所以实际解析成a & (b == c)。如果b和c都是布尔值或者0/1,这个表达式可能还能跑,但逻辑完全错了。正确的写法是(a & b) == c。
2.6 位运算符:容易被忽略的优先级陷阱
第七梯队是&(按位与),第八梯队是^(按位异或),第九梯队是|(按位或)。
这三个运算符的优先级都低于相等运算符,这意味着a & b == c会被解析成a & (b == c),a | b == c会被解析成a | (b == c)。这个规则几乎每年都会让一批人踩坑。
为什么C语言要这样设计?因为位运算符的优先级设计参考了逻辑运算符的优先级——&&高于||,对应的&高于^高于|。但问题在于,==的优先级被放在了&上面,这就导致了“先比较再按位与”的解析顺序,和大多数人的直觉相反。
实际编码中,只要表达式中同时出现了位运算符和比较运算符,一律加括号。这不是可读性的问题,是正确性的问题。
2.7 逻辑运算符:短路求值的优先级基础
第十梯队是&&(逻辑与),第十一梯队是||(逻辑或)。
这两个运算符的优先级关系是&&高于||,这个和数学里的“与高于或”一致。结合性都是从左到右。
逻辑运算符的优先级低于位运算符,这意味着a & b && c | d会被解析成(a & b) && (c | d)。这个规则在写条件判断的时候需要注意——如果你想表达“a和b按位与的结果非零,并且c和d按位或的结果非零”,这个表达式是对的;但如果你想表达别的意思,就需要加括号。
另外,逻辑运算符的短路求值特性是C语言的一个重要机制。a && b中,如果a为假,b不会被执行;a || b中,如果a为真,b不会被执行。这个特性依赖于优先级——&&先于||求值,所以a || b && c等价于a || (b && c),如果a为真,整个表达式直接为真,b && c根本不会执行。
2.8 条件运算符:三目运算的右结合性
第十二梯队是?:(条件运算符)。它的优先级低于逻辑运算符,高于赋值运算符。结合性是从右向左。
从右向左的结合性意味着a ? b : c ? d : e等价于a ? b : (c ? d : e)。这个规则在写嵌套条件表达式的时候很有用,但嵌套太多层可读性会很差,建议拆成if-else。
条件运算符的优先级低于逻辑运算符,这意味着a > b ? c : d等价于(a > b) ? c : d,这个符合直觉。但a ? b : c = d会出问题——因为赋值运算符的优先级低于条件运算符,所以这个表达式等价于(a ? b : c) = d,而不是a ? b : (c = d)。如果c是一个左值,这个表达式可能能编译通过,但逻辑完全错了。
2.9 赋值运算符:从右向左的“链式赋值”
第十三梯队是赋值运算符:=、+=、-=、*=、/=、%=、<<=、>>=、&=、^=、|=。它们的优先级只比逗号运算符高,结合性是从右向左。
从右向左的结合性使得链式赋值成为可能:a = b = c = 0等价于a = (b = (c = 0))。这个规则大家都很熟悉。
赋值运算符的优先级低于条件运算符,这意味着a = b > c ? d : e等价于a = ((b > c) ? d : e),这个符合直觉。但a = b ? c : d = e会出问题——因为=的优先级低于?:,所以这个表达式等价于a = ((b ? c : d) = e),而不是a = (b ? c : (d = e))。
2.10 逗号运算符:最低优先级的“顺序执行”
第十四梯队是,(逗号运算符)。它的优先级最低,结合性从左到右。
逗号运算符的作用是“顺序执行”——先算左边的表达式,再算右边的表达式,整个表达式的值是右边表达式的值。比如a = (b = 1, c = 2),先执行b = 1,再执行c = 2,最后把c = 2的值(也就是2)赋给a。
逗号运算符的优先级最低,这意味着a = 1, 2等价于(a = 1), 2,a的值是1,整个表达式的值是2。如果你想表达a = (1, 2),需要加括号。
在实际编码中,逗号运算符很少单独使用,大多数情况下出现在for循环的初始化和更新部分。比如for (i = 0, j = n; i < j; i++, j--),这里的逗号就是逗号运算符。
3. 结合性:同优先级下的求值方向
3.1 左结合与右结合的分界线
结合性解决的是“同优先级运算符连续出现时,先算哪边”的问题。C语言中,大多数运算符是左结合的,只有单目运算符、条件运算符、赋值运算符是右结合的。
左结合的意思是“从左往右算”。比如a - b - c,先算a - b,再算(a - b) - c。右结合的意思是“从右往左算”。比如a = b = c,先算b = c,再算a = (b = c)。
这个分界线其实很好记:所有“单目”和“赋值”类的运算符都是右结合,其他都是左结合。单目运算符包括前缀++、前缀--、+、-、!、~、*、&、sizeof、(type)。赋值运算符包括=和所有复合赋值。条件运算符?:也是右结合。
3.2 右结合带来的实际影响
右结合在实际编码中最常见的体现就是链式赋值和嵌套条件表达式。
链式赋值a = b = c = 0,因为=是右结合,所以先算c = 0,再算b = (c = 0),最后算a = (b = (c = 0))。这个规则大家都很熟悉,但有一个细节:a = b = c中,b = c的值是赋值后b的值,所以a和b最终都等于c的值。
嵌套条件表达式a ? b : c ? d : e,因为?:是右结合,所以等价于a ? b : (c ? d : e)。这个规则在写一些紧凑的条件判断时很有用,但嵌套超过两层就会严重影响可读性。
单目运算符的右结合性体现在!!x、*&x、- -x(注意中间有空格,否则会被解析成--x)这些表达式中。!!x先算右边的!x,再算左边的!,结果是把x转换成布尔值。*&x先算&x取地址,再算*解引用,结果就是x本身。
3.3 左结合中的“隐形陷阱”
左结合看起来更符合直觉,但也有一些容易忽略的陷阱。
a / b * c是左结合,等价于(a / b) * c。如果a=10,b=3,c=2,结果是(10/3)*2 = 3*2 = 6。如果你误以为是10/(3*2) = 10/6 = 1,那就错了。这个在写浮点运算或者整数除法的时候特别容易出问题。
a << b << c是左结合,等价于(a << b) << c。如果a=1,b=2,c=3,结果是(1<<2)<<3 = 4<<3 = 32。如果你误以为是1<<(2<<3) = 1<<16 = 65536,那就差远了。
a & b & c是左结合,等价于(a & b) & c。按位与满足结合律,所以这个其实无所谓。但a - b - c不满足结合律,左结合和右结合的结果完全不同。
4. 那些年我们踩过的优先级坑
4.1*p++与(*p)++:指针操作的经典陷阱
这是C语言优先级问题中出现频率最高的一个。*p++等价于*(p++),先取p的旧值,然后p自增,最后对旧值解引用。(*p)++等价于先解引用p,然后对p指向的值自增,p本身不动。
我见过一个真实的案例:某开发者在写字符串遍历的时候,想用*p++来逐个字符处理,但他在循环条件里写成了while (*p++),结果每次循环p都会多移动一次,导致跳过了某些字符。正确的写法应该是while (*p)然后手动p++,或者用for循环明确控制p的移动。
还有一个类似的坑:*p++ = *q++。这个表达式等价于*(p++) = *(q++),先取p和q的旧值,然后两个指针都自增,最后把q旧值指向的内容赋给p旧值指向的位置。这个写法在字符串复制或者内存拷贝中很常见,但如果你不理解优先级,就会觉得这行代码“莫名其妙”。
4.2a & b == c:位运算与比较的优先级错位
这个坑我在前面已经提过了,但值得再强调一次。==的优先级高于&,所以a & b == c等价于a & (b == c)。如果b和c都是0或1,这个表达式可能还能跑,但逻辑完全错了。
正确的写法是(a & b) == c。这个括号不是可选的,是必须的。
类似的还有a | b == c等价于a | (b == c),a ^ b == c等价于a ^ (b == c)。只要表达式中同时出现了位运算符和比较运算符,一律加括号。
4.3a = b > c ? d : e:赋值与条件的优先级关系
这个表达式等价于a = ((b > c) ? d : e),因为?:的优先级高于=。这个符合大多数人的直觉,所以一般不会出错。
但反过来,a ? b : c = d等价于(a ? b : c) = d,因为=的优先级低于?:。这个就很容易出问题了。如果你想表达“如果a为真,把d赋给b;否则把d赋给c”,应该写成a ? (b = d) : (c = d),或者直接用if-else。
4.4sizeof(int) * p:sizeof的优先级
sizeof的优先级和单目运算符同级,高于算术运算符。所以sizeof(int) * p等价于(sizeof(int)) * p,也就是“int类型的大小乘以p”。如果你想表达“p指向的类型的大小”,应该写sizeof(*p)。
还有一个常见的坑:sizeof(arr) / sizeof(arr[0])。这个表达式用来计算数组长度,因为sizeof的优先级高于/,所以等价于(sizeof(arr)) / (sizeof(arr[0])),结果是数组元素个数。这个写法在C语言中非常常见,但如果你把sizeof误认为是函数调用,就会觉得sizeof(arr)和sizeof(arr[0])的优先级有问题。
4.5a << 2 + b:移位与加法的优先级
+的优先级高于<<,所以a << 2 + b等价于a << (2 + b)。如果你想表达“a左移2位然后加b”,应该写成(a << 2) + b。
这个坑在嵌入式开发中特别常见,因为位操作和算术运算经常混在一起。我的建议是:只要表达式中同时出现了移位运算符和算术运算符,一律加括号。不要试图靠记忆优先级来省括号,省下来的时间不够你排查一个bug的。
5. 快速判断复杂表达式求值顺序的实用方法
5.1 括号法:从最内层开始逐层剥离
面对一个复杂的表达式,最可靠的方法就是“括号法”——按照优先级从高到低,逐步给子表达式加括号,直到整个表达式被完全括号化。
比如a = b + c * d << 2 & 0xFF,按照优先级从高到低:
*最高:c * d→a = b + (c * d) << 2 & 0xFF+次之:b + (c * d)→a = (b + (c * d)) << 2 & 0xFF<<再次:(b + (c * d)) << 2→a = ((b + (c * d)) << 2) & 0xFF&再次:((b + (c * d)) << 2) & 0xFF→a = (((b + (c * d)) << 2) & 0xFF)=最后:整个表达式
这个方法虽然看起来笨,但非常可靠。你不需要记住所有优先级,只需要每次找出当前表达式中优先级最高的运算符,给它加括号,然后重复这个过程。
5.2 查表法:34种运算符的完整优先级表
下面这张表把34种运算符按优先级从高到低排列,同一行的运算符优先级相同,结合性也相同。
| 优先级 | 运算符 | 名称 | 结合性 |
|---|---|---|---|
| 1 | ()[]->. | 函数调用、数组下标、结构体指针成员、结构体成员 | 左结合 |
| 2 | ++--(后缀) | 后缀自增、后缀自减 | 左结合 |
| 3 | ++--(前缀)+-!~*&sizeof(type) | 前缀自增/自减、正负号、逻辑非、按位取反、解引用、取地址、sizeof、强制类型转换 | 右结合 |
| 4 | */% | 乘、除、取模 | 左结合 |
| 5 | +- | 加、减 | 左结合 |
| 6 | <<>> | 左移、右移 | 左结合 |
| 7 | <<=>>= | 关系运算符 | 左结合 |
| 8 | ==!= | 相等、不等 | 左结合 |
| 9 | & | 按位与 | 左结合 |
| 10 | ^ | 按位异或 | 左结合 |
| 11 | | | 按位或 | 左结合 |
| 12 | && | 逻辑与 | 左结合 |
| 13 | || | 逻辑或 | 左结合 |
| 14 | ?: | 条件运算符 | 右结合 |
| 15 | =+=-=*=/=%=<<=>>=&=^=|= | 赋值和复合赋值 | 右结合 |
| 16 | , | 逗号运算符 | 左结合 |
注意:这张表把优先级分成了16档,但有些教材把后缀++/--和第一档合并,有些把复合赋值拆开算,所以“34种运算符”的统计口径会略有差异。关键是理解层级关系,而不是纠结于具体数字。
5.3 实际编码中的括号策略
虽然括号法可以解决所有优先级问题,但在实际编码中,你不可能给每个表达式都加满括号。我的策略是:
- 只要表达式中同时出现了位运算符和比较运算符,一律加括号。
- 只要表达式中同时出现了移位运算符和算术运算符,一律加括号。
- 只要表达式中同时出现了
*(解引用)和++/--,一律加括号。 - 只要表达式中同时出现了
?:和赋值运算符,一律加括号。 - 其他情况,如果优先级关系符合直觉(比如先乘除后加减),可以不加括号。
这个策略的核心思想是:只在“优先级关系与直觉不符”的地方加括号。这样既能保证正确性,又不会让代码被括号淹没。
6. 从编译器视角看优先级:语法树是怎么生成的
6.1 递归下降解析中的优先级处理
编译器在解析表达式的时候,通常会使用“递归下降”或者“运算符优先”算法。递归下降解析器的每一层对应一个优先级,高优先级的运算符在更深的递归层中处理。
比如一个简化的表达式解析器可能长这样:
// 解析加减表达式 Expr *parse_additive() { Expr *left = parse_multiplicative(); while (current_token == '+' || current_token == '-') { Token op = current_token; advance(); Expr *right = parse_multiplicative(); left = make_binary_expr(op, left, right); } return left; } // 解析乘除表达式 Expr *parse_multiplicative() { Expr *left = parse_unary(); while (current_token == '*' || current_token == '/' || current_token == '%') { Token op = current_token; advance(); Expr *right = parse_unary(); left = make_binary_expr(op, left, right); } return left; }这个结构本身就体现了优先级:parse_additive调用parse_multiplicative,所以乘除会先被解析,形成更深的语法树节点。当你写a + b * c的时候,解析器会先解析a,然后看到+,调用parse_multiplicative解析b * c,最后把a和b * c组合成加法节点。
理解了这个机制,你就能明白为什么优先级是“语法层面”的规则,而不是“运行层面”的规则。编译器在生成代码之前,就已经按照优先级把表达式组织成了语法树,运行时的求值顺序完全由语法树决定。
6.2 短路求值与优先级的关系
逻辑运算符的短路求值特性也是通过语法树实现的。a && b在语法树中是一个逻辑与节点,左子节点是a,右子节点是b。编译器在生成代码的时候,会先计算a,如果a为假,直接跳过b的计算,整个节点的值为假。
这个机制依赖于优先级——&&的优先级高于||,所以a || b && c的语法树是a || (b && c),如果a为真,整个表达式直接为真,b && c根本不会被求值。
如果你不理解优先级,就可能写出a || b && c这样的表达式,然后误以为它是(a || b) && c。虽然后者可以通过加括号来实现,但前者的短路行为完全不同——前者在a为真时不会计算b和c,后者在a为真时会计算b,然后根据b的值决定是否计算c。
6.3 未定义行为与优先级的关系
有些优先级问题会导致未定义行为,而不仅仅是逻辑错误。比如i = i++,这个表达式在C语言中是未定义行为,因为i在同一个表达式中被修改了两次,而且没有序列点分隔。
i = i++的优先级解析是i = (i++),先取i的旧值,然后i自增,最后把旧值赋给i。但问题在于,赋值和自增的先后顺序在标准中没有明确规定,不同的编译器可能生成不同的代码。有的编译器先自增再赋值,结果i不变;有的编译器先赋值再自增,结果i加1。
类似的还有a[i] = i++,这个也是未定义行为。i在[]和++中都被使用了,而且++修改了i的值,导致a[i]中的i到底是旧值还是新值不确定。
这类问题不是靠加括号能解决的,因为问题出在“同一个变量在同一个表达式中被修改多次”这个层面。唯一的解决办法是拆成多条语句:
int temp = i; i++; a[temp] = temp;7. 实战中的优先级经验:从代码审查说起
7.1 代码审查中最常见的优先级问题
我在代码审查中见过最多的优先级问题,排名第一的是a & b == c,排名第二的是*p++和(*p)++的混淆,排名第三的是a << 2 + b。
这些问题有一个共同特点:代码看起来“很自然”,但实际行为与直觉不符。a & b == c看起来像是“a和b按位与的结果等于c”,但实际是“a和(b==c)按位与”。*p++看起来像是“取p指向的值然后p自增”,但实际是“p自增然后取旧值指向的值”。a << 2 + b看起来像是“a左移2位然后加b”,但实际是“a左移(2+b)位”。
解决这些问题的办法不是记住所有优先级规则,而是养成一个习惯:只要表达式中同时出现了“容易混淆”的运算符组合,就加括号。这些组合包括:位运算+比较、移位+算术、解引用+自增/自减、条件+赋值。
7.2 宏定义中的优先级陷阱
宏定义是优先级问题的重灾区。因为宏是文本替换,如果不加括号,替换后的表达式可能会因为优先级问题而改变含义。
比如:
#define SQUARE(x) x * x如果你写SQUARE(a + b),替换后变成a + b * a + b,等价于a + (b * a) + b,而不是(a + b) * (a + b)。正确的写法是:
#define SQUARE(x) ((x) * (x))再比如:
#define MAX(a, b) a > b ? a : b如果你写MAX(x, y) + 1,替换后变成x > y ? x : y + 1,等价于x > y ? x : (y + 1),而不是(x > y ? x : y) + 1。正确的写法是:
#define MAX(a, b) ((a) > (b) ? (a) : (b))宏定义中的括号规则是:每个参数都要加括号,整个表达式也要加括号。这个规则看起来简单,但在实际写宏的时候很容易漏掉。
7.3 位操作中的优先级实践
位操作是优先级问题的高发区,因为位运算符的优先级普遍低于比较运算符,而大多数人直觉上认为位运算应该先算。
比如你想判断一个数的第3位是否为1,可能会写:
if (flags & 0x08 == 0x08) { ... }这个表达式等价于flags & (0x08 == 0x08),也就是flags & 1,完全错了。正确的写法是:
if ((flags & 0x08) == 0x08) { ... }再比如你想把两个标志位合并,可能会写:
int combined = flags1 | flags2 & 0x0F;这个表达式等价于flags1 | (flags2 & 0x0F),因为&的优先级高于|。这个可能符合你的意图,但如果你想让flags1 | flags2先算,就需要加括号:(flags1 | flags2) & 0x0F。
我的建议是:在位操作中,只要表达式中出现了多个运算符,一律加括号。位操作的代码通常对正确性要求很高,加括号的成本远低于排查bug的成本。
7.4 条件表达式中的优先级实践
条件运算符?:的优先级低于逻辑运算符,高于赋值运算符。这个规则在实际编码中需要注意的地方是:a ? b : c = d等价于(a ? b : c) = d,而不是a ? b : (c = d)。
如果你想在条件表达式中做赋值,应该写成:
a ? (b = d) : (c = d);或者更清晰地写成if-else:
if (a) { b = d; } else { c = d; }条件运算符的另一个常见问题是嵌套。a ? b : c ? d : e等价于a ? b : (c ? d : e),这个规则可能符合你的意图,但嵌套超过两层就会严重影响可读性。我的建议是:条件运算符最多嵌套一层,超过一层就用if-else。
8. 把优先级规则内化成编码习惯
8.1 不要试图记住所有规则
34种运算符、15个优先级层级、左结合和右结合——这些规则加起来有几十条,试图全部记住是不现实的。即使你记住了,在写代码的时候也不一定能想起来。
更实际的做法是:记住那些“与直觉不符”的规则,其他规则靠直觉就行。与直觉不符的规则包括:
- 位运算符的优先级低于比较运算符(
&、^、|都低于==和!=) - 移位运算符的优先级低于算术运算符(
<<、>>低于+、-) - 后缀
++/--的优先级高于单目*(*p++等价于*(p++)) - 条件运算符的优先级高于赋值运算符(
a ? b : c = d等价于(a ? b : c) = d)
记住这四条,就能避免90%以上的优先级问题。
8.2 用括号表达意图,而不是用括号规避规则
加括号的目的不是“绕过优先级规则”,而是“表达你的意图”。当你写(a & b) == c的时候,你是在告诉读代码的人:“我先做按位与,再比较”。这个括号让代码的意图变得明确,而不是让读者去查优先级表。
同样,当你写*p++的时候,如果你确实想表达“取当前值然后移动指针”,这个写法是没问题的,因为这是C语言的惯用法。但如果你不确定,加个括号写成*(p++)会更清晰。
我的原则是:如果一行代码需要读者查优先级表才能看懂,那就加括号。代码是写给人看的,不是写给编译器看的。
8.3 代码审查中的优先级检查清单
如果你负责代码审查,可以用下面这个清单快速检查优先级问题:
| 检查项 | 常见问题 | 正确写法 |
|---|---|---|
| 位运算+比较 | a & b == c | (a & b) == c |
| 移位+算术 | a << 2 + b | (a << 2) + b |
| 解引用+自增 | *p++ | 确认意图,必要时写(*p)++ |
| 条件+赋值 | a ? b : c = d | a ? (b = d) : (c = d) |
| 宏定义 | #define SQUARE(x) x * x | #define SQUARE(x) ((x) * (x)) |
| 逻辑+位运算 | a && b & c | 确认意图,必要时加括号 |
这个清单不能覆盖所有情况,但能覆盖大多数常见问题。在实际审查中,只要看到这些组合,就多问一句“这里的优先级是你想要的吗?”
8.4 从“记住规则”到“形成直觉”
优先级规则最终要内化成编码直觉,而不是每次写代码都去查表。形成直觉的方法只有一个:多写、多读、多踩坑。
我自己的经验是:每次遇到优先级相关的bug,不要只是改代码,而是把出错的表达式和正确的表达式都记下来,分析为什么出错。积累多了,你就会对哪些组合容易出问题形成条件反射。
比如我现在看到a & b == c这种表达式,第一反应就是“这里少了一对括号”。看到*p++,第一反应就是“这是取当前值然后移动指针”。看到a << 2 + b,第一反应就是“这里可能少了一对括号”。
这种直觉不是靠背规则背出来的,是靠一次次踩坑踩出来的。所以,不要怕犯错,但要确保同样的错误不犯第二次。
9. 几个值得反复琢磨的边界案例
9.1a+++b到底怎么解析
a+++b这个表达式看起来很奇怪,但它是合法的C语言。词法分析器会按照“最长匹配”原则来切分token,所以a+++b被切分成a++、+、b,等价于(a++) + b。
如果你想表达a + (++b),应该写成a + ++b,中间加空格。这个例子说明:词法分析阶段就已经决定了token的切分方式,优先级是在token切分之后才起作用的。
9.2a = b, c = d的求值顺序
a = b, c = d等价于(a = b), (c = d),因为逗号运算符的优先级最低。先执行a = b,再执行c = d,整个表达式的值是c = d的值。
如果你想表达a = (b, c) = d,这个表达式是非法的,因为(b, c)不是一个左值,不能出现在赋值运算符的左边。
9.3sizeof和数组的优先级问题
sizeof(arr) / sizeof(arr[0])这个表达式用来计算数组长度,因为sizeof的优先级高于/,所以等价于(sizeof(arr)) / (sizeof(arr[0]))。
但如果你写sizeof(arr[0]) / sizeof(arr),结果就是1 / 数组长度,通常等于0(整数除法)。这个错误在初学者中很常见,因为sizeof看起来像函数调用,容易让人误以为它的优先级和函数调用一样。
9.4->和++的优先级组合
p->next++等价于(p->next)++,因为->和++的优先级相同,结合性都是从左到右。先通过->取出next成员,然后对next自增。
如果你想表达p->(next++),这个表达式是非法的,因为next不是一个指针,不能自增。如果你想表达(p->next)++,这个写法是正确的,但通常不需要加括号,因为->和++的优先级关系已经决定了这个解析顺序。
9.5&和|的优先级关系
a & b | c等价于(a & b) | c,因为&的优先级高于|。这个规则和逻辑运算符的&&高于||是一致的。
但a | b & c等价于a | (b & c),因为&的优先级高于|。这个规则可能和你的直觉不符——你可能觉得应该从左到右依次计算。但实际上,&的优先级高于|,所以先算b & c,再算a | (b & c)。
这个规则在写位掩码操作的时候需要注意。如果你想表达(a | b) & c,必须加括号。
10. 优先级规则背后的设计哲学
C语言的优先级规则不是随意制定的,它背后有一套设计哲学:越靠近“取值”的操作优先级越高,越靠近“组合”的操作优先级越低。
函数调用、数组下标、成员访问——这些都是“取值”操作,优先级最高。单目运算符——这些是“改造”操作,优先级次之。算术运算符——这些是“计算”操作,优先级再次。比较运算符——这些是“判断”操作,优先级更低。逻辑运算符——这些是“组合判断”操作,优先级更低。赋值运算符——这些是“存储”操作,优先级更低。逗号运算符——这是“顺序执行”操作,优先级最低。
这个设计哲学解释了为什么位运算符的优先级低于比较运算符——因为位运算符是“计算”操作,比较运算符是“判断”操作,判断应该在计算之后。但这个设计也导致了a & b == c这样的问题,因为大多数人直觉上认为位运算应该先算。
理解了这个设计哲学,你就不需要死记硬背优先级表了。当你遇到一个不确定的表达式时,问自己:这个运算符是“取值”、“改造”、“计算”、“判断”、“组合判断”、“存储”还是“顺序执行”?然后按照这个顺序判断优先级。
这个方法的准确率不是100%,因为有些运算符的优先级设计确实有点反直觉。但它能帮你快速判断大多数情况,剩下的少数情况靠加括号解决就行。
11. 我个人的几条实战建议
写了这么多年C语言,关于运算符优先级,我总结了几条实战建议,都是踩坑踩出来的。
第一条:位运算和比较运算混用的时候,永远加括号。(a & b) == c比a & b == c多打两个字符,但能省你半小时的调试时间。
第二条:移位运算和算术运算混用的时候,永远加括号。(a << 2) + b比a << 2 + b清晰得多,而且不会出错。
第三条:宏定义中,每个参数和整个表达式都要加括号。这个规则没有例外,漏掉一个括号就可能出问题。
第四条:*p++和(*p)++要分清楚。如果你不确定,就拆成两条语句:int temp = *p; p++;或者(*p)++;。拆开写虽然多一行,但可读性更好。
第五条:不要在一行代码里塞太多运算符。如果一行代码需要你查优先级表才能看懂,那就拆成多行。代码是写给人看的,不是写给编译器看的。
第六条:遇到不确定的表达式,用括号法逐层分析。找出当前表达式中优先级最高的运算符,给它加括号,然后重复这个过程。这个方法虽然笨,但100%可靠。
第七条:多读底层代码。Linux内核、嵌入式驱动、标准库源码中有大量位操作和指针操作,读这些代码能帮你快速建立优先级直觉。
第八条:每次遇到优先级bug,记下来。不要只是改代码,要分析为什么出错,正确的写法是什么。积累多了,你就会对哪些组合容易出问题形成条件反射。
这些建议看起来简单,但真正做到需要时间和练习。优先级规则不是靠背出来的,是靠写出来的。写得多了,自然就记住了。