
聊C语言的时候很多人一上来就开始背运算符优先级表背完就忘写代码照样出错。我把话说在前面运算符不是靠背出来的是靠用出来的。这篇文章想聊的是把C语言里各类运算符掰开揉碎从算术、赋值、自增自减到位运算、三目运算、逗号运算每一步都配合真实踩过的坑和能直接抄的代码。适合刚学C语言、或者学了一阵子还在被各种优先级、副作用和“莫名奇妙结果”折磨的读者。看完你会发现原来那些看起来像玄学的输出背后几乎都能在运算符的求值规则里找到答案。1. 运算符全家福先知道有哪些再看怎么用1.1 按功能把运算符分成七大类C语言的运算符数量不多但每个都有自己的一亩三分地。按功能分大致可以分成算术运算符、赋值运算符、关系运算符、逻辑运算符、位运算符、条件运算符和其他运算符。算术里就是加减乘除和取余、-、*、/、%赋值里除了还有一套复合赋值、-、*、/、%、、、、|、^关系运算符是、、、、、!逻辑运算符是、||、!位运算符是、|、^、~、、条件运算符就是著名的三目运算符?:剩下的还有自增自减、--逗号运算符,以及像sizeof、下标运算符[]、成员访问.和-这类形式上像运算符的东西。建议初学者不要一上来就盯着优先级表。先把每个运算符是干什么的、操作数要什么类型、结果是什么类型这三件事搞清楚优先级表和结合性放到后面通过代码慢慢形成肌肉记忆。我用一个很土但有效的类比运算符就像菜谱里的切法、焯水、爆炒优先级就是下锅顺序。你光背顺序不练手法菜是不会好吃的。1.2 表达式求值的两条主线优先级和结合性任何一个表达式都会遇到两个核心问题先算谁再算谁。优先级决定的是不同运算符之间的先后结合性决定的是同级运算符从左还是从右这两者共同决定表达式的计算路径。结合性经常被忽略但它坑人不浅。比如赋值运算符是右结合的a b c就会先算b c再把结果赋给a所以链式赋值能成立。而加减乘除是左结合的a - b - c从左往右算等价于(a - b) - c。三目运算符?:和赋值一样是右结合这决定了a ? b : c ? d : e会被解析成a ? b : (c ? d : e)而不是(a ? b : c) ? d : e。所以我一直觉得优先级和结合性不需要一次性记完但必须知道“存在这么回事”。遇到复杂表达式最稳妥的做法是对自己写给别人看的代码加括号对别人写的代码先按优先级和结合性逐层还原再动手改。很多“经验老手”嘲讽加括号太初级可实际项目里因为一两个括号差异查了半天的例子比比皆是。括号不是能力不足是对自己和同事负责。2. 算术与赋值最容易看错符号、写错结果的两组运算符2.1 除法与取余整型截断、负数符号、浮点取余先聊算术运算符里最容易被低估的/和%。两个整数做除法结果是整数小数部分直接截断。5 / 2结果是2不是2.5。这是初学者第一个容易懵的地方。如果你想得到2.5就得让至少一个操作数变成浮点类型比如5 / 2.0或者(double)5 / 2。这里解释一下为什么C语言在做算术运算时会先把操作数转换成同一类型规则是低精度向高精度靠拢int和double混合时int会先转成double再做除法所以结果就是double了。如果两个操作数都是int那就按整数除法处理不会有浮点结果。取余运算符%的要求更严格两边都必须是整数类型浮点数用%在编译阶段就直接报错。如果你需要浮点数取余标准库提供了fmod()函数在math.h里用的时候记得链接数学库。还有一个细节是负数取余的符号问题。C99标准规定取余结果的符号跟随被除数也就是左边的数。-5 % 2结果是-15 % -2结果是1。这个规则在不同语言里不一样但既然写C就得按C的规矩来。实际写代码时取余最常见的用途是判断整除、进制换算、循环分页、哈希散列。比如判断一个数能不能被另一个数整除直接n % m 0就行。千万别用n / m * m n去判断整除虽然数学上成立但整数溢出或者边界情况会让你怀疑人生。2.2 赋值运算符的右结合性与“ab”拆解热词里有一条是“c语言 a b解释”这正好是个经典复合表达式。b是前置自增先把b加1然后整个表达式的结果就是加完之后的b。所以a b等价于先执行b b 1再把b的新值赋给a。假设b原来是5执行完a和b都是6。那如果写成a b呢后置自增的逻辑是先取b当前值作为表达式结果再把b加1。所以a b执行完后a是5b是6。很多人把这两条记反我的记忆方法是前置“先变后用”后置“先用后变”。在单独一句b;里没有区别但一旦放进表达式、参数列表、数组下标里区别立刻凸显。单纯看a b这条语句其实没必要纠结真正要命的是多个自增出现在同一表达式里。比如printf(%d %d, i, i)这种代码在不同编译器下结果都可能不一样属于未定义行为undefined behavior。未定义行为不是“结果随机”而是标准根本不管你永远不能依赖它。我见过面试题拿这种东西考人现实里谁这么写谁就是在给维护代码的同事埋雷。记住一条铁律不要在同一个表达式里对同一个变量做多次自增自减。2.3 复合赋值运算符的隐式转换复合赋值运算符长得像简写但其实藏了一个隐蔽的类型转换规则。以a b为例很多人理所当然地认为它等价于a a b。从结果上看差不多但严格说有一个区别复合赋值会把右侧计算结果隐式转换回左侧变量的类型再赋给a。举一个实际例子int a 3; double b 2.7; a b; // 等价于 a (int)(a b) (int)5.7 5如果写成a a b结果是5.7但赋值给int的时候也会被截断成5。看起来好像一样再看一个例子unsigned char c 250; c 10; // 等价于 c (unsigned char)(250 10) (unsigned char)260 4原因是unsigned char能表示的范围是0到255260截断到低8位就是4。这类问题在嵌入式开发、协议解析、图像处理里特别容易踩因为到处都是短整型和位操作。我的建议是涉及无符号短整型、位域、或者有符号数和无符号数混合运算时不要偷懒尽量显式写转换。c (unsigned char)(c 10);虽然长一点但任何人一眼就能看懂你在做什么。3. 自增自减、关系与逻辑短路、副作用和死循环三兄弟3.1 前置和后置自增在表达式的区别刚才聊a b时已经点到了前置和后置的用法区别这里再往深挖一层。自增自减运算符是少数带副作用的运算符所谓副作用就是表达式求值之外还改变了某个变量的值。这跟a b不一样a b算完a和b还是老样子但a算完a真的变了。这种副作用在循环里很好用比如while (i 10) { arr[i] i; }一行就完成了赋值和下标后移。但如果滥用代码可读性直线下降。我的建议是自增自减尽量独立成句不要嵌在复杂的表达式里。尤其是*p这种语义是取指针p指向的值再把p向后移一位新手看十遍也未必能确认是(*p)还是*(p)。这里插一句后缀运算符优先级高于单目*所以*p等价于*(p)不少老手也会一时想不起来。实际项目里有人喜欢写for (i 0; i n; i)有人喜欢写while (n--)都行。但有一点要注意后置自增会产生一个临时值某些编译器上可能比前置多一次拷贝开销不过现代编译器基本会优化掉不用为此过分纠结。读代码时如果看到一个变量在一条语句里又读又写先停下来把它拆成两步再往下看能省很多脑细胞。3.2 “”和“”写反的经典事故关系运算符里和!是判断相等和不等而是赋值。这组符号写错是C语言程序员最经典的失误之一。区别就一个字符的区别含义天差地别。常见事故现场是这样的本来想判断变量x是否等于5写了if (x 5)。这在C语言里不会报错因为赋值表达式的结果就是赋值后的值5非零为真于是if条件永远成立循环永远跑不完函数永远走同一条分支。更隐蔽的是if (p malloc(...))这种写法其实是故意用赋值结果作为条件判断这是合法且常见的C语言风格新手容易误读但不是错误。我的建议分两档如果你是刚学老老实实写if (x 5)编译时尽量开-Wall。GCC和Clang在开启警告后对if (x 5)这种明显可疑的赋值会给出assignment instead of comparison的提示能救你一次。如果你已经有一定经验确实想用赋值表达式压行也建议写成if ((p malloc(size)) ! NULL)把比较写完整别人读起来不会误解也规避了“赋值和比较”混在一起时的优先级问题。3.3 逻辑运算符的短路求值逻辑运算符和||有一个特别重要的性质短路求值。A B中如果A为假B根本不会被执行A || B中如果A为真B根本不会被执行。这个性质不是语言设计者的恶趣味而是非常实用的工具。最常见的用法是保护指针安全访问。比如你想访问一个指针指向的结构的成员但指针可能是NULL你可以写if (p ! NULL p-age 18) { // 安全操作 }当p是NULL时p ! NULL直接为假p-age不会被求值不会触发空指针段错误。如果把顺序反过来先写p-age 18 p ! NULL那么p为空时还是会崩溃。所以和||的顺序不是随意的是防守的第一道防线。另一个例子是判断闰年。闰年的规则是能被4整除且不能被100整除或者能被400整除。标准写法是if ((year % 4 0 year % 100 ! 0) || year % 400 0)这里既用了短路也用了括号保证运算逻辑。顺便说一句!运算符要注意和!区分。!a是对a取逻辑反结果是0或1a ! b是比较a和b不等。别把!a b读成“a且b都不存在”它实际上是(!a) b。4. 位运算、三目运算、逗号运算和其他冷门运算符4.1 位运算的四个基本操作置位、清零、翻转、取位位运算在面试题、嵌入式、协议解析里出场率极高。按位与、按位或|、按位异或^、按位取反~再加上左移和右移一共六个。先学会四个基本操作。给某个位或者某几位“置1”用或运算x | (1 n)把第n位置1其他位不变。给某位“清零”用与运算加取反x ~(1 n)先构造一个只有第n位为0、其余位为1的掩码再和x做与运算。“翻转”某一位用异或x ^ (1 n)0变11变0。“取某一位的值”用与运算后右移(x n) 1。这四个操作建议直接背下来因为它们是所有位运算技巧的基石。按位与还可以用来做奇偶判断if (x 1)为真说明x是奇数这比x % 2在某些场景下更直接尤其是嵌入式上取余运算可能被编译器优化成位运算但你没必要赌编译器直接写清楚即可。要注意位运算的操作数必须是整数类型不能是浮点数和指针。4.2 移位运算与乘除2的那些事左移和右移看起来简单细节里全是坑。左移n位右边补0相当于乘以2的n次方前提是没有溢出或丢失有效位。比如1 4就是16。右移n位左边补什么取决于类型无符号数补0有符号数在大多数平台上是算术右移即补符号位。-8 1在x86上结果是-4。这是“尽量符合直觉”的实现方式但它不是标准强制保证的所以跨平台时千万别写死依赖于算术右移的代码。移位运算的优先级比加减法还低这是个经常被忽视的点。于是1 4 1会被解析成1 (4 1)结果是32而不是先移位再加1。这个和直觉反差很大的点建议你在代码里一律加括号。写(1 4) 1谁看都不会误读。另外移位位数不能大于等于类型的位宽。对32位int执行1 32是未定义行为结果不可预测。这一点在写循环移位、哈希函数时尤其要小心可以先判断移位量。4.3 三目运算符的嵌套、右结合和类型转换三目运算符a ? b : c是C语言里唯一一个需要三个操作数的运算符。它的求值逻辑是先判断a为真求值b为假求值c最后整个表达式的结果就是被选中的那个值。三目运算符最大的坑有两个。第一个是嵌套可读性。a ? b : c ? d : e因为三目运算符右结合实际解析成a ? b : (c ? d : e)。逻辑上没毛病但人眼读起来很费劲。我的经验是嵌套三目最多用一层再多就改成if-else。代码是写给维护者看的不是用来证明自己脑力的。第二个坑是类型转换。三目运算符要求b和c的类型能够统一成一个公共类型如果不能编译器会做隐式转换。比如printf(%f\n, 1 0 ? 1 : 1.5);这里的1会被转成1.0输出1.000000。看起来还好但如果你写的是char *p condition ? malloc(10) : NULL;在某些编译器上可能报错因为malloc返回void *NULL是(void *)0类型不一样又没法统一。这时候就要显式转换。所以用三目运算符时能保证两个分支类型一致就尽量保证不行就加括号加转换。4.4 逗号运算符、sizeof与下标运算符的小细节逗号运算符,在C语言里不太起眼但它在for循环里出场率很高。它的规则是从左到右依次求值所有表达式整个表达式的结果是最后一个表达式的值。比如int x (1, 2, 3);x等于3。注意必须加括号因为逗号的优先级是所有运算符里最低的不加括号的话int x 1, 2, 3;会被解析成变量声明直接报错。sizeof也是个有意思的“运算符”不是函数。它有两种用法sizeof(类型)必须加括号sizeof 表达式可以不加括号。但建议统一加括号因为表达式里经常有优先级问题。比如sizeof的求值结果类型是size_t在printf里用%zu输出很多人写成%d编译器开了-Wall会警告但不会报错输出却是错的。下标运算符[]本质上是a[b]等价于*(a b)所以a[3]和3[a]在C语言里完全合法都是访问同一块内存。这种知识当冷知识知道就行写代码还是别这么干正常人都看懵。它的核心意义在于数组和指针的换算规则追根到底就是指针算术下标运算符只是语法糖。4.5 优先级速查表与避坑口诀优先级表我整理一份从高到低的常用版本不需要死记但要能看懂优先级运算符最高后缀()[]-.--高单目!~---*(类型)sizeof中高*/%中-中低低更低!再低更低^低|很低更低||再低?:更低赋值运算符等最低,避坑口诀有两条我认为最重要。第一位运算符优先级普遍低于关系运算符所以a b c会被解析成a (b c)这几乎肯定不是你要的一律加括号。第二赋值优先级非常低只高于逗号所以while (c getchar() ! EOF)解析成c (getchar() ! EOF)c拿到的是0或1而不是字符本身。这类坑我已经见过无数人踩了最靠谱的解决办法就一个该加括号的地方坚决加括号。5. 运算符实战三个能直接练手的小案例5.1 九九乘法表循环和算术运算符的组合拳九九乘法表几乎是每个C语言学习者的第一个完整程序它朴素但很能练基本功双层循环、字符格式化输出、算术运算符的熟练度都有。#include stdio.h int main(void) { for (int i 1; i 9; i) { for (int j 1; j i; j) { printf(%d*%d%-2d , j, i, i * j); } printf(\n); } return 0; }这里有两个运算符相关的小细节。第一% -2d里-2是printf的格式控制让输出左对齐占两个字符宽这样整个表的列是对齐的。第二内层循环条件是j i不是j 9这决定了乘法表是下三角形状。运算符在这里看起来只是*和但真正考验的是你如何设计循环边界此时运算符的优先级反而不是重点重点是循环变量之间的算术关系。很多初学者死在边界条件上就是因为没有把i和j的语义想清楚。5.2 完数判断取余运算符在因子求解里的作用完数是指一个数恰好等于它所有真因子之和。比如6的真因子是1、2、3加起来正好是6所以6是一个完数。这个题目在热词里出现刚好是练习取余运算符的好素材。#include stdio.h int isPerfect(int num) { if (num 1) return 0; int sum 0; for (int i 1; i num / 2; i) { if (num % i 0) { sum i; } } return sum num; } int main(void) { for (int n 2; n 10000; n) { if (isPerfect(n)) { printf(%d\n, n); } } return 0; }判断一个数i是否是num的因子用的就是取余num % i 0余数为0说明能整除。这里有个小优化循环上界是num / 2因为一个数的真因子除了它自己外不可能大于它的一半这是用除法运算符做边界剪枝的典型例子。换成任何“求约数”“求质数”的题目思路完全一样都是靠取余判断整除关系。5.3 PAT 1037 在霍格沃茨找零钱进制换算与取余这个题目来自PAT乙级题干说霍格沃茨的货币体系是29个纳特Knut1个银西可Sickle17个银西可1个加隆Galleon。要求给定应付价格P和实付金额A输出要找的零钱。思路很直接先把P和A都换算成最小的单位纳特做减法再换回加隆、银西可、纳特的三段格式。换算过程中除法、取余、乘法的配合特别典型。#include stdio.h int main(void) { int g1, s1, k1, g2, s2, k2; scanf(%d.%d.%d %d.%d.%d, g1, s1, k1, g2, s2, k2); int p g1 * 17 * 29 s1 * 29 k1; int a g2 * 17 * 29 s2 * 29 k2; int diff a - p; if (diff 0) { printf(-); diff -diff; } int g diff / (17 * 29); int left diff % (17 * 29); int s left / 29; int k left % 29; printf(%d.%d.%d\n, g, s, k); return 0; }这段代码把复合运算和优先级问题都暴露出来了g1 * 17 * 29是从加隆换算到纳特注意运算符结合性是从左往右数值不会溢出题目范围很小但如果你处理的是大整数就要考虑乘法会不会溢出。%和/的配合是进制换算的标准套路先除出高一档的数值再取余得到低档的余数一层层剥下去。这个套路在做时间换算时分秒、金额换算、角度换算时通用。5.4 日期计算一年中第几天逻辑运算符和条件运算符的配合热词里还有一个经典题目输入一个日期计算它是这一年的第几天。这题考的是闰年判断、累加、逻辑运算符的配合。#include stdio.h int main(void) { int year, month, day; scanf(%d %d %d, year, month, day); int leap (year % 4 0 year % 100 ! 0) || year % 400 0; int days[] {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; int sum 0; for (int i 0; i month - 1; i) { sum days[i]; } sum day; if (leap month 2) { sum 1; } printf(%d\n, sum); return 0; }这个例子有两处运算符使用值得细看。第一闰年判断表达式用了三个关系运算符和两个逻辑运算符的优先级高于||所以整个表达式等价于(year % 4 0 year % 100 ! 0) || (year % 400 0)正好对应闰年规则。这里括号不是必须的但加上更清晰。第二month 2的判断和leap变量用联合逻辑上处理“闰年且已经过了2月才需要加一天”这个边界条件。没有运算符知识这段逻辑会写得很臃肿有了短路求值和优先级意识代码就能压缩成几行清晰的条件判断。6. 常见问题与排查技巧实录6.1 scanf格式匹配、缓冲区残留和“运算符无关却天天遇到”的坑很多初学者写程序明明运算符都用对了程序还是一跑就错最后发现是输入格式问题。热词里有一条“scanf一定要输入abc吗”其实是在问scanf的格式控制串和实际输入如何匹配。这里给你一个实用结论scanf(%d, n)要求输入一个整数你输入abc时读取失败n没有被赋值返回值为0缓冲区里的abc还留在那紧接着的下一次scanf会继续读到同样的失败局面。这看起来和运算符没关系但如果你想判断输入是否成功就回到了赋值运算符的应用上int n; while (scanf(%d, n) 1) { // 只有读到了整数才进来 }scanf返回成功匹配并赋值的参数个数用 1判断正好是关系运算符和赋值运算符配合的经典模式。如果scanf读取失败导致死循环多半是缓冲区里的非法字符一直没被清掉。解决办法是读失败后用getchar把残留字符吃掉或者干脆改用fgets加sscanf的套路。这些经验说多了都是泪但遇过一次就长记性了。6.2 文件缓冲区getchar与fgets混用为什么老出错C语言里getchar、scanf、fgets这些输入函数都涉及缓冲区。最常见的问题是先用scanf(%d, n)读数字后再调用getchar()结果getchar返回的不是预期字符而是上一个输入留下的换行符\n。这是因为scanf读取数字时会在遇到空白字符时停下但那一个换行符还留在缓冲区里getchar一上来就读到了它。处理办法是在scanf之后、getchar之前手动吃掉这个残留的换行符scanf(%d, n); getchar(); // 吸收换行符 char c getchar();这个问题的本质是输入流的缓冲机制很多初学者误以为是自己运算符写错在和之间反复横跳结果方向完全错了。排查这类问题我的经验是先确认输入函数是否按照预期把数据读走再去看逻辑和运算符。输入层的问题不解决后面所有表达式求值都是给错误数据打工。6.3 用GDB一步步看表达式求值遇到运算符相关的诡异结果我最推荐的排查工具是GDB。热词里也有“利用gdb调试c语言程序”那就展开说说。GDB的print命令可以直接对表达式求值比如在断点处执行p a b * cGDB会按C语言规则计算并打印结果。你甚至可以一次打印多个表达式对比结果。(gdb) p x $1 5 (gdb) p x $2 5 (gdb) p x $3 6这个例子直接展示了后置自增的“先用后变”第一次print x时结果还是5但副作用已经发生第二次print x就变成了6。用GDB把表达式拆开来观察副作用比盯着屏幕人肉分析要高效得多。另一个技巧是使用GDB的set命令直接修改变量的值用来测试边界条件。比如位运算程序里你想测试1 31但又不想改代码重编就可以在断点处set var n 31然后单步看结果。调试熟练后你对运算符的语义理解会更扎实因为每一步都能看到内存和寄存器里的真实变化。6.4 运算符相关错误速查表最后整理一个我日常排查中反复用到的速查表按症状分列症状大概率原因处理方式条件判断总是为真把写成了开-Wall编译检查if条件循环不执行或死循环边界条件里赋值表达式做判断打印循环变量检查运算符整数除法结果总少一位忘了区分整数除法与浮点除法让至少一个操作数为double%两边是浮点数编译报错取余只支持整数改用fmod()负数取余结果和预期不同忘掉C99余数符号跟随被除数查文档或手动调整符号位运算结果和预期差很远优先级低于关系运算符导致解析错误加括号再调试a[b]和b[a]混用看起来也行下标运算符本质是指针算术别用正常写a[b]scanf第二次失败死循环缓冲区残留非法字符清空缓冲区或改用fgets自增自减结果在不同平台不同未定义行为不要把多个副作用的变量写在同一表达式这份表不全面但覆盖了我在教学和代码审查里见过的八成问题。很多错误一眼看上去是“运算符没学明白”排查到最后反而是输入、编译选项、或者分支逻辑的问题。所以我的习惯是先怀疑输入再怀疑优先级最后怀疑人。如果让我给刚学C语言的读者一条建议那就是多写小的验证程序。遇到任何疑惑的表达式别停在“好像应该这样”直接写一个十行的小程序跑一下再用GDB看一眼中间值。C语言运算符的规则其实不多但你一定要亲手踩过、查过、调试过才算真正掌握。