C语言系列走到第三篇,刚好是让初学者又爱又恨的一章:运算符与表达式。说它“入门”,是因为运算符翻来覆去就那么几十个,语法规则也就一张优先级表;说它“水深”,是因为表达式求值背后的类型转换、副作用顺序、未定义行为,足够让写了三五年C的人照样翻车。我见过太多人在这里不是学不会,而是学“浅”了——会背优先级表,却看不懂一段嵌套表达式到底在算什么;会写a + b,却说不清a++ + ++b为什么在不同编译器上结果不一样。
这篇文章就把这章彻底讲透。我会从运算符的分类和优先级开始,逐个拆解算术、赋值、关系、逻辑、位、三目、逗号这些运算符的行为和坑点,再结合类型转换、表达式求值、未定义行为这些进阶话题,最后附上我实际开发中整理的问题排查经验。无论你是正在跟课学C语言的新手,还是准备笔试面试的在校生,或者做嵌入式、单片机开发想把位运算用得溜的工程师,这篇都值得从头看到尾。
1. 先把地图铺开:运算符和表达式到底在说啥
1.1 一句话分清“运算符”和“表达式”
运算符就是C语言里的“动作指令”,比如+、*、&&、?:这些符号,它们告诉程序要对数据做什么操作。表达式则是“运算符 + 操作数”组成的整体,比如a + b、x = y = 10、(a > b) ? a : b,每一个表达式经过求值后都会得到一个结果值。
打个比方:运算符是菜谱里的“切”“炒”“蒸”,操作数是食材,而表达式就是一道做出来的菜。菜的好坏不仅取决于食材(数据的类型和值),更取决于动作的顺序和手法(运算符的优先级和结合性)。C语言几乎所有的语句都建立在表达式之上,赋值是表达式,函数调用是表达式,循环条件也是表达式,理解这一层,后面看任何代码都不会发怵。
1.2 运算符家族全景:到底有哪几类
C语言的运算符按功能可以分成大致七类。我建议你先把这张表刻在脑子里,后续所有细节都围绕它展开:
- 算术运算符:
+ - * / % ++ -- - 赋值运算符:
= += -= *= /= %= <<= >>= &= |= ^= - 关系运算符:
< <= > >= == !=(结果只有0或1) - 逻辑运算符:
! && ||(操作数按“真/假”处理,结果仍是0或1) - 位运算符:
& | ^ ~ << >>(直接操作二进制位) - 条件运算符:
?:(唯一的三目运算符) - 逗号运算符:
,(顺序求值,取最后一个结果)
补充两个特殊角色:sizeof其实也是运算符而不是函数,括号只是它的语法习惯写法;下标运算符[]、成员访问运算符. ->在表达式求值中也扮演重要角色,只是它们更多出现在数组、结构体和指针场景,这一章重点先放在运算类符号上。
1.3 每一个表达式都有“值”这件事,很多人忽略了
C语言里“表达式有值”这个特性极其重要,却常常被初学者忽略。赋值表达式a = 5的值就是5,所以你可以连续赋值:a = b = c = 10;关系表达式a > b的值是0或1,所以它可以继续参与其他运算;甚至逗号表达式(a++, b++, a+b)的值是最后一个子表达式的值。
意识到“表达式有值”,很多代码就好理解了:while (ch = getchar())不是语法错误,它先把getchar()的返回值赋给ch,再拿这个值判断真假;return a = b;也是合法的,它会返回赋值后的结果。很多书上强调“C语言一切皆表达式”,虽然夸张,但把握住这个思路,读代码会顺很多。
2. 常用运算符逐个拆解:从行为到坑点
2.1 算术运算符:整数除法、取余、自增自减
算术运算符里,+ - *没什么好说的,重点在/和%。两个整数相除得到的是整数商,小数部分直接丢弃:7 / 2结果是3,而不是3.5。注意这是“向零截断”,C99之后标准明确规定向零取整,所以-7 / 2结果是 -3,而不是数学上的 -4。想做真正的小数除法,至少其中一个操作数得是浮点型,写成7.0 / 2或(double)7 / 2才有3.5。
取余运算符%的坑也不少。C99规定余数的符号与被除数相同,所以-7 % 3结果是 -1,7 % -3结果是1。这个规则许多初学者不知道,写循环遍历、哈希散列时一旦遇到负数就会算出出乎意料的结果。另外%的操作数必须是整数,浮点数取余要用fmod函数。
自增++和自减--最值得单独说。i++先返回原值再自增,++i先自增再返回新值。放在语句里单独使用没区别,但一旦嵌进表达式就会出乱子。
特别注意:
printf("%d %d", i++, i++);这类代码,函数参数的求值顺序在C标准里是未指定的,不同编译器可能算出不同结果。我自己实测过 GCC 和 Clang 在同一段代码上输出就不同。
解决办法只有一个:自增自减单独成语句,不要和别的运算混在一起。
2.2 赋值运算符:=是运算不是断言
很多从数学课过渡过来的初学者,会把=理解为“等于”,这是最大的误解。C语言里=是“赋值动作”,含义是“把右边的值拷贝到左边的变量里”;判断相等用的是==。这两个搞混,轻则逻辑错误,重则程序崩溃——经典错误是if (a = 5)本身是合法的,它把5赋给a,然后判断5是否为真(非0即为真),于是这个分支永远成立,而a的原值被覆盖了。
赋值运算符的结合性是“从右往左”,这就是连续赋值能成立的原因:a = b = c = 10先算c = 10,再把结果10赋给b,最后赋给a。复合赋值运算符+= -= *= /= %=则是一种简写,a += 3等价于a = a + 3,但好处是只读取a一次,在涉及数组下标、指针解引用时效率更高也更安全。
还要注意复合赋值和普通赋值的优先级都特别低,只比逗号运算符高。所以ch = getchar() != EOF实际是ch = (getchar() != EOF),先把比较结果赋给ch。如果你本意是把getchar()的返回值赋给ch再和EOF比较,就必须写成(ch = getchar()) != EOF。这个括号差异是我在教新手时发现命中率最高的错误。
2.3 关系运算符和逻辑运算符:真假与短路
关系运算符< <= > >= == !=的结果都是int类型的0或1。注意C语言里没有专门的布尔类型,一切非0值都当作“真”,只有0是“假”。所以if (x)等同于if (x != 0),if (!x)等同于if (x == 0)。这一段逻辑是C和Java、Python最大的思维差异之一,从别的语言转过来的朋友需要格外适应。
逻辑运算符&&(逻辑与)、||(逻辑或)、!(逻辑非)操作的是操作数的“真假属性”,结果同样是0或1。关键特性是“短路求值”:a && b如果a为假,b根本不会执行;a || b如果a为真,b不会执行。这个特性在开发中是双刃剑——用得好可以写出简洁的防御代码:
if (ptr != NULL && strlen(ptr) > 5) { // 安全:ptr为NULL时strlen不会执行 }用得不好就埋雷。我见过一个生产事故:有人写if (a || (b = func())),本意是“a为假时调用func并把结果赋给b”,结果某天a一直为真,func永远没被调用,b一直保留旧值,程序在后续逻辑里做了错误的判断。排查了很久才定位到短路求值上。经验就是:任何带副作用的表达式,都不要放在&&或||的右侧,除非你100%确定它该不该执行。
逻辑运算符的优先级低于关系运算符,高于赋值运算符。结合性方面,&&和||都是左结合,但有个新手常踩的大坑:&&的优先级高于||。所以a || b && c等价于a || (b && c),和数学里的“与优先于或”一致。
2.4 三目运算符?::从右往左的“小if”
三目运算符条件 ? 表达式1 : 表达式2是C语言里唯一需要三个操作数的运算符。它的行为很直白:条件为真执行表达式1,否则执行表达式2,整个表达式的值就是被选中那个子表达式的值。很多面试题会问“为什么三目运算符从右往左结合”,这是因为它的语法设计:右结合才能支持嵌套,比如a ? b : c ? d : e会解析成a ? b : (c ? d : e)。
虽然嵌套三目能把代码写得很短,但我强烈不建议这么做。可读性太差,一旦条件复杂,别人维护代码时心里只剩“这是什么鬼”。我自己的习惯是:三目运算符只用于“二选一取个值”的简单场景,比如int max = (a > b) ? a : b;,超过一个条件就老老实实写if-else。
还有一个隐藏细节:三目运算符的返回类型会自动做“常见类型转换”。true ? 1 : 2.5的结果是1.0而不是1,因为两个分支一个int一个double,编译器会把int提升为double。如果分支是指针,还会涉及指针类型兼容性的检查,写代码时要注意两边类型尽量一致。
2.5 逗号运算符:顺序求值和“取最后一个”
逗号运算符是优先级最低的运算符,它把多个表达式串起来,从左到右依次求值,整个表达式的值是最后一个子表达式的值。最常见的用法是for循环里同时初始化或更新多个变量:
for (i = 0, j = n - 1; i < j; i++, j--) { // 经典的双指针操作 }但要分清:逗号在变量声明(int a, b;)、函数参数列表(func(a, b);)里只是分隔符,不是逗号运算符。只有在表达式语境中出现的逗号才是运算符。比如int x = (a++, b);这个括号里的逗号就是运算符,x的值是b。一个常见的笔试题是int x = a, b;,其实这根本不是逗号运算符,而是声明了两个变量x和b,x初始化为a的值。括号决定了语义,写代码时要格外小心。
3. 位运算符:嵌入式开发的地基
3.1 六种位运算速查
位运算直接操作整数在内存中的二进制位,是C语言相对很多高级语言的独门武器。六个运算符的功能如下:
&按位与:两个位都为1结果才为1,常用来“清位”|按位或:两个位有一个为1结果就为1,常用来“置位”^按位异或:两个位不同结果为1,相同为0,常用来“翻转位”~按位取反:0变1、1变0<<左移:右侧补0,相当于乘2的幂>>右移:左侧补符号位(算术右移)或补0(逻辑右移),由实现定义
位运算最适合的操作对象是unsigned类型。对有符号整数做右移,C标准只说是“实现定义”,大多数平台采用算术右移(补符号位),但依赖这个行为并不稳妥。左移时如果移出的位包含符号位,还可能导致未定义行为。我的原则:位运算永远用无符号类型。
3.2 位运算的典型应用场景
单片机、嵌入式开发里寄存器操作离不开位运算。比如你想把某个寄存器的第3位置1、第5位清0,标准写法是:
reg |= (1U << 3); // 置位第3位 reg &= ~(1U << 5); // 清位第5位 reg ^= (1U << 7); // 翻转第7位判断某一位的值则用if (reg & (1U << 3))。注意这里要加括号,因为&的优先级低于==,如果写成reg & (1U << 3) == 0,实际解析为reg & ((1U << 3) == 0),逻辑完全错了。
位运算另一个经典应用是用位掩码打包多个状态。一个32位整数可以拆成多个字段,比如用低8位存数量、高24位存类型,通过移位和与运算读写,省内存且效率高。网络协议头解析、图像像素处理这块用得非常频繁。
异或还有一个著名的小技巧:不借助临时变量交换两个整数:
a ^= b; b ^= a; a ^= b;这个写法看起来很酷,但实际开发里我不会用它。原因:一是可读性差,二是如果a和b指向同一个变量,结果直接归零,风险太大。交换变量老老实实用临时变量,编译器优化后性能完全一样,代码却好懂得多。位运算的效率确实高,但高在“机器指令少”,不是高在这些花活上。
3.3 移位运算与乘除法的关系
左移一位相当于乘2,右移一位相当于除以2(无符号整数),所以x << 3相当于x * 8,x >> 4相当于x / 16。我在早期写代码时热衷用移位替代乘除,觉得这样“优化”了。后来发现这是过度优化——现代编译器对乘除常数的优化已经做得很好,x * 8在汇编层面本来就会生成移位指令,手写移位反而降低可读性。
移位真正不可替代的场景是:把多个字节拼成一个整数、把一个整数拆成多个字节、实现状态机的位标志判断。比如大端小端字节序转换:
uint32_t val = (b0 << 24) | (b1 << 16) | (b2 << 8) | b3;这类代码阅读者一看就明白意图,移位在这里是“语义正确”的选择,而不是“性能优化”的炫技。
4. 优先级与结合性:一张表解决所有纠结
4.1 优先级表该怎么看
完整的C语言运算符优先级表,从上到下大约是这样的:()括号和下标 → 单目运算符(! ~ ++ -- - + * & sizeof)→ 乘除取余 → 加减 → 移位 → 关系 → 相等 → 位与 → 位异或 → 位或 → 逻辑与 → 逻辑或 → 三目 → 赋值 → 逗号。
这张表不需要死记硬背。真正需要记住的关键层级就那么几条:
- 单目运算符高于所有双目运算符
- 算术高于关系,关系高于逻辑
- 位运算优先级低于比较运算,这是个隐患
- 赋值运算符几乎垫底,只有逗号在它下面
- 三目运算高于赋值,但低于所有双目运算符
剩下的细节,我建议一律用括号明确表达意图。C语言不是让你炫技的,写(a + b) * c比a + b * c让读者舒服得多。同事维护你代码时,不会感谢你节省了两个括号。
4.2 结合性:左结合和右结合怎么影响求值
优先级决定了运算符谁先算,结合性决定了一串“同级运算符”从左算还是从右算。比如a - b - c,减法左结合,等价于(a - b) - c;a = b = c,赋值右结合,等价于a = (b = c)。
容易搞混的是单目运算符的右结合:*p++等价于*(p++),不是(*p)++。后缀自增优先级高于解引用,所以p先自增、再取原p所指的值。这个细节在遍历数组、链表时特别容易出错,我想很多C语言学习者都在这里栽过跟头。
三目运算符和赋值运算符一样是右结合,所以a ? b : c ? d : e的解析顺序是从右往左嵌套。记住“赋值和三目是右结合,其他双目运算符基本左结合”这一条,绝大部分结合性问题都不会绊住你。
4.3 经典歧义表达式逐行拆解
我挑几个实际工作中见过很多次的歧义表达式,逐行分析一下:
int x = a > b ? a : b; // 三目优先级低于关系运算,无歧义 int y = a << 2 + 1; // 实际是 a << (2+1),因为加法和减法优先级高于移位 if (x & mask == 0) // 实际是 x & (mask == 0),几乎肯定不是本意 *p++ = *q++; // 实际是 *p++ = *q++,等价于 *(p++) = *(q++)第四行代码是C语言里最著名的指针赋值惯用法,两个后缀自增先执行,表达式整体等价于“把q当前指向的值拷贝到p当前指向的位置,然后p和q分别后移一个单位”。这个写法在字符串拷贝函数里极为常见:
while ((*p++ = *q++) != '\0') ;这行代码把字符串拷贝和指针后移一次性完成,简洁高效。但新手看它就像看天书。我的建议:这种惯用法你至少要能看懂,因为现成的库函数、老代码里到处都是;但自己写代码时如果团队里新手多,拆成多行也未尝不可。
5. 表达式求值的进阶机制:类型转换与未定义行为
5.1 隐式类型转换:整型提升和“常见算术转换”
表达式中出现不同类型的操作数时,编译器会自动进行隐式转换。规则简单说:先把char、short提升为int(这叫整型提升),再把不同精度的数值统一成“较高”类型。比如int和float运算结果是float,float和double运算结果是double。这个机制叫“通常算术转换”。
最常见的坑是“有符号和无符号混用”。C语言有一条规则:当int和unsigned int在一起运算时,int会先转换为unsigned int。于是下面这段代码的结果震碎了无数新手的认知:
int a = -1; unsigned int b = 1; if (a < b) { // 永远不会进入! }因为-1转换为无符号整数后是4294967295,当然大于1。只要无符号和有符号混用,优先把有符号转为无符号,结果往往出人意料。解决办法是尽量避免混用,比较时显式转型。
整型提升还带来一个现象:char类型的加减运算结果会变成int。比如:
char a = 200; // 假设char是有符号的,200可能被截断 char b = 100; char c = a + b; // 先提升为int运算,再截断赋给char如果char是8位有符号类型,200会变成-56,a+b在int层面是44,c的值反而是44。这种隐秘的截断很容易让人困惑,处理数据时养成用int接中间结果的习惯会省很多事。
5.2 强制类型转换:什么时候该用、什么时候不该用
强制类型转换的语法是(类型)表达式,它告诉编译器“按我指定的类型对待这个值”。我常用的场景有几个:
- 整数除法想要小数结果:
(double)a / b - 访问硬件寄存器时精确控制读写的位数:
*(volatile uint32_t *)addr - 指针类型转换:
(struct my_type *)buffer
强制转换是把双刃剑。整数转整数是“截断式转换”,大数变小数的过程会丢失高位数据,务必清楚自己在干什么。浮点转整数是“向零截断”,(int)3.99是3不是4。所有转换都建议写注释说明原因,否则后续维护者完全看不懂为什么这里要强转。
另外要记住sizeof结果的坑:sizeof的返回值类型是size_t,它是无符号类型。sizeof(arr) - 1如果数组长度为0,结果不是-1而是巨大的无符号数。写循环时要警惕这类无符号溢出,必要时先加(int)强转。
5.3 未定义行为:表达式里最暗的礁石
C语言标准里明确列出了一批“未定义行为”(undefined behavior),意思是代码怎么写都行,编译器可以给出任何结果。表达式求值领域里最常见的两个未定义行为是“修改变量和访问变量交织”以及“函数参数的求值顺序不确定”。
i = i++ + ++i; // 未定义行为! printf("%d %d\n", i++, i++); // 未指定求值顺序 arr[i] = i++; // 未定义行为!这些代码在不同编译器、不同优化级别下可能得出完全不同的结果。我记得在一次笔试里,考官专门问了i = i++ + ++i的输出,正确答案不是某个数字,而是“未定义行为,不能预测”。很多人不理解:我实测过明明有结果啊?但那是GCC在某优化级别下的产物,换个编译器、换个参数结果就不同了。写产品代码的人必须杜绝这类写法。
判断标准很简单:在一个表达式中,如果同一个变量被修改多次,或者被修改的同时又被读取(读取的目的是为了修改它本身除外),而中间没有插入“序列点”,就是未定义行为。C11标准用“先序关系”来精确描述这个问题,初学者不深究也没关系,记住结论即可:i++单独成句,绝不要在一个表达式里对同一变量搞两次自增。
6. 实战问题排查:我在代码里遇到的那些坑
6.1 浮点比较:为什么a == 0.1总是不成立
浮点数在计算机里是二进制近似存储的,0.1这个十进制小数在二进制里是无限循环小数,所以0.1 + 0.2不等于0.3,而是约等于0.30000000000000004。直接比较浮点数是新手最常见的错误之一。
我自己的做法是引入一个误差范围:
if (fabs(a - b) < 1e-9) { // 认为a和b相等 }比较大小则用if (a > b - 1e-9)之类的写法。如果是金融计算、需要精确十进制结果的场景,不要用浮点,直接考虑用整数分单位存储(比如金额用“分”做单位),或者用定点数。浮点适合范围大、精度要求不苛刻的工程计算。
6.2 赋值与等于的混淆:编译器为什么不报错
if (a = b)不报错,是因为C语言合法地把“赋值表达式的值”用于条件判断。很多团队为了防止这个错误,会写“阳性条件”风格:if (10 == x)。把常量放左边,如果误写成if (10 = x),编译器立刻报错,因为不能给常量赋值。这个习惯能挡掉一大类低级错误。
但现代编译器也很贴心,GCC和Clang对if (a = b)会给出-Wparentheses警告,如果你明确就是想赋值,可以写成if ((a = b))加一层括号,警告就会消失。即便如此,我还是建议赋值语句单独写一行,然后判断,代码清晰度对后续维护的价值永远大于省那一行。
6.3 短路求值带来的“选择性执行”
前面说了短路求值,这里再补充一个真实场景:有些开发者会用a && b来“压缩”逻辑,比如:
(is_valid && process(data));这种写法把process的执行绑定在is_valid为真时,省了一个if。但问题在于:它返回值被丢弃,逻辑意图不够明确,而且一旦将来要在process失败时做处理,就得重构。代码的可读性永远优先于“简洁”的炫技。我偏好显式if,逻辑清晰不容易埋雷。
6.4 取余和除法的负数陷阱
C99之后取余符号与被除数一致,但很多老教材、旧编译器(比如某些嵌入式交叉编译器)的行为可能不同。如果代码要跨平台,最好在注释里写明%的期望行为。对于“负数除法”的应用场景,比如钟表时间换算、数组环形缓冲区下标计算,一定要先用取模运算调整为正数再处理。
int idx = (current + offset) % size; if (idx < 0) { idx += size; // 把结果调整为合法的正数下标 }这段代码在环形队列、分页逻辑里很常见,属于位运算之外另一个“运算符实践出真知”的地方。
6.5 综合案例:看一段冒泡排序和字符串逆序里的运算符
冒泡排序是最经典的C语言入门代码,它的核心就是比较运算符、赋值运算符和临时变量交换的组合:
void bubble_sort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; } } } }这里的arr[j + 1]用到了下标运算符和加法运算符的配合,temp交换用到了赋值运算符,循环条件里用了关系运算符和逻辑与的组合。可以说一段排序代码把运算符章节的核心全串起来了。
再看字符串逆序,输入用fgets读取一行,需要注意fgets会把换行符也读进缓冲区,逆序前通常要先把末尾的\n去掉:
char str[100]; fgets(str, sizeof(str), stdin); str[strcspn(str, "\n")] = '\0'; // 去掉换行符 int i = 0, j = strlen(str) - 1; while (i < j) { char temp = str[i]; str[i] = str[j]; str[j] = temp; i++; j--; }这段代码里又有下标、指针、自增、关系、赋值,加上fgets和strlen的配合,是练习表达式求值的绝佳素材。PTA、OJ上这类题目很多,建议新手自己手写一遍,再对照优化写法,收益远大于看十遍书。
6.6 排查顺序:遇到“算错了”的问题怎么定位
表达式结果不符合预期时,我有一个固定的排查顺序,分享给大家:
- 先确认操作数的类型是否符合预期,尤其是
char、short的整型提升、有无符号混用 - 再用
printf打印中间变量的值和类型,必要时用%zu打印sizeof结果 - 检查优先级:歧义表达式直接加括号测试
- 检查副作用:把
i++单独提出来执行,再跑一次对比 - 最后确认编译器的警告信息,
-Wall -Wextra打开,很多坑编译期就警告了
我个人强烈建议从学C语言第一天就把编译警告当作错误对待。GCC加上-Wall -Wextra -Werror,Clang也类似。宁可前期多花一分钟处理警告,也不要在运行期花一小时查一个编译器早就告诉你的问题。
回到标题本身,“运算符与表达式”这一章在C语言课程体系里的位置很特殊:它是“语法知识”和“编程能力”之间的桥梁。前面的变量、类型是建构数据的基础,后面的数组、指针、结构体都要靠表达式把数据“动起来”。这一章的内容不是背完运算法则就结束了,真正的理解来自反复写代码、踩坑、修正。
我在实际教学中发现,学得最快的同学都有一个共同习惯:每学一个运算符,就写一个几行的测试程序,用printf把所有边界情况打印出来自己看。比如学了取余,就试7 % 3、-7 % 3、7 % -3;学了移位,就试负数右移和无符号右移的区别。这种“主动实验”的方式,比死记硬背优先级表高效得多。C语言的表达能力很强,但它的自由也意味着责任——每一个表达式都在和机器的底层行为打交道,尊重规则、勤加验证,才能写出可靠又优雅的代码。