
从“操作符3”这个标题说起。我自己在整理C语言学习笔记的时候操作符这块硬是拆成了三篇。前两篇讲的是算术、关系、逻辑、赋值这些“正餐”今天这篇“操作符3”要聊的全是藏在暗处、又特别容易让程序“莫名其妙”的那批操作符——位操作符、移位操作符、自增自减、sizeof、三目操作符、逗号操作符还有隐式类型转换。很多刷了上百道题的人最后栽跟头都不在算法上而在一个a[i] i这种表达式上。这篇文章就是要把这些坑一个个刨开把原理讲透顺便给出可以直接跑的验证代码。不管你是刚开始学C语言的大学生还是复习到操作符准备面试的求职者都值得把这篇收藏起来慢慢看。1. 为什么操作符值得单独开一篇“3”1.1 操作符学习的三个层次大部分C语言教材会把操作符列成一张大表告诉你有算术、关系、逻辑、位、赋值、条件、逗号等等。新手背完优先级觉得“哦我会了”。但实际写代码的时候你会发现真正的难点根本不是“记住*比优先”而是理解两个更深的东西副作用什么时候生效以及类型转换在背后做了什么手脚。我把操作符的学习分成三个层次。第一层是“认得出”看到%知道是取余看到知道是按位与。第二层是“排得对”能正确写出混合表达式知道什么时候该加括号。第三层是“想得清”看到一个表达式能在脑子里模拟出编译器求值的顺序知道哪些写法是未定义行为哪些坑是语言标准故意留给编译器自由的。第三层才是区分“会用C”和“真正理解C”的分水岭。这篇文章所有内容都在帮你往第三层爬。不要小瞧这个分层。很多人在学校做课设、刷PTA题代码能跑出正确结果就万事大吉。但一旦进入真实项目或者去面试考官问一句“这个表达式合法吗输出是什么”很多人就懵了。原因就是平时只关注“结果对不对”没关注“这个写法在标准里到底是什么地位”。操作符系列真正想解决的问题就是把这种模糊地带暴露出来。1.2 优先级、结合性与序列点理解一切陷阱的钥匙C语言操作符的优先级表很长但真正需要重点警惕的区域其实不多。我平时给学弟学妹讲的时候会让他们先记一个极简口诀括号最高单目第二乘除加减再往后移位关系别搞混位运算比逻辑运算低赋值逗号垫底。这里藏着一个经典大坑位操作符的优先级比相等操作符低。什么意思你写if (a b 0)编译器会把它解析成a (b 0)而不是(a b) 0。因为的优先级比高。这就是为什么几乎所有C编码规范都要求位运算参与逻辑判断时必须加括号。除了优先级还有结合性。同一优先级的操作符从左往右或从右往左结合。比如赋值操作符是右结合的所以a b c会先把c赋给b再把b的值赋给a三目操作符也是右结合的。但比结合性更隐蔽的是“序列点”概念。C标准规定在某些位置会有一个“序列点”序列点之前的副作用必须完成之后的副作用不能再提前。常见序列点包括分号结束、逻辑与和逻辑或||的左边、三目操作符?:的第一个表达式之后、逗号操作符的左边。在同一个表达式里如果对同一个变量做了多次修改并且没有序列点隔开就是未定义行为。比如i i两个副作用都修改i中间没有序列点结果是随机的不同编译器可能给出不同结果。理解序列点才是理解自增自减陷阱的根本。2. 位操作符从寄存器到算法都能用的硬核工具2.1 按位与、或、异或、取反的用途位操作符直接操作变量的二进制位包括按位与、按位或|、按位异或^、按位取反~。它们最常见的用途就是“掩码”。拿按位与举例x 0x0F只保留低4位其余位清零。这在读取硬件寄存器、处理网络协议头、做数据包解析时非常常用。按位或则相反用来把某些位置1比如x | 0x80把最高位置1其他位不变。按位异或有个特别好用的性质一个数异或自己等于0异或0等于自己所以它可以用来翻转特定位。比如x ^ 0xFF把低8位全部取反其余位不变。异或还有一个经典应用不借助临时变量交换两个数。代码长这样int a 3, b 5; a a ^ b; b a ^ b; a a ^ b; // 交换后 a5, b3为什么能交换第一行后a变成了原a ^ 原b。第二行b (原a ^ 原b) ^ 原b 原a ^ (原b ^ 原b) 原a。第三行a (原a ^ 原b) ^ 原a 原b。逻辑上完全正确。但我要提醒一句这种技巧看起来很酷在嵌入式环境下确实能省一个寄存器但在普通PC上编译器优化后和临时变量版本性能几乎一样可读性却差很多。我个人的经验是如果不是在寄存器极度紧缺的芯片上写代码还是老老实实用临时变量更好。位操作符在算法题里也经常出现。判断一个整数是不是2的幂n 0 (n (n - 1)) 0。原理是2的幂的二进制只有1个1减1后这个1变成0后面的0全变成1两者按位与结果必为0。统计二进制中1的个数可以用n (n - 1)反复消去最低位的1循环几次就是几个1。这些技巧刷题时特别好用但前提是你真的理解位运算的规则而不是背代码。2.2 左移右移与有符号数的一个大坑移位操作符和也是操作符家族里的重点。左移很好理解x n就是把x的二进制位整体左移n位右边补0。左移1位相当于乘以2左移n位相当于乘以2的n次方前提是没有溢出到符号位或丢失高位。右移稍微复杂一点对于无符号数右移是逻辑右移左边补0对于有符号数右移是算术右移还是逻辑右移C标准说“由实现定义”。大多数现代编译器对有符号负数右移采用算术右移即左边补符号位所以-8 1通常是-4。但这并不是标准保证的可移植代码里不应该依赖这一点。这里有一个我见过很多人踩的坑用1 31表示最低不是最高位。1是int类型如果int是32位1 31会把1移到符号位上这本身已经是未定义行为因为移位数大于等于类型宽度或者把值移入符号位。正确做法是用无符号类型1u 31。更常见的错误是1 32直接是未定义行为因为32等于int的位宽。移位操作的移位数如果大于等于操作数的位宽或者为负数结果是未定义的。所以写代码时一定要做边界检查。比如你想快速计算2的n次幂写1 n要确保0 n 32假设int 32位否则就该换用1ULL n或者直接用pow函数。移位操作符的优先级也特别容易坑人。它的优先级比加减低但比关系、相等、位与、位或高。所以x 2 1会被解析成x (2 1)因为加法优先级高于移位。你要是想表达(x 2) 1不加括号就错了。这种题目在笔试里出现率极高我每次看到都忍不住叹气加个括号能死吗3. 自增自减、sizeof、三目操作符才是真正的“阅读理解”3.1 i 与 i 的区别以及自增陷阱自增自减恐怕是C语言里流传最广、误解最多的操作符。i和i的区别两者都让i加1但表达式的结果不同。i返回的是自增前的值i返回的是自增后的值。这是从语义上理解。很多人背口诀“先使用后自增”但一到复杂表达式就晕。真正的麻烦是如果把自增和别的操作写在同一条语句里极易触发未定义行为。例如i i 1。这条语句里i既被赋值又被自增而且没有序列点隔开结果未定义。有些编译器甚至会在编译时报warning。你可能会说“我平时就这么写运行结果好像挺固定的。”那是因为你只在一个编译器上测。换个编译器、换个优化级别结果可能就变了。我在实际项目里排查过一个诡异bug后来发现是同事写了一句value array[len] next;依赖了某个编译器的特定求值顺序。这种代码一旦升级编译器或换平台立刻崩溃。规则其实很简单在同一个表达式中对同一个变量最多只修改一次。如果你想既取旧值又递增那就写成两行int old i; i i 1;这样可读性比int old i;差一点但语义完全明确绝不会出错。我自己写代码时自增自减只用在for循环的独立迭代语句里或者单独的i一行。凡是出现在数组下标、函数参数、赋值语句里的自增自减我都会先拆开。3.2 sizeof 的“编译期魔法”与两个反直觉现象很多人把sizeof当成函数因为它后面常常跟着括号比如sizeof(int)。其实它是操作符不是函数。对于类型名必须加括号对于变量名可以不加括号比如sizeof x完全合法。但因为写法太像函数我建议一律加括号省得被人误读。sizeof的最大特点是它在编译期就计算好类型的大小不会对表达式求值。这意味着sizeof(i)不会让i加1。我见过有人写printf(%d, sizeof(i));以为i会自增结果i原地不动。这是一个很经典的面试陷阱。更实用的场景用sizeof计算数组元素个数。sizeof(arr) / sizeof(arr[0])是C语言初学者必须会的惯用法。但注意这个式子只在数组名作为“真实数组”时成立。一旦数组作为函数参数传入它就退化成指针sizeof(arr)得到的是指针大小64位系统上通常是8而不是整个数组的大小。很多人在写函数时顺手写了这个式子算出个“8”然后一脸懵。还有一个反直觉现象sizeof的返回值类型是size_t通常是unsigned long或unsigned long long。如果你直接把它和int比较或参与减法可能会触发隐式类型转换导致意想不到的负数变巨大数。举个例子sizeof(arr) - 1如果数组长度为0C标准不允许长度为0的数组但如果你在表达式中使用了sizeof(arr) - strlen(str)这种混合一旦strlen的返回值很大结果可能变成无符号大数。我个人排查过一个问题判断字符串长度是否小于缓冲区长度写成if (strlen(s) sizeof(buf))没问题但写成if (sizeof(buf) - strlen(s) 0)在某些情况下会出bug因为无符号数相减的结果不是负数而是一个很大很大的正数永远大于0。3.3 三目操作符的类型统一规则三目操作符exp1 ? exp2 : exp3看着简单但你问过一个问题吗exp2和exp3类型不一样时结果类型是什么C标准有一套“通常算术转换”规则。比如1 ? 1 : 1.5因为1和1.5一个是int一个是double所以整个表达式的类型是double结果是1.0。再看1 ? a : 100char和int结果是int值是97。这些规则很符合“直觉”但到了指针和空指针那里就开始奇怪了。比如1 ? NULL : helloNULL通常是((void*)0)类型是void*字符串字面量类型是char[6]在表达式中会退化成char*两者类型不兼容但标准规定如果一个是void指针另一个是其他指针结果为void*所以整个表达式是void*。这个语法细节很难记但有一个统一的应对方法如果两个分支类型明显不同或者涉及指针与整数一定要强制类型转换。三目操作符结合性是右结合嵌套时容易产生歧义。虽然标准有明确规则但人读起来费劲。我自己写代码时只要遇到两层以上的三目就会改成if-else。可读性永远比省几行代码重要。举个例子求三个数最大值写成max a b ? (a c ? a : c) : (b c ? b : c);虽然对但看着就头疼。不如int max a; if (b max) max b; if (c max) max c;三行搞定还不会错。你可能会觉得这样不够“炫技”但真实项目里维护代码的人才不在乎你用了什么花哨语法只在乎能不能一眼看懂。4. 逗号操作符与隐式类型转换身边的隐藏操作符4.1 逗号操作符能分隔也能“丢弃”逗号在C语言里有两种角色一是分隔符比如函数参数f(a, b)里的逗号和变量声明int a, b;里的逗号二是真正的逗号操作符。操作符和分隔符的区别在于逗号操作符会把左右两个表达式连成一个整体先求左表达式丢弃其结果再求右表达式整个逗号表达式的值是右表达式的值。而且逗号操作符左边是一个序列点所以前面的副作用一定先完成。最常见的应用是for循环里写多个变量更新for (i 0, j n - 1; i j; i, j--) { // 双指针遍历 }这里i 0, j n - 1和i, j--都是逗号表达式。但很多人不知道逗号操作符的优先级非常低比赋值还低。所以a b, c;会被解析成(a b), c;整个表达式的值是c的值而不是a (b, c)。如果你打算用逗号把几个表达式当作一个整体用在别的场景里必须加括号。比如return a, b;会返回b因为逗号表达式的值是最后一个。但如果你写return (a, b);也是返回b两行效果一样。这看起来像废话但如果你想让a和b都被计算然后返回某个值就要小心。另一个坑是函数参数里的逗号不是逗号操作符所以f((1, 2), 3)和f(1, 2, 3)不一样前者第一个参数是逗号表达式(1,2)值为2所以调用是f(2,3)后者是三个参数1、2、3。这个区别在面试题里也出现过。老实说除了for循环我很少在业务代码里用逗号操作符。因为它的存在感太低读者容易看漏。比如if (a) b, c;这种写法不仔细看还以为if只控制b呢。为了安全除了for循环头其他地方尽量不要用逗号操作符。4.2 整型提升与寻常算术转换C语言规定在表达式计算时char和short类型包括它们的signed和unsigned变体会被提升为int如果int放不下就提升为unsigned int。这就是“整型提升”。为什么要这样因为早期CPU对int的计算最自然编译器在寄存器层面按int处理更高效。但整型提升会带来一个反直觉的结果。比如char c 0xFF; if (c 0xFF) { printf(equal\n); }你会以为c等于0xFF条件成立。但c是char假设char是8位有符号0xFF赋值给c后的值是-1取决于char是否有符号标准未定义多数平台有符号。在比较时c被提升为int值为-1而0xFF是int整型常量255-1不等于255所以不打印。如果你用unsigned char c 0xFF;提升后c是255条件才成立。这个例子说明写char类型比较时一定要搞清楚会不会发生整型提升以及char是否带符号。“寻常算术转换”是更普遍的隐式类型转换规则。当两个操作数类型不同时先提升再找两者中“更高”的类型如果任一操作数是long double转成long double否则如果double转double否则如果float转float否则对整数类型再比较如果无符号等级不低于有符号等级转无符号等等。规则本身可以查表但实际应用中最常见的坑就是有符号和无符号混用。4.3 一个综合例子混合类型比较为什么会翻车我们把上面的知识点串起来看一个经典例子int a -1; unsigned int b 1; if (a b) { printf(a b\n); } else { printf(a b\n); }直觉上-1肯定小于1所以应该打印“a b”。但实际上C语言会把a转换成unsigned int再比较-1转换成无符号后是UINT_MAX通常4294967295它当然大于1所以打印“a b”。这个坑在一些老代码里非常常见。解决方案很简单比较前先强制类型转换或者设计时就避免有符号和无符号直接比较。再看一个类似的sizeof和strlen混合。sizeof(buf)返回size_tstrlen(s)也返回size_t两者相减没问题但如果你拿结果赋给int就可能溢出。比如char buf[10]; int diff sizeof(buf) - strlen(s);如果strlen(s)大于10sizeof(buf) - strlen(s)是两个无符号数相减结果不是负数而是一个巨大的无符号数。把它赋给int标准规定如果值无法表示结果是实现定义的通常会变成某个负数。这种“从无符号到有符号”的隐式转换在很多编译器上只是丢掉高位很容易产生逻辑错误。我处理过一个线上bug就是有人用这种写法判断剩余空间结果字符串长度正好等于缓冲区大小时变成了巨大的无符号数绕过检查写越界了。从那以后我在涉及长度、大小的比较时都会把类型显式转成long long或者直接受到约束。5. 常见问题与排查技巧实录5.1 五个新手必踩的坑我把这些年遇到的高频错误整理成一张速查表每一行都是真实采集到的“翻车现场”。这些错误如果你只读不练很快就会忘建议拿编译器实际跑一遍看看warning输出。错误写法实际解析结果正确写法if (a b 0)a (b 0)先比较再位与if ((a b) 0)x y 2 1y 3因为加法优先级高于移位x (y 2) 1int n sizeof(arr) / sizeof(arr[0]);arr是函数参数得到指针大小相除远小于真实长度传入数组长度参数char c 0xFF; if (c 0xFF)c被提升为int后是-1不等于255unsigned char c 0xFF;return a, b;返回b如果想要a则写成return (a, b);也返回b按需求明确括号表格里的第一行和第二行是优先级问题第三行是数组退化问题第四行是整型提升问题第五行是逗号操作符问题。还有一个不在表里但同样经典if (x 1)把赋值当成了相等比较。编译器在-Wall下会提示“赋值作为条件”但如果你写成if (x 1)实际是永远为真。我建议把所有判断里写成if (1 x)这样万一漏写一个等号变成1 x会直接编译报错从源头杜绝。5.2 用编译器和调试器辅助理解学操作符不能光看书一定要动手验证。我强烈建议你在自己的电脑上装好gcc和gdb。编译时打开警告开关gcc -Wall -Wextra -stdc11 test.c -o test-Wall和-Wextra能帮你揪出很多优先级或类型转换问题。比如某些编译器对a b 0这种写法会给出warning“suggest parentheses around comparison in operand of ”。看到类似提示乖乖去加括号准没错。如果表达式过于复杂我推荐一个土办法把它拆成一个一个中间变量分步计算。比如x a | b c ^ d你完全先算tmp1 b c; tmp2 tmp1 ^ d; x a | tmp2;这样既避免了优先级问题也更容易用gdb在每一步查看数值。很多人担心拆开会影响性能其实现代编译器的优化能力极强固定模式下的中间变量基本都能被优化掉不会多生成指令。可读性提升带来的价值远大于那一点点微乎其微的性能差异。使用gdb时可以在断点用print查看某个子表达式的值。比如print (a b)它能实际计算这个子表达式并返回结果这正是理解操作符行为的利器。我在给学生演示i i是未定义行为时会在不同优化级别下运行发现结果不同然后解释这是编译器自由发挥的空间。这种现场演示比背一百条理论都管用。5.3 结合热点的实战练习题与拓展思路网上几个热门C语言练习题其实都和操作符紧密相关。比如“用位运算判断奇偶”if (n 1)比n % 2更底层却一样好用。“九九乘法表”看起来是嵌套循环实际上打印格式里用到printf(%d*%d%2d , i, j, i*j)这里面i*j就是操作符的计算。“字符串逆序”里用到i和j--的双指针本质上就是逗号表达式在for循环里的应用。“计算5*5鞍点问题”一般用stdio.h和limits.h例子里会出现INT_MIN之类的极值比较这就要小心隐式类型转换了。这些题目本身不难但如果你能刻意去分析每道题里操作符的优先级和类型转换收获会大很多。再给你两个可以直接上机的自测题。第一题int i 1; int j (i) (i); printf(%d %d\n, i, j);请问输出确定吗严格来说这个表达式是未定义行为因为i在一个表达式里被修改了两次一个i一个i没有序列点隔开。你实际运行可能得到3 3或2 3或别的结果但这道题的重点不是背输出而是理解“为什么不确定”。第二题unsigned int x 0xFFFFFFFF; printf(%d\n, x -1);你猜输出是1还是0有符号-1在比较时会被转换为unsigned int也就是UINT_MAXx和UINT_MAX相等所以x -1为假输出0。这个反直觉结果正是隐式类型转换的典型陷阱。最后分享一点个人经验操作符这块学到后面你会发现最有价值的不是记得住多少冷门规则而是对“表达式求值”这件事有一种敬畏心。我在实际开发中吃过太多这种亏所以现在写代码有一条铁律只要表达式里的操作符超过两层或者混用了不同类型就一定加括号或拆开。这看起来“很笨”但能让你少熬无数个夜去调那种“在不同编译器上表现不同”的bug。还有一个我一直在用的学习技巧收藏一张优先级表不是让你背而是每次遇到不确定的表达式就去查。查一个月你自然就免疫了。毕竟C语言的哲学是相信程序员但聪明的程序员从不给自己挖坑。