学习C语言的人,很多都卡在这一章:运算符与表达式
别的语法你背一背就能写,到了运算符这里,*p++到底先取指针还是先移动指针?1/2为什么算出0?a = b = c为什么合法?(a+b>c && b==c)里面到底先算谁?有些同学在这里开始怀疑自己是不是不适合写代码。
其实不是你不适合,是这一章的知识点太碎。运算符有四十多个,优先级表长得像天书,表达式求值还牵扯隐式转换和未定义行为,任何一个环节掉链子,代码就出诡异结果。这篇文章就是想把这一章彻底讲透,从运算符分类到优先级记忆,从隐式转换到典型的坑,配合大量能直接抄走的代码和自查方法。适合刚学完变量和数据类型、正在啃这一章的初学者,也适合学了半年还总在运算符上翻车的同学,还可以当作复习提纲用。
1. 运算符全景:先看清C语言家族里到底有哪些运算符
1.1 算术运算符:最熟悉也最容易翻车的地方
先说五个二元算术运算符:+、-、*、/、%。加减乘没什么好讲的,重点在两个:除法和取余。
关于除法,C语言里的整数除法是截断除法,不是四舍五入。7 / 2结果是3,不是3.5,也不是4。-7 / 2呢?结果取决于编译器,在绝大多数现代编译器上得到的是-3,因为C99标准规定向零取整。这个坑在移植老代码时容易踩,早期某些平台上整数除法是向下取整的,-7 / 2会给-4。
关于取余,%要求两个操作数必须是整数类型。取余结果的符号,C99之前的标准没规定,之后规定余数的符号跟被除数一致。举例说,-7 % 3在C99之后的标准下是-1,7 % -3是1。有一条很有用的规律:(a / b) * b + a % b恒等于a。你自己可以拿几组正负数验证一下,这个恒等式能帮你理解除法和取余到底是怎么配合的。
再说一个新手最容易踩的雷:除法运算中整数溢出和除零。整数除以0是未定义行为,程序通常直接崩溃或者给出一个垃圾值。浮点数除以0不会崩,给的是inf。我见过不少同学在写平均值的时候直接sum / n,压根没检查n是否为0,等到数组长度为0的时候整个程序就炸了,这类问题在真实项目里通常会导致线上事故。所以写除法之前一定要先确认分母不为0。
1.2 赋值运算符:"="不是"等于",是"把东西放进盒子里"
很多初学者的第一个坎就是=和==分不清。=是赋值,把右边的值放到左边的变量里;==是判断相等,返回1或0。写代码的时候把if (a = 5)写成判断条件的并不少见,这个表达式会把5赋给a,然后判断a的值是否为真——5是真,于是条件永远成立。这类bug编译器会给你一条警告,但如果你没开-Wall,它很可能就默默放过了。
C语言的赋值运算符有一个特点:赋值表达式本身是一个值。这个值就是赋进去的值。正因如此,才能写出链式赋值:
int a, b, c; a = b = c = 0; // 合法,从右往左依次赋值这条语句的本质是a = (b = (c = 0)),先给c赋0,整个表达式c = 0的值是0,再赋给b,最后赋给a。
复合赋值运算符是另一类:+=、-=、*=、/=、%=,以及后面位运算里的<<=、>>=、&=、|=、^=。a += b等价于a = a + b,但注意:复合赋值只对左操作数求值一次。对于简单的变量名,两者没有区别;但如果左边是数组元素或者指针解引用,比如arr[i++] += 5和arr[i++] = arr[i++] + 5,前者i只自增一次,后者自增两次,行为完全不同。这是面试喜欢问的细节,也是实际代码里隐藏很深的bug。
1.3 关系运算符与逻辑运算符:真假的博弈
关系运算符包括<、>、<=、>=、==、!=六种,结果只有0和1。很多人以为C语言里"真"就是1,其实任何非0值都视为真,0视为假。-1、2.5、任意非空指针都是真。这是个非常重要的概念,后面学指针和条件判断全靠它。
逻辑运算符有三个:&&(逻辑与)、||(逻辑或)、!(逻辑非)。这里有两个关键特性:
第一,短路求值。a && b中,如果a为假,b根本不会执行;a || b中,如果a为真,b根本不会执行。这个特性在实际开发中极其常用,最典型的写法是:
if (p != NULL && p->value > 10) { // 安全,p为NULL时不会访问成员 }这里的顺序不能反过来。如果写成p->value > 10 && p != NULL,当p是NULL时,访问p->value本身就已经是未定义行为,短路也救不了你。同样的道理,if (i < n && arr[i] == target)里下标越界检查必须在前面。
第二,逻辑运算符的结果一定是0或1。!x的结果要么是0,要么是1。但注意!x跟x == 0完全等价,跟x != 0相反。想判断a是否在区间(0, 10)内,新手很容易写成0 < a < 10,这在C语言里语法合法但逻辑是错的,0 < a先求出0或1,然后拿这个结果去跟10比较,结果恒为真。正确的写法是a > 0 && a < 10。
1.4 位运算符:C语言的优势基因
位运算符是C语言区别于Java、Python这些高级语言的特色之一,包括&(按位与)、|(按位或)、^(按位异或)、~(按位取反)、<<(左移)、>>(右移)。它们的操作数必须是整型。
位运算和逻辑运算要区分清楚:&和&&长得像,含义完全不同。x & 1是取x二进制的最低位,x && 1是判断x和1是否都为真。下面这段代码是经典面试题:
if (x & 1) { // x是奇数 } if (x && 1) { // x非0 }左移运算符<<把二进制位整体左移,右边补0,左移一位相当于乘2,是效率很高的乘2操作。右移分为逻辑右移和算术右移:对有符号数,大多数编译器实现的是算术右移,左边补符号位;对无符号数,左边补0。这个差异很重要,负数右移和正数右移结果不一样。
位运算的实际应用场景特别多。嵌入式开发里操作寄存器,就是读一个寄存器的值、改某几位、再写回去,全部用的是&、|、^、<<。系统编程里权限管理用位标志,网络编程里校验和计算也是16位一组的位运算。我在后面第4节会专门给一个位标志打包解包的实战例子。
1.5 其他运算符:sizeof、三目、逗号,个个都能玩出花样
sizeof运算符返回变量或类型所占的字节数,结果类型是size_t(无符号整数)。它在编译期就能算出结果,不会在运行时求值,所以sizeof(arr) / sizeof(arr[0])是计算数组元素个数的经典写法。
三目运算符?:是C语言里唯一一个三目运算符,格式为条件 ? 表达式1 : 表达式2。它本质是一个表达式,有返回值,所以可以直接赋值:
int max = (a > b) ? a : b;三目运算符可以嵌套,但不建议写太多层,嵌套三层以上代码基本就没法读了。另外注意三目运算符的结合性是从右往左的,a ? b : c ? d : e等价于a ? b : (c ? d : e)。
取地址运算符&和解引用运算符*虽然通常是在学指针的时候才接触,但从分类上来说它们也是单目运算符。&x得到变量x的地址,*p得到指针p指向的那个变量。这两个运算符和++、--组合起来能写出很多让人头疼的表达式,比如*p++,我在第2节专门分析。
逗号运算符逗号表达式由逗号分隔的多个表达式组成,按从左到右的顺序求值,整个表达式的值取最后一个子表达式的值。
int x = (a, b, c); // x的值是c,a和b算但结果被丢弃逗号运算符最常见的应用是在for循环里一次初始化多个变量:for (i = 0, j = n - 1; i < j; i++, j--)。还有在用宏构造复合语句时,也会经常用到逗号表达式来保证多个语句按序执行。
2. 表达式求值:优先级和结合性是绕不过去的两道关
2.1 从高到低梳理一张优先级速查表
C语言运算符优先级共有15级,从高到低排列如下,你不需要背下全部,但至少要记住几个关键层级:
| 优先级 | 运算符 | 说明 | 结合性 |
|---|---|---|---|
| 1 | ()[]->. | 括号、下标、成员访问 | 左到右 |
| 2 | !~++---(负号)*(解引用)&(取地址)(类型)sizeof | 单目运算符 | 右到左 |
| 3 | */% | 乘除取余 | 左到右 |
| 4 | +- | 加减 | 左到右 |
| 5 | <<>> | 移位 | 左到右 |
| 6 | <<=>>= | 关系比较 | 左到右 |
| 7 | ==!= | 相等比较 | 左到右 |
| 8 | & | 按位与 | 左到右 |
| 9 | ^ | 按位异或 | 左到右 |
| 10 | | | 按位或 | 左到右 |
| 11 | && | 逻辑与 | 左到右 |
| 12 | || | 逻辑或 | 左到右 |
| 13 | ?: | 三目运算符 | 右到左 |
| 14 | =+=-=等所有赋值运算符 | 赋值 | 右到左 |
| 15 | , | 逗号运算符 | 左到右 |
这个表怎么记?我给你一个口诀思路:先单目,再算术,再移位,再比较,再位运算,再逻辑,再三目,最后赋值和逗号。位运算那一片比较冷门,但&低于==、高于&&这个顺序经常考。
2.2 结合性:从左往右读还是从右往左读
优先级解决的是"不同类型运算符"之间的先后顺序,结合性解决的是"相同优先级运算符"之间的顺序。
大多数运算符是左结合的,比如a - b - c等价于(a - b) - c。少数是右结合的,需要特别记住:
- 单目运算符:
-x++等价于-(x++),先算x++再取负。 - 三目运算符:
a ? b : c ? d : e等价于a ? b : (c ? d : e)。 - 赋值运算符:
a = b = c等价于a = (b = c)。 - sizeof和强制类型转换:
(int)3.5 * 2等价于((int)3.5) * 2,强制转换只作用于紧跟其后的那个表达式。
结合性里边最容易出问题的就是指针和自增自减的组合。看这一句:
int arr[] = {10, 20, 30}; int *p = arr; printf("%d\n", *p++);这里*p++根据优先级,++的优先级高于*(单目那一层里++排在*前面),所以它等价于*(p++)。整个表达式的值是p原来指向的那个值,也就是10,然后p自增指向下一个元素。这是"先取当前值,再后移指针"的标准写法,在遍历数组、实现栈等场景里非常常见。如果写的是(*p)++,含义就完全不同了:先取*p的值,然后让这个值自增,p本身不移动。
2.3 三个最容易把代码写炸的优先级陷阱
第一个是前面提到过的a < b < c。数学上这是"b大于a且b小于c",在C语言里却是(a < b) < c。假设a=1, b=2, c=3,a < b得到1,然后1 < 3成立,结果是1。这碰巧对了。但如果c=0呢?1 < 0为假,整个表达式变成0。更离谱的是当a >= b时,a < b得到0,0 < c(c为正数时)成立,结果又是1。这类bug不会报错,逻辑却完全错了,是排查起来最痛苦的那种问题。正确写法就是a < b && b < c。
第二个是位运算和逻辑运算、比较运算符混用时的优先级错乱。比如判断"x的bit3是否为1并且y的bit1是否为1",新手容易写成if (x & 0x08 && y & 0x02)。根据优先级表,&的优先级高于&&,这行代码实际是if ((x & 0x08) && (y & 0x02)),歪打正着是对的。但如果想表达的是"x的bit3为1,或者x的bit3为0时y的bit1为1"这种复杂条件,优先级就很容易出错。写位运算相关的条件,一律加括号,不要考验自己,也不要考验读代码的同事。
第三个是&和|跟算术运算符混用。例如判断一个整数n是不是2的幂,常见写法是n > 0 && (n & (n - 1)) == 0。这里的括号不能省,因为==的优先级高于&,没括号的话会变成n & ((n - 1) == 0),结果完全是另一回事。
我的建议是:复杂表达式一律加括号。这不是示弱,是职业素养。C语言的项目里从来没人笑话你的括号多,只会有人骂你不加括号。
3. 隐式转换与类型提升:表达式求值背后的"看不见的手"
3.1 整型提升:小类型会先被"撑大"
C语言有一个默认规则:所有比int小的整型(char、short等)在参与算术运算之前,会先被提升为int。这叫整型提升。
举个例子:
char a = 127; char b = 3; char c = a + b; // a和b先被提升为int,相加得130,再赋给char130超出了char能表示的范围(有符号char最大127),结果是未定义行为(实际上通常发生截断,变成-126)。这解释了为什么char类型做加法时要特别注意溢出。
另一个经典现象:
char ch = -1; if (ch == 0xFF) { ... } // 永远不成立因为ch在比较时被提升为int,-1提升后是0xFFFFFFFF,而不是0x000000FF。想和0xFF比较,要么把ch声明成unsigned char,要么把0xFF改成-1。
整型提升是很多诡异bug的源头,特别是当你在写字符串处理、缓冲区操作时,一个char不小心就被"撑大"成了int,符号位也跟着扩展。
3.2 寻常算术转换:有符号和无符号相遇时的规则
当两个不同类型的操作数参与运算时,C语言会执行"寻常算术转换"把两者统一到同一个类型。规则大致是:
- 如果其中一个操作数是
long double,另一个也转成long double。 - 否则,如果一个是
double,另一个转成double。 - 否则,如果一个是
float,另一个转成float。 - 否则,两个操作数都按整型提升处理,然后:
- 如果两者都是有符号或无符号的同一类型,保留较大类型;
- 如果无符号类型不小于有符号类型,有符号转成无符号;
- 如果有符号类型能容纳无符号类型的所有值,无符号转成有符号;
- 否则,两者都转成无符号版本。
最后三条不用死记,有一个实际后果要知道:有符号数和无符号数混用,可能出现让你怀疑人生的结果。
int a = -1; unsigned int b = 1; if (a < b) { printf("a < b\n"); // 不会执行 } else { printf("a >= b\n"); // 会走这里 }a被转换成无符号数,-1变成0xFFFFFFFF,也就是4294967295,当然不小于1。这个坑在写文件大小判断、内存长度比较、循环条件时特别常见。我踩过最离谱的一次是遍历数组时用int i和size_t类型的数组长度比较,i < len在i变成-1时反而成立,导致死循环。
3.3 一个实战例子:为什么求平均值要写成0.5
写一个计算圆面积的程序,新手最容易写错的地方是半径的平方和π的组合。
float radius = 5; float area = 3.14159 * radius * radius;这没问题。但如果是计算1/2个圆的面积,写成area = 3.14159 * radius * radius * 1/2;就有问题了。因为1和2都是整数,1/2先运算,结果是0(整数除法截断),最后面积变成0。很多人以为会发生类型提升,但1/2两个操作数都是int,没有别的类型可以提升,就只能整数相除。
正确写法有三种:
area = 3.14159 * radius * radius * 0.5; // 用浮点字面量 area = 3.14159 * radius * radius * (1.0 / 2); // 其中一个操作数用浮点 area = 3.14159 * radius * radius / 2; // 先乘除后除,除法之前已经是浮点第三种写法里边,前面的乘法算完已经是double类型了,再除以整型2,先把2转换成double再除,这没问题。核心逻辑就是:确保除法运算的操作数至少有一个是浮点类型。
3.4 浮点数比较的坑:不要直接写"=="
浮点数在计算机里是二进制近似表示,0.1 + 0.2并不等于0.3,这在几乎所有平台上都不成立。所以判断浮点数相等时,千万不要直接写if (a == b),尤其是经过一系列运算后的结果。
正确做法是比较差值是否在可接受误差范围内:
#include <math.h> double a = 0.1 + 0.2; double b = 0.3; if (fabs(a - b) < 1e-9) { printf("equal\n"); }1e-9这个阈值要根据你的实际精度要求调整,并且要考虑参与运算的数值量级。如果a和b都是几亿的量级,1e-9的误差阈值就不合适了,要改成相对误差或者动态阈值。
在有符号数和无符号数混用的场景之外,浮点比较是另一大类让人排错排到崩溃的问题。有些测试用例在整型里怎么跑怎么对,一换到浮点就随机失败,基本都是这里出了问题。
4. 常见问题与排查技巧实录:这些坑我替你们踩过了
4.1 未定义行为:i++ + ++i 为什么结果不确定
C语言里有些表达式的求值结果取决于编译器,这类行为叫未定义行为(Undefined Behavior)。最经典的例子:
int i = 1; int x = i++ + ++i;i++读取i的值、自增i;++i先自增i、再读取i。这两个操作之间缺少序列点(C11之后叫"排序"),编译器可以先算i++再算++i,也可以反过来,甚至可以同时算。不同的编译器、不同的优化等级,结果都不一样。同一段代码,在GCC的-O0和-O2下结果可能不同,在Clang下又不一样。
这类代码不仅是不可移植的,而且是完全错误的。真实的项目里没有人能接受一个在换编译器或者换优化选项后行为就变的程序。规避方法很简单:
- 一个表达式里不要对同一个变量做多次自增自减。
- 有副作用的子表达式之间,不要依赖求值顺序。
- 编译器开启
-Wall和-Werror,把这类潜在问题直接变成编译错误。
我在带实习生的时候,遇到过一段排序代码,同样的输入在不同机器上排出不同的结果,排查了整整两天,最后定位到就是while (arr[i++] < arr[i])这种写法。C语言给了你灵活的权利,但也要你承担对应的责任。
4.2 调试表达式逻辑问题的三板斧
第一板斧是拆解。把复杂的表达式拆成多个简单的语句,用临时变量保存每一步的值。
// 原始表达式,很难判断哪里出错 if (a & b || c == d && !e) { ... } // 拆解后,每一步都可以打印和检查 int r1 = c == d; int r2 = !e; int r3 = r1 && r2; int r4 = a & b; int result = r4 || r3; if (result) { ... }拆解之后你还可以直接在调试器里观察每个变量,定位出错环节。
第二板斧是printf调试。在每个关键运算符处打印中间结果,尤其是优先级容易搞混的地方。这个方法看着low,但对付运算符问题非常有效,因为你能直观地看到每一个子表达式的求值结果是否符合预期。
第三板斧是用括号强制明确意图。就算你已经把优先级背得很清楚,也要写清楚。代码首先是写给人看的,其次才是给机器执行的。以下两行逻辑完全相同,但第二行一眼就能看懂:
if (x & 0x08 && y & 0x02) // 不推荐,让人心慌 if ((x & 0x08) && (y & 0x02)) // 推荐,意图明确4.3 位运算实战:状态标志的打包与解包
位运算最实用的场景之一就是用整数管理多个布尔状态。假设一个系统里有4个开关,分别表示"启用""暂停""自动""锁定",传统做法定义4个单独的int,很浪费内存也没法一次性传递。用一个unsigned char就能装下全部4个状态:
#define FLAG_ENABLE (1 << 0) // 0b00000001 #define FLAG_PAUSE (1 << 1) // 0b00000010 #define FLAG_AUTO (1 << 2) // 0b00000100 #define FLAG_LOCK (1 << 3) // 0b00001000 unsigned char flags = 0; // 开启某个状态 flags |= FLAG_ENABLE; // 关闭某个状态 flags &= ~FLAG_PAUSE; // 判断某个状态是否开启 if (flags & FLAG_AUTO) { ... } // 切换某个状态(异或) flags ^= FLAG_LOCK; // 同时判断多个状态 if ((flags & (FLAG_ENABLE | FLAG_AUTO)) == (FLAG_ENABLE | FLAG_AUTO)) { ... }这里的|=、&=、^=都是复合赋值运算符,结合第1.2节讲的内容,其实每一步都是在跟位掩码做运算。这种写法在嵌入式系统里几乎无处不在:GPIO寄存器每一位控制一盏灯,串口状态寄存器每一位表示一个事件,中断控制器的每一位管理一个中断源。学会位运算,你读数据手册和寄存器描述时会轻松很多。
一个需要小心的点是:标志位宏最好定义成无符号类型相关的值。如果用的右移位数超出了类型位数,比如1 << 31赋给unsigned int(32位)没问题,但如果移位数超过了类型宽度,行为也是未定义的。定义掩码的时候保持移位次数在类型位数范围内。
4.4 GNU扩展的"表达式中的语句"要不要用
C语言的标准之外,GCC提供了一些扩展语法,比如语句表达式,允许在表达式里嵌一段语句:
#define MAX(a, b) ({ \ int _a = (a); \ int _b = (b); \ _a > _b ? _a : _b; \ })这个宏在Linux内核里很常见,它能规避传统宏#define MAX(a, b) ((a) > (b) ? (a) : (b))中参数被重复求值的毛病。但它不是标准C,换到MSVC或其他编译器下编不过。做跨平台开发时,这种写法要慎重。如果是写单片机或Linux驱动,目标编译器基本锁定GCC或Clang,用起来问题不大。
4.5 运算符优先级排查速查表
最后整理一个实用速查表,你在调试时可以直接对着看:
| 症状 | 可能的优先级问题 | 正确写法 |
|---|---|---|
| 区间判断结果恒真 | a < b < c被解析为(a < b) < c | a < b && b < c |
| 位掩码判断结果不对 | x & mask == 0被解析为x & (mask == 0) | (x & mask) == 0 |
| 指针移动但取值不对 | *p++和(*p)++混淆 | 看具体意图 |
| 赋值判断总是成立 | if (a = b)应为if (a == b) | if (a == b) |
| 逻辑判断结果诡异 | !x & 1被解析为(!x) & 1 | 加括号明确 |
| 逗号表达式值不对 | x = a, b, c赋值到a | x = (a, b, c) |
我个人在实际调试里的体会是,绝大多数运算符相关的bug根本不是"不会运算",而是写得让人看不清。你把表达式拆开、加上括号,问题的答案往往自己就跳出来了。C语言的灵活是留给有准备的人的,在这一章里多花时间搞清楚优先级、结合性、类型转换这三座大山,后面写指针、写链表、写任何复杂逻辑的时候,你都会感谢当初的自己。