C语言的运算符,表面看就是一堆符号和关键字,加加减减、比大小、做逻辑判断,好像没什么可讲的。但真要我说,这个题目是整个C语言里最容易“一看就会,一写就错”的地方。你翻翻那些流传了很久的诡异代码段,十个里有八个都是运算符在作怪:优先级记反了、自增自减的副作用没算清、位运算当成逻辑运算用、赋值表达式嵌在条件里读起来像天书。这篇文章不打算给你念手册,我想换个思路,从“为什么会出错”和“实际工程里怎么用”的角度,把C的运算符彻底拆一遍。无论你是刚学C的在校生,还是写嵌入式、写底层服务的老手,里面应该都有能直接拿去用的东西。
1. C语言运算符全景:先搞清楚手里有哪些牌
1.1 按功能分类,C的运算符到底有多少种
C语言的运算符数量,不同教材统计口径略有差异,但主流说法是44个左右。光看数字没什么感觉,关键在于这些运算符把“计算、赋值、比较、逻辑、位操作、寻址”这些事全包了,而且很多运算符身兼数职,同一个符号在不同上下文里完全是两个意思。
我从实际使用的角度把它们分成七类,这样好记也好用:
| 类别 | 主要运算符 | 典型用途 |
|---|---|---|
| 算术运算符 | + - * / % | 数值计算 |
| 关系运算符 | < > <= >= == != | 比较大小、判断相等 |
| 逻辑运算符 | && || ! | 条件组合、真值判断 |
| 位运算符 | & | ^ ~ << >> | 位级操作、掩码、寄存器控制 |
| 赋值运算符 | = += -= *= /= %= <<= >>= &= |= ^= | 变量赋值、复合运算 |
| 自增自减 | ++ -- | 计数、遍历 |
| 特殊运算符 | ?: sizeof (类型) . -> & * , | 条件选择、类型转换、取址解引用、逗号分隔 |
你注意最后一行,&和*既是位运算符,又是取地址和解引用运算符,看上下文才知道是谁。(既是表达式分组的括号,也是强制类型转换的符号。这种“一词多义”是C运算符容易混淆的根源之一,后面我会专门展开。
1.2 运算符和表达式的“语法树”思维
很多初学者把运算符当成“动作”,觉得a + b就是“把a和b加起来”。这个理解没错,但不够用。想真正掌握运算符,你得换个视角:每个运算符都是一个“节点”,它把若干个操作数连接成一个更大的表达式,表达式本身又可以作为另一个运算符的操作数。就像搭积木,a + b * c实际上是乘号先把b和c结合成一个子树,加号再把a和那个子树结合成更大的树。这个结构叫语法树。
用语法树想问题,很多糊涂账就清了。比如为什么a + b * c里先算乘法?不是“乘法优先级高”这种背诵口诀,而是语法树的形状决定了先算谁。再比如a = b = c为什么能成立?因为赋值运算符的结合性是右到左,语法树是a = (b = c),先执行b = c,再把结果赋给a。树形结构是理解优先级和结合性的底层方法论。
我建议你直接在纸上画几个复杂表达式的语法树,比如a = b > c ? b : c或者x = *p++ + 1。画过三五个之后,你对运算符的理解会比背十遍规则都扎实。后面所有章节讲到具体运算符时,我都会用这个“树形视角”去解释,它比死记硬背可靠得多。
2. 优先级和结合性:C语言最大的坑,没有之一
2.1 一张表记住80%的优先级场景
C语言运算符优先级表有15级,全部背下来不现实,也没必要。但如果让我在国内做技术分享,我通常会告诉大家:别背表,背规律。C的优先级设计其实有个朴素的逻辑——可以概括成六个字:单目、算术、位、关系、逻辑、赋值。其中位运算和关系运算的位置最容易被记错,后面细说。
我给学员画过一张简化金字塔,从高到低大致是这样:
- 第一梯队(最高):
()、[]、->、.,以及后缀的++、-- - 第二梯队:前缀
++、--、!、~、+、-、*、&、(类型)、sizeof - 第三梯队:算术运算符,先乘除模,后加减
- 第四梯队:移位运算符
<<、>> - 第五梯队:关系运算符
<、<=、>、>= - 第六梯队:相等运算符
==、!= - 第七梯队:按位与
&,按位异或^,按位或| - 第八梯队:逻辑与
&&,逻辑或|| - 第九梯队(最低):条件运算符
?:,赋值运算符=以及各种复合赋值,逗号运算符
注意一个高频错误:位运算的优先级比关系运算符低,但比逻辑运算符高。也就是说a & b == c会被解析成a & (b == c),而不是(a & b) == c。我亲眼见过有人写if (flags & MASK == FLAG)想判断两个条件同时满足,结果编译器先算MASK == FLAG,整个逻辑完全跑偏,而且这种Bug还特别难查,因为编译不报错,运行结果偶尔对偶尔错。
2.2 “贪心法则”:编译器如何切分运算符
排错之前你得先知道编译器是怎么“读”代码的。C标准规定了一个原则,叫“最大吞噬”(maximal munch):编译器从左到右扫描,能组合成合法运算符的字符尽量往长了组合。也就是说a+++b会被解析成a++ + b,因为++是合法运算符,而+++不是。同理a--->b其实是a-- > b。
这个规则导致了一些恶趣味代码,比如a+++++b会解析成a++ ++ + b,然后编译报错,因为自增运算的结果不是左值,不能再自增。你可能觉得没人会这么写,但缩进排版混乱时,a + ++b和a+++b长得几乎一样,真实项目里因为少写空格导致误解的例子很多。
给你的实战建议很简单:在运算符两侧留空格。这件事除了好看,更重要的是杜绝意外吞噬。尤其是++、--、&、*这类单目运算符,一定要让它们和周围的变量、运算符之间界限分明。我自己的习惯是:双目运算符两侧都加空格,单目运算符与操作数之间不加空格,这样代码一读就知道p++和a + b的结构。
2.3 拆解一个“魔鬼表达式”
我们拿一个综合例子来实战。假设有这样一个表达式:
int a = 5, b = 10, c = 2, result; result = a > b ? a : b & c;请问result拿到的是多少?有人会想:先算a > b,是假,所以走b & c,即10 & 2 = 2。抱歉,这是错的。因为位运算&的优先级高于条件运算符?:,所以表达式实际是a > b ? a : (b & c),结果确实是2。但如果换成result = (a > b ? a : b) & c,结果就是(10) & 2 = 2,看起来一样对吧?那再换一组数:b = 3, c = 6,前一种算法b & c = 3 & 6 = 2,后一种算法(a > b ? a : b) & c = 3 & 6 = 2,还是碰巧相同。这种“碰巧”会让错误代码潜伏很久,等数据一变就炸出来。
再看一个直接把优先级吃死的经典题:
int x = 1; int y = x << 2 + 1;移位运算符的优先级低于加法,所以实际是x << (2 + 1),即1 << 3 = 8。可很多人第一反应是(1 << 2) + 1 = 5。写代码的人到底是哪个意思?只有他自己知道。这就是我反复强调要加括号的原因:**优先级不该用“我记住了”来保证,而该用括号让读代码的人不用猜。**编译器不在乎你有没有括号,但你的同事和三天后的你在乎。
3. 自增自减与副作用:一个表达式里最危险的东西
3.1 前自增与后自增,到底差在哪
++i和i++的问题,是C语言运算符里的一个分水岭。很多人能说出来“前自增先加后用,后自增先用后加”,但真到了复合表达式里就乱了。先看最朴素的用法:
int i = 3; int a = ++i; // i先变成4,a=4 int b = i++; // b拿到i当前值4,然后i变成5拆开看很清晰。但a = ++i其实有个隐藏信息:++i这个表达式的值就是自增之后的新值,并且它本身是一个左值。i++则不同,它的值是自增前的旧值,而且不是左值——你没法对i++的结果再赋值。C语言为什么这么设计?因为后自增要“先保存旧值,再自增,最后返回旧值”,这个旧值只是一个临时拷贝,没有身份,自然不能当左值。而前自增直接返回变量自身,当然可以当左值。
这个左值差异在实际代码里能玩出花来。比如解引用连续操作:
char s[] = "hello"; char *p = s; char first = *p++;因为后缀++优先级比解引用*高,所以*p++实际上是*(p++),也就是先把p自增,但解引用的是自增前的旧地址,取到的是'h'。这一行代码干了两件事:取当前字符,并把指针移到下一个位置。遍历字符串时这是最典型的写法,比*p; p++;更紧凑,也更容易出副作用问题——但至少副作用是明确的。
3.2 未定义行为:同一段代码,换个编译器结果就变
自增自减最深的坑在于:当同一个变量在一个表达式里被多次修改,或者修改和读取交织在一起,C标准直接撂挑子不保证了。看这个经典例子:
int i = 1; int a = i++ + i++;请问a是几?有人算1 + 2 = 3,有人算1 + 1 = 2,有人算2 + 2 = 4。不好意思,正确答案是“未定义行为”(undefined behavior),编译器怎么做都不算错。因为在两个相邻的序列点之间(理解成两个分号之间),对同一个变量的修改次数超过一次,C标准不要求编译器给出任何确定结果。不同的编译器、不同的优化级别,甚至同一个编译器不同版本,都可能给出不同答案。
类似的情况还有printf("%d %d\n", i++, i++)——函数参数的求值顺序在C里也是不指定的,从左到右还是从右到左都有可能。这类代码在面试题里很常见,但在工程里是绝对的禁区。你不要觉得“反正我的编译器结果是对的”,换个优化选项,或者换到别的架构上,马上就翻车。
我给你的硬性建议:一个表达式中,对同一变量最多只做一次修改;如果需要自增后再用,优先拆成独立语句。比如arr[idx++] = val这种没问题的,可以保留;但arr[idx++] = arr[idx]这种必须拆开。别跟编译器玩赌博,它赢定了。
3.3 提升可读性:副作用独立成句不是妥协,是专业
我知道有人会觉得,“一行代码搞定”才是高手风范。但我在实际项目里吃过太多亏,现在看到复杂的自增嵌套,第一反应是直接改代码,而不是去猜语义。比如下面这段:
while (*dest++ = *src++) ;这是一段非常著名的字符串拷贝代码,简洁、优雅,C语言教科书常把它当成“神作”来分析。但平心而论,这种代码坑过太多人:两个变量同时自增,赋值表达式的值作为循环条件,指针在循环体里已经悄悄移动。你写起来很有快感,可后面维护的人大概率要画半天语法树。
我不是说不能这么写,而是说你要评估团队水平和代码审查流程。如果是你自己维护的小项目,这种紧凑写法没问题;如果这是给很多人看的公共代码,我建议写成:
while (*src != '\0') { *dest = *src; dest++; src++; } *dest = '\0';性能几乎没差别,但可读性天差地别。现代编译器的优化能力远超想象,你写得越直白,它反而越容易优化。把副作用拆出来不是向“菜鸟”妥协,是对所有后来者负责。
4. 位运算符:底层开发的利器,也是Bug温床
4.1 掩码操作:置位、清除、翻转、检查
位运算在嵌入式、协议解析、状态标示、权限系统这些场景里,是绕不开的核心技能。它的本质就是数据在底层只有0和1,你要用有限的比特位存尽可能多的信息。四个最基础的操作我给你整理成口诀:置位用或、清位用与、翻转用异或、检查也用与。
unsigned char reg = 0x00; // 置位:把bit3置1,其他位不受影响(用掩码0x08) reg |= (1 << 3); // 清位:把bit5清0,其他位不受影响(用掩码0xDF,因为~0x20) reg &= ~(1 << 5); // 翻转:把bit1和bit2同时取反 reg ^= (0x03 << 1); // 检查:判断bit7是否为1 if (reg & (1 << 7)) { // bit7是1 }我把1 << n称为“位探针”,它制造了一个只有第n位是1、其余全是0的掩码。置位用|可以把对应位强制改成1;清位用& ~(1 << n),~先把掩码反转成“只有第n位是0”,再按位与,第n位就一定变0,其他位因为掩码是1所以保留原值。异或就是相同为0、不同为1,专门用来翻转。检查if (reg & (1 << n)),因为按位与的结果只有第n位可能非0,所以整个表达式非0即真。
这些操作在寄存器配置里太常用了。比如某传感器芯片的使能位在bit0,灵敏度配置占bit1到bit2,你先读寄存器、改对应位、再写回去,一个reg = (reg & ~0x06) | (sensitivity << 1);就搞定。你要是忘了清位直接或上去,旧值和新值叠加在一起,寄存器数值必然错乱。清位和置位必须成对出现,这是写寄存器代码的基本修养。
4.2 移位运算符的“未定义”阴影
左移<<和右移>>看起来简单,其实有一堆标准层面的细节。先说左移:对有符号整数,左移导致符号位发生变化,或者移出的位被丢弃,这些都属于未定义行为或者由实现定义。实际工程里,左移最安全的用法是操作unsigned类型。你写1 << 31,如果int是32位,这可能就翻车了,因为1的符号位被推到最左边,变成负数,属于未定义行为。正确写法是1u << 31。
右移更复杂:对无符号数,右移是逻辑右移,高位补0;对有符号数,右移是什么行为?C标准说“由实现定义”,大多数编译器采用算术右移——高位补符号位,也就是负数右移还是负数。这在可移植代码里是个隐患。如果你依赖右移来除2,最好明确写unsigned,别把有符号负数右移行为当成C语言的统一规则。
另外有个和移位相关的冷知识:移位位数不能大于等于类型的位宽。比如32位的int,你写x << 32是未定义行为。一些编译器会警告,但很多不会。循环中对移位位数取模是常见高性能技巧,但一不小心就触发UB。我用过一个老旧的第三方库,里面有个地方写stride = 1 << len,当len超过类型位宽后,整个系统状态错乱,查了两天才定位到是移位溢出。
4.3 异或交换的旧闻:别再当宝贝了
网上流传一个经典代码,用三次异或交换两个变量,不引入临时变量:
a ^= b; b ^= a; a ^= b;这个技巧确实能完成交换,而且很炫酷。但我要泼一盆冷水:第一,现代编译器的寄存器分配能力很强,写int tmp = a; a = b; b = tmp;,生成的机器码和异或交换几乎一样快,甚至更快,因为临时变量可以放在寄存器里;第二,如果a和b指向同一块内存,异或交换会把数据清零,这是个隐藏炸弹;第三,可读性太差,没人愿意维护这种代码。
我建议异或交换当面试题做做、当文化了解一下就好,工程项目里老老实实用临时变量。位运算的重点应该放在掩码、旋转、CRC、哈希、状态压缩这些真正体现底层能力的地方。
5. 赋值、类型转换与表达式求值的那些暗礁
5.1 赋值也是表达式:链式赋值与右值化
在C里,=不只是“把右边算出来存到左边”,整个赋值表达式本身也有一个值,这个值就是赋给左操作数的值。正因为如此,才能写链式赋值:
int a, b, c; a = b = c = 0;结合性是右到左,等价于a = (b = (c = 0)),先把0赋给c,整个c = 0的值是0,再赋给b,再赋给a。
但注意,赋值表达式作为条件判断时,是高危区。有人写if (x = 5),本意是判断x == 5,结果是把5赋给x,然后因为5非0,条件永远为真。这属于经典的“赋值误写为相等”。现代编译器都会给if (x = 5)发警告,但这不能完全防住。反过来,有人故意用if ((p = malloc(n)) != NULL)这种赋值嵌在条件里的写法,利用赋值表达式的值做空指针判断,这在C里是合法的,也是很多新手读不懂的难点。
我的建议:如果你确实要在一个条件下同时赋值和判断,再加一层括号,并且把比较写明确,像if ((p = malloc(n)) != NULL)这样。不要为了少写一行而写成if (p = malloc(n)),那等于把语义托付给隐式非零判断,读代码的人还得自己推导布尔转换。
5.2 整数提升与隐式转换:为什么char会变成int
C语言中,表达式里的char、short类型会被自动提升为int再参与运算,这叫整数提升。虽然现代CPU上这么做通常没有额外成本,但它会带来一个隐蔽后果:提升后的类型是有符号int,当你拿它和无符号数比较或运算时,整个运算会被强制转成无符号。看这个例子:
char c = 0xFF; // 如果char是有符号的,c的值是-1 if (c == 0xFF) { puts("equal"); } else { puts("not equal"); }如果平台上的char是有符号类型,c实际值为-1,和0xFF比较时,整型提升把两边都转成int,就是-1 == 255,结果为假,输出not equal。如果char是无符号类型,c是255,结果为真。同一段代码在不同平台上输出不同,这就是移植噩梦。要避免这种问题,要么直接用unsigned char保存原始字节,要么比较时显式转换:if ((unsigned char)c == 0xFF)。
有符号和无符号比较的坑更常见。看这段代码:
int a = -1; unsigned int b = 1; if (a < b) { puts("a < b"); } else { puts("a >= b"); }凭直觉,-1 < 1当然成立。但C的隐式转换规则是:当int和无符号int比较时,int会被转成unsigned int,于是-1变成UINT_MAX,是一个巨大的无符号数,比较结果变成了“a大于等于b”。这种Bug在判断循环边界、检查数组长度时都容易出现。规矩就一条:比较和运算时,别让有符号和无符号混在一起。实在要混,就用强制转换把类型统一,并确保数值落在安全范围内。
5.3 除法、模运算和溢出的细节
整除/对两个整数做运算,结果也是整数,小数部分直接截断。截断方向需要特别注意:C99标准规定,整数除法向零截断,也就是说正数向下取整,负数向上取整。7 / 2得3,-7 / 2得-3,而不是C89时代有些平台上的-4。这一点跨平台行为已经统一,但你得知道,它和“向下取整”不是一个概念。取模运算符%的结果符号和被除数的符号一致:-7 % 2在C99里是-1,而7 % -2是1。你如果拿模运算做哈希或者循环下标,负数的模会让你措手不及。
溢出的问题则属于“无论你怎么算,结果都可能超出类型范围”。比如int是32位,INT_MAX加1就是未定义行为,后果可能是变为负数,也可能直接进入未定义分支被编译器当作不可能路径优化掉。判断两个数相加是否溢出,不能写成if (a + b > INT_MAX),因为a + b本身已经溢出了,检查就是在UB之后做的,毫无意义。要安全判断,应该反过来:
if (a > INT_MAX - b) { // 相加会溢出 }把加法转换为比较,绕开了溢出的发生。减法同理:if (a < INT_MIN + b)。这些技巧在实现安全校验、协议解析长度检查时非常有用。我为某嵌入式项目做过一次Modbus报文长度检查,缓冲区边界判断就用了这种方案,从那以后我再也不写“先算后查”的代码了。
5.4 浮点比较:千万别用“==”
浮点数用==做相等判断,基本上是自己给自己挖坑。因为0.1、0.2这类十进制小数在二进制里是无限循环小数,存储时必然有舍入误差,0.1 + 0.2的结果不是精确的0.3,而是0.30000000000000004。所以写if (x == 0.3)十有八九是假。
正确做法是比较差值绝对值是否足够小,也就是所谓的epsilon:
#include <math.h> double a = 0.1 + 0.2; if (fabs(a - 0.3) < 1e-9) { // 认为相等 }epsilon选多大取决于你的应用场景:图形学里的坐标比较,可以放宽到1e-6;财务计算则应该完全避开浮点,用整数表示分或厘。还有个技巧:比较两个浮点数谁大谁小,用<和>其实问题不大,最难处理的是“相等”分支。很多分类讨论逻辑在浮点上可以用“区间判断”代替“相等判断”,比如判断是否命中某个挡位,用value >= 0.5 && value < 1.5这种区间,就别用value == 1.0了。
6. 特殊运算符与典型陷阱速查表
6.1 条件运算符?:的优先级与可读性
条件运算符是C里唯一的三目运算符,语法是condition ? expr1 : expr2。它的优先级非常低,只比赋值和逗号高,这就意味着你写a ? b : c = d,=?右边的=其实可能属于整个条件表达式的一部分。看这个例子:
int a = 1, b = 2, c = 3, d = 4; a ? b : c = d;按优先级,c = d的优先级比?:高,所以实际上先发生c = d,再参与条件表达式。但条件表达式的结果并没有被使用,所以这行代码通常等于什么也没干——除了把c改成了4。如果你本意是“条件成立就把b赋值给某个变量”,这种写法完全不对。条件运算符的每个操作数如果不是特别简单,建议都套上括号。
条件运算符嵌套是另一个可读性灾难。a ? b : c ? d : e虽然合法,但读起来极不友好。我建议:超过一层的三元嵌套,就改用if-else或switch。代码是写给人看的,不是写给编译器看的。编译优化能把if-else优化得和?:一样高效,你别为了“省几个字”牺牲清晰度。
6.2 sizeof是运算符,不是函数
sizeof在C里是一个运算符,不是函数。这意味着很多新手的写法sizeof(int)虽然能工作,但实际上那是因为括号是表达式的一部分,而不是函数调用。sizeof可以不要括号,比如sizeof x、sizeof p都合法。它计算的是操作数类型的字节大小,且是在编译期求值的——除了变长数组(VLA)在运行时求值。这对性能敏感代码是个福利:用它算数组元素个数不会产生运行时开销。
但sizeof有两个著名陷阱。第一,对数组名使用sizeof得到的是整个数组的字节数,不是指针的。你把数组传给函数后,在函数内对形参用sizeof,得到的是指针大小,不是数组大小:
void clear(int arr[]) { memset(arr, 0, sizeof(arr)); // 错误!这里sizeof(arr)是指针大小 }正确做法是把数组长度作为参数传进来,或者用宏计算,比如#define ARRAY_LEN(a) (sizeof(a) / sizeof((a)[0])),但这个宏也只适用于数组本身,不适用于已退化为指针的形参。第二,sizeof的操作数如果是表达式,它不会真的求值,只取类型信息。sizeof(i++)不会让i自增。这个特性在有些场景下是好事,但它会让代码产生误导——你看着sizeof(i++)以为i变了,其实没有。
6.3 逗号运算符,一个总被低估的“顺序保证器”
逗号运算符,在C里有两种身份:分隔符和运算符。作为运算符时,它的作用是按从左到右的次序依次求值,并返回最右操作数的值。比如int y = (x = 1, x + 2);会让x变成1,然后y得到3。最常见的应用场景是在for循环里同时更新多个变量:
for (i = 0, j = n - 1; i < j; i++, j--) { // 从两端向中间遍历 }这里的逗号是运算符,确保i和j的初始化、自增按顺序执行。逗号运算符的优先级是最低的,连赋值都比它高,所以你想用逗号表达式的结果,必须加括号。我见过有人写int a = 1, 2;,报错之后才发现自己根本没用逗号运算符,而是把它当成了变量声明里的分隔符。这两个场景差别很大,用的时候就问自己:我是想“声明多个变量”,还是想“按顺序求值并取最后一个值”?想清楚再用。
6.4 常见问题速查表:这些坑我全踩过
| 问题现象 | 常见原因 | 解决方案 |
|---|---|---|
| 判断相等却一直走真分支 | if (x = 5)误用赋值 | 改成x == 5,条件内赋值加双括号 |
| 位运算结果与预期不符 | flags & MASK == FLAG优先级错误 | 加括号,写(flags & MASK) == FLAG |
| 数组缓冲区溢出却查不出 | 数组名传给函数后用sizeof求长度 | 显式传长度参数,或使用宏且仅对数组本体 |
| 循环遍历越界 | i <= strlen(s)死循环 | 用i < strlen(s)或提前缓存长度 |
| 有符号负数比无符号数小却判断反了 | 隐式转换把负数变无符号大数 | 统一类型,或强转并确认数值范围 |
| 自增自减表达式结果不稳定 | 同一表达式多次修改变量 | 拆成多行,严格遵守“一次修改”原则 |
| 浮点数相等判断失败 | 二进制精度丢失,不能用== | 用fabs(a - b) < eps比较差值 |
| 除以2的幂用右移,负数结果不对 | 有符号右移是算术移位 | 明确用unsigned类型,或直接除 |
sizeof(i++)以为会让i自增 | sizeof不求值操作数 | 知道这个特性,不要依赖它做副作用 |
链式比较a < b < c结果总是真 | <结合性左到右,先算a<b得0或1 | 写成a < b && b < c |
你把这个表收藏起来,写代码前翻一遍,能省很多调试时间。每一行都是我或者身边同事真实踩过的坑,不是从教科书里抄的。
7. 表达式求值顺序:一个总被忽略的“性能与安全”话题
7.1 序列点与求值顺序的关系
很多人只关心优先级,不关心求值顺序。优先级决定的是“运算符结合成什么树”,但求值顺序决定的是“树的每个节点按什么先后去算”。C标准用“序列点”(sequence point)这个概念来约束求值顺序:在序列点之间,表达式的各部分求值顺序是未指定的,编译器可以自由安排;而到了序列点,之前的副作用必须全部完成。
常见的序列点包括:分号处、函数调用传参之前、&&和||的左侧操作数求值完成后。也就是说:
int x = f() + g();f()和g()谁先调用,C标准不保证。如果你依赖调用顺序(比如f修改了g要读的全局变量),那就是给自己挖坑。要强制顺序,可以用逗号运算符或拆成多条语句。而&&和||是例外,它们保证左侧先求值,且右侧在必要时才求值,这就是短路求值的基础。
7.2 短路求值:不止是效率,更是安全
逻辑与&&和逻辑或||都有短路特性:左侧表达式已经能确定整个逻辑结果时,右侧表达式根本不会执行。这个特性不仅是性能优化,更是程序正确性的保障。比如:
if (ptr != NULL && strlen(ptr) > 5) { // 安全:只有ptr非空才会调strlen } if (num == 0 || total / num > 10) { // 安全:num为0时不会触发除法 }反过来,如果你把操作数顺序写反,比如if (strlen(ptr) > 5 && ptr != NULL),当ptr为空时,第一步就调用了strlen,直接崩溃。这种Bug在写空指针检查时太常见了。我的习惯是:把更简单的判断、或者会产生副作用的保护条件放在&&的左边,把可能不安全的操作放在右边。利用好短路求值,代码既安全又高效。
但也要注意,短路求值意味着右侧的副作用不一定会发生。比如if (status == OK && ++count > 10),当status不是OK时,count不会自增。如果你期待的是“无论如何都自增一下”,这个表达式就是错的。把有副作用的操作放进逻辑表达式的右侧是一种低效且容易出错的写法,能避免就避免。
7.3 编译器优化会让未定义行为变成“读起来完全反直觉”的代码
未定义行为(UB)最阴险的地方在于,编译器不会报错,甚至会利用UB做激进优化,生成让程序员看不懂的机器码。比如int i = 1; int a = i++ + i++;,某些编译器会把这种代码优化成什么都不做,或者返回一个固定值,完全无法预测。
再比如,有符号整数溢出是UB,编译器可能假设“这个循环不可能发生溢出”,从而删除某个边界检查。我在一个协议解析项目里见过一个例子,代码写if (len + 8 > MAX),因为len是从外部报文里读的,攻击者可以控制它为一个接近INT_MAX的值,那么len + 8先溢出变成负数,判断失效,缓冲区就爆了。修复方式就是用前面说的溢出安全判断:if (len > MAX - 8)。这个案例让我形成了条件反射:凡是输入可能来自外部,凡是数值运算可能靠近边界,一律先做安全数学运算,再参与判断。
运算符是C语言的底层拼图。你除了要知道每个符号怎么用,更要养成习惯:涉及优先级和副作用的地方,用括号和拆句把意图钉死。这不是不自信,而是对代码的一种专业态度。编译器是冷酷的机械,它严格按照标准执行,但你也别指望它读懂你的心。回到开头那句话,C的运算符是最能体现“程序员掌控力”的地方——你学得越扎实,写出来的代码越锋利。我自己写C十余年,翻过最多的车不是语法不懂,而是“觉得懂”。希望在看过这篇文章之后,你写运算符表达式时会多留个心眼:加括号、拆步骤、想清楚副作用、避开未定义行为。这些习惯加起来,比记住任何一张优先级表都有用。