如果你是刚开始学C语言,多半已经体会过:代码能看懂,但真让自己写一个带判断条件的表达式,结果总跟预期对不上。比如a = b << 2 + 1大家各执一词,有人说先算移位有人先说加法;再比如a++ + ++a这种经典题目,不同编译器出来的结果还不一样。这些东西的背后,全都指向同一个主题:操作符。这是《C语言初阶》系列里绕不开的第二道坎,也是后续指针、结构体嵌套表达式的基础。
这篇“操作符详解上”把 C 语言里最常用的算术、移位、位、赋值、单目、关系、逻辑七类操作符拆开讲,重点放在“为什么是这样”和“实际写代码时怎么避坑”,不是只丢给你一张优先级表。适合刚学完变量、输入输出、开始写分支和循环的初学者,也适合准备计算机二级或学校考试、想系统复习操作符优先级的同学。你只要能看懂int a = 1;这种最基本的声明,就能跟着往下走。
1. 操作符整体分类与优先级速览
1.1 操作符分类总览
很多人学操作符喜欢一个个硬背,我的建议反过来:先把 C 语言里有哪些操作符搞清楚,再去看细节。C 语言的操作符主要可以分成这几类:
- 算术操作符:
+-*/% - 移位操作符:
<<>> - 位操作符:
&|^~ - 赋值操作符:
=+=-=*=/=等 - 单目操作符:
!-+&*sizeof~++--(类型) - 关系操作符:
><>=<===!= - 逻辑操作符:
&&||! - 条件操作符:
?: - 逗号表达式:
, - 下标引用、函数调用、结构成员:
[]().->
这篇“上”先讲前七类,因为日常写表达式用得最多。条件操作符、逗号表达式、下标和结构成员这些放到“下”会更合适,它们经常和函数调用、指针、结构体语法混在一起,拆开讲不会把你绕晕。
1.2 优先级和结合性速记表
操作符的核心规则只有两个:优先级和结合性。优先级决定不同操作符谁先算,结合性决定相同优先级的操作符从左算还是从右算。
下面这张表是我整理的速记版,从高到低:
| 优先级 | 操作符 | 说明 |
|---|---|---|
| 高 | ()[]->.后缀++-- | 后缀表达式 |
!~+-*&sizeof(类型)前缀++-- | 单目操作符 | |
*/% | 乘法类 | |
+- | 加法类 | |
<<>> | 移位 | |
<<=>>= | 关系比较 | |
==!= | 相等比较 | |
& | 按位与 | |
^ | 按位异或 | |
| | 按位或 | |
&& | 逻辑与 | |
|| | 逻辑或 | |
?: | 条件操作符 | |
| 低 | =+=-=等 | 赋值操作符 |
结合性方面,绝大多数二元操作符是左结合,也就是从左往右算;但赋值操作符是从右往左结合,条件操作符也是右结合的。最典型的是a = b = c = 10,它的执行顺序是从右往左,把 10 依次赋给 c、b、a。
1.3 不确定就加括号
我见过太多人栽在1 << 2 + 1这种表达式上。根据优先级表,加减法的优先级高于移位,所以2 + 1会先计算,结果其实是1 << 3,等于 8。如果你想表达“1 左移 2 位,再加 1”,必须写成(1 << 2) + 1,结果才是 5。
所以我的核心建议第一条:写复杂表达式时,不要对着表硬推,直接加括号。加括号不是示弱,是把意图清清楚楚告诉编译器,也让后面维护代码的人少掉头发。
2. 算术操作符
2.1 五个基础算术操作符
算术操作符一共有五个:+-*/%。其中加、减、乘、除大家都熟悉,唯一要重点注意的是取模%。
取模%要求两个操作数都是整数,结果也是整数,它得到的是“整除后的余数”。比如7 % 2结果是 1,因为 7 除以 2 商 3 余 1。
下面这段代码我建议你亲手跑一遍:
#include <stdio.h> int main(void) { int a = 7; int b = 2; printf("%d\n", a + b); // 9 printf("%d\n", a - b); // 5 printf("%d\n", a * b); // 14 printf("%d\n", a / b); // 3 printf("%d\n", a % b); // 1 return 0; }注意看最后一次除法a / b,输出是 3 而不是 3.5,因为两个整数相除,C 语言直接截断小数部分。
2.2 整数除法、浮点数除法和取模的坑
整数除法是初学者第一个容易翻车的地方。5 / 2得到的是 2,不是 2.5。哪怕你潜意思觉得“数学上应该是 2.5”,C 语言只看操作数类型:两个整数相除,结果就是整数。
如果希望保留小数,至少要让一边变成浮点数。常见的写法有三种:
double r1 = 5.0 / 2; // 2.500000 double r2 = (double)5 / 2; // 2.500000 double r3 = 5 / 2; // 2.000000,先整除再转double,还是2第三种写法是错误的,它先做整数除法得到 2,再转成 double,结果依然是 2.0。这一点在笔试里特别爱考。
关于取模操作符还有一个细节:负数取模的结果符号问题。在主流编译器里,-7 % 2的结果是 -1,7 % -2的结果是 1。可以简单记忆为“余数的符号跟被除数一致”。不过早期 C 标准并没有完全统一这个行为,所以写代码时尽量不要依赖负数取模的符号,最好保持操作数是正数。
2.3 溢出问题与实用建议
溢出是算术操作里最容易被忽略的坑。有符号整数溢出在 C 语言中属于未定义行为,也就是说编译器的处理方式可以千奇百怪,你不能默认它会“溢出后回绕成负数”。例如INT_MAX + 1的结果不是固定的,甚至可能被优化成意外的东西。
无符号整数不一样,它做加减乘时是明确按 2 的 n 次方取模回绕的。也就是说无符号数永远不会溢出,它会“包回去”。
实际开发建议是:如果预料到数字可能比较大,优先考虑long long而不是贪图省事用int。尤其是做累加、求阶乘这类题,int满打满算也就二十多亿,很容易就爆了。
3. 移位操作符
3.1 左移和右移的二进制本质
移位操作符有两个:<<左移,>>右移。它们的操作对象是整数的二进制位。
左移x << k,就是把 x 的所有二进制位向左移动 k 位,右边空出来的位补 0,左边高位直接丢弃。每左移 1 位,数值上相当于乘以 2。所以3 << 2等于 12,因为 3 乘以 2 再乘以 2。
右移x >> k稍微复杂一点。对于无符号整数,右移时左边空出来的位补 0,叫逻辑右移。对于有符号整数,C 标准没有强制规定,大多数编译器采用算术右移:左边补符号位,也就是负数补 1,正数补 0。所以在写代码时,尽量不要对有符号负数做右移,因为结果依赖平台。
看个例子:
#include <stdio.h> int main(void) { unsigned char x = 1; printf("%u\n", x << 3); // 8 int y = -8; printf("%d\n", y >> 1); // 常见结果是 -4,但依赖编译器实现 return 0; }3.2 移位操作符的优先级陷阱
移位操作符的优先级比算术加减法低,这一点非常反直觉。很多人写1 << 2 + 1,心中想的是“把1左移2位再加1”,但实际结果是1 << 3。
原因很简单:+的优先级高于<<,所以先算2 + 1,再移位。
| 表达式 | 真实含义 | 计算结果 |
|---|---|---|
1 << 2 + 1 | 1 << (2 + 1) | 8 |
(1 << 2) + 1 | (1 << 2) + 1 | 5 |
你要是问我怎么避免,答案还是那句:移位操作符配合其他运算符时,一律加括号。
3.3 移位在实际项目中的常见用途
移位最常见的用途是配合位操作符做标志位处理。比如一个状态变量,你想用位 3 表示某个开关状态,写成flags |= 1 << 3;既可以置位,又不影响其他位。
另一个用处是乘以或除以 2 的幂。不过现在编译器优化做得很好,x * 8和x << 3生成的机器码几乎一样,所以初学阶段不必刻意用移位替代乘法。
还需要强调一个安全点:移位的位数如果大于等于该类型的位数,属于未定义行为。比如 32 位 int,写x << 32是不可靠的。这类问题在老代码里很常见,自己写的时候要规避。
4. 位操作符
4.1 按位与、按位或、按位异或、按位取反
位操作符处理的是每一个二进制位,它们包括:
&按位与:两个位都是 1 时结果才是 1|按位或:只要有一个是 1,结果就是 1^按位异或:两个位不同,结果是 1~按位取反:1 变 0,0 变 1
举个例子:
#include <stdio.h> int main(void) { unsigned char a = 0x0A; // 二进制 0000 1010 unsigned char b = 0x0C; // 二进制 0000 1100 printf("%X\n", a & b); // 0000 1000 -> 8 printf("%X\n", a | b); // 0000 1110 -> E printf("%X\n", a ^ b); // 0000 0110 -> 6 printf("%X\n", (unsigned char)~a); // 1111 0101 -> F5 return 0; }注意最后一个~a我做了(unsigned char)强制转换,因为按位取反之前会发生整型提升,直接打印可能会得到一个大整数,初学者经常被这个现象吓到。整型提升的细节在指针和类型转换专题里会展开,这里先记住“取反后记得看类型”。
4.2 位操作经典应用:掩码、置位、清位、翻转
位操作不只是考试题里的计算题,实际开发中用处极大。
判断奇偶性可以用n & 1:如果二进制最低位是 1,数字就是奇数。这比n % 2在某些底层场景下更直观,性能也差不多。
取低 4 位可以用掩码:n & 0x0F。这个操作在寄存器处理里很常见。
置位、清位、翻转是三个基本操作:
flags |= 1 << 3; // 把第3位置1 flags &= ~(1 << 3); // 把第3位清0 flags ^= 1 << 3; // 把第3位翻转很多人不理解为什么清位时要用~(1 << 3)而不是flags &= ~1 << 3。这里其实是“先算出只有第3位为1的掩码,再取反,得到除了第3位为1之外其他位都是1的掩码”,然后按位与,就能不动其他位置只清掉第3位。这个思路建议先在纸上画一遍二进制的位,必须自己推一次才能真懂。
还有个老玩法是异或交换两个变量:
a ^= b; b ^= a; a ^= b;这个技巧看似炫酷,但实际可读性差,性能也没有优势,我不建议在正式项目里用它。它的价值在于帮你理解异或的性质:同一个数异或两次会还原。
4.3 位操作符与逻辑操作符的对比陷阱
&和&&,|和||是两对非常容易混淆的操作符,但它们完全不同。
1 & 2 // 0,因为 01 & 10 = 00 1 && 2 // 1,因为两边都非0 3 | 4 // 7 3 || 4 // 1位操作符比较的是“每个对应的二进制位”,逻辑操作符比较的是“整个表达式的真假”。一个典型错误是有人写if (x & 1 == 0),以为判断 x 是偶数,实际上因为==优先级高于&,表达式变成x & (1 == 0),而1 == 0是 0,整个结果永远是 0。这里如果想让判断生效,必须写成(x & 1) == 0。这类优先级问题在后面的排查章节还会提到。
5. 赋值操作符
5.1 简单赋值与连续赋值
=是赋值操作符,不是数学上的等号。它的作用是把右边表达式的值赋给左边可修改的变量。
赋值操作符有一个关键特性:整个赋值表达式是有值的,值就是被赋的那个值。所以它可以嵌套在更大的表达式里。最经典的例子是:
while ((ch = getchar()) != EOF) { // ... }这里ch = getchar()的赋值结果会与EOF比较,但因为!=优先级高于=,所以必须加括号,否则编译都会报错。
连续赋值是赋值操作符右结合性的体现:
int a, b, c; a = b = c = 10;这个语句把 10 赋给 c,再把 c 的值赋给 b,再把 b 的值赋给 a,三个变量最终都是 10。它特别适合一段代码里把多个变量同时初始化。
5.2 复合赋值操作符
复合赋值操作符是“赋值和算术/位操作的组合写法”,包括+=-=*=/=%=<<=>>=&=|=^=等。
a += b等价于a = a + b,a <<= 2等价于a = a << 2。写成复合形式除了省几个字符,还有一个细微好处:左边的变量只写一次,语义更清晰,编译器也更容易优化。
注意事项:复合赋值表达式的优先级比较低,所以把它混合在复杂表达式里时,最好也用括号包住。比如x = y *= 2虽然合法,但读起来很费劲,不建议写。
5.3 赋值与等于的比较陷阱
初学阶段最常被坑的写法是:
if (x = 5) { // ... }这里不是判断 x 是否等于 5,而是把 5 赋给 x,再判断赋值表达式的值是否为真。由于 5 非 0,条件恒为真,程序行为完全偏离预期。
防止这种错误有个老派但有效的技巧:把常量写在左边。
if (5 == x)如果你手滑写成if (5 = x),编译器会直接报错,因为 5 不是一个可修改的左值。这个方法不能解决所有问题,但能拦截掉最常见的==写成=的隐患。另外现在主流编译器比如 GCC 加-Wall也会对if (x = 5)给出警告,编译时多看一眼警告,能省下很多调试时间。
6. 单目操作符
6.1 单目操作符全家桶
单目操作符的意思是“只需要一个操作数就能运算”的操作符,常见的有这么几个:
!逻辑非-负号+正号,基本没实际意义&取地址*解引用sizeof计算大小~按位取反++--前置自增自减(类型)强制类型转换
取地址和解引用在这里先混个脸熟,详细内容会在指针那篇展开。这一节我重点讲自增自减、sizeof 和强制类型转换。
6.2 自增自减的经典问题
i++和++i的区别,一句话说:i++先返回 i 的旧值,再把 i 加 1;++i先把 i 加 1,再返回 i 的新值。
直接看代码:
int i = 3; printf("%d\n", i++); // 输出 3,然后 i 变成 4 printf("%d\n", ++i); // i 先变成 5,输出 5这里有一个大坑:把一个变量在同一表达式里多次自增自减,结果是不确定的。比如i++ + i++属于未定义行为,不同编译器可能给出不同结果,这种代码没有任何讨论价值,笔试考到了直接指出“未定义行为”就行。实际项目里也不要写这种“炫技”代码,就算能跑,也是埋雷。
*p++是另一个高频考点,它等价于*(p++),也就是先取指针 p 当前指向的值,再让 p 向后移动。这和(*p)++完全不一样,后者是先让 p 指向的那个变量自增。这个区别到了指针部分会反复出现,现在就建立条件反射:后缀++的优先级高于解引用*。
6.3 sizeof 的细节
sizeof是操作符,不是函数。它可以用来求一个类型或变量所占的字节数,返回值类型是size_t,打印时用%zu。
它的写法有点特殊:
sizeof(int) // 类型要加括号 sizeof x // 变量可以不加括号初学者最容易记错的是第二点:对变量使用 sizeof 时,括号不是必须的。你写sizeof(x)也不会错,所以我通常建议统一都加括号,少踩规则坑。
另一个经典考点是 sizeof 不会真正执行括号里的表达式。看这里:
int a = 5; printf("%zu\n", sizeof(a++)); printf("%d\n", a); // 输出仍然是 5因为sizeof只关心类型大小,不会去执行“自增”的副作用。这个规律有一个特例是变长数组,初学阶段可以不用管,但要知道不能默认 sizeof 一定不求值。
有一个最容易被误解的点是:sizeof求出来的是“字节数”,不是“元素个数”。求数组元素个数要写成sizeof(arr) / sizeof(arr[0]),这在后续刷题时是高频代码。
6.4 强制类型转换
强制类型转换(类型)表达式也是一个单目操作符,它可以把表达式的结果转换成指定类型。
重点还是刚才说的整数除法:
int a = 5, b = 2; double x = (double)a / b; // 2.5 double y = (double)(a / b); // 2.0第一种是先把 a 转成 double,再除以 b,得到 2.5;第二种是先做整数除法得到 2,再转成 double。一字之差,结果完全不同,考试和面试都爱用这种对比。
7. 关系操作符与逻辑操作符
7.1 关系操作符返回值与连续比较问题
关系操作符包括><>=<===!=,它们的返回值只有两个:真返回 1,假返回 0。在 C 语言里,0 代表假,任何非 0 值都代表真,而关系操作符作为表达式时,只会返回 0 或 1。
关系操作符有个经典错误,叫“连续比较”。比如数学里写a < b < c,很多人以为 C 语言也是这样判断的。实际上 C 语言会从左往右先算a < b,结果是 0 或 1,再用这个 0 或 1 去和 c 比较。举个例子:
int a = 5, b = 3, c = 2; printf("%d\n", a > b > c);先算5 > 3,结果是 1,再算1 > 2,结果是 0。如果把 c 改成 0,1 > 0就是 1,整个表达式就变成真了,但数学上你根本不会拿 1 去比较。正确写法是a > b && b > c。
7.2 逻辑与、逻辑或、逻辑非
&&逻辑与:左右两个操作数都非 0,结果才是 1,否则为 0。||逻辑或:只要有一个操作数非 0,结果就是 1。!逻辑非:把真假反过来,非 0 变成 0,0 变成 1。
看代码:
int x = 3; int y = 0; printf("%d\n", x && y); // 0 printf("%d\n", x || y); // 1 printf("%d\n", !x); // 0 printf("%d\n", !y); // 1注意逻辑与和逻辑或的操作数会被隐含转换成“真假值”,它们本身不一定是 0 或 1,所以!100的结果是 0 而不是别的数。
7.3 短路求值
逻辑与和逻辑或都有“短路求值”的特性。看名字就能理解:一旦已经能确定结果,后面的表达式就不再求值。
A && B:如果 A 为 0,整个结果必然为 0,B 不会执行A || B:如果 A 非 0,整个结果必然为 1,B 不会执行
这个特性非常有用。最常见的场景是判空:
if (p != NULL && strlen(p) > 5) { // ... }如果 p 是空指针,第一个条件为假,后面strlen(p)根本不会执行,不会解引用空指针。反过来如果写if (strlen(p) > 5 && p != NULL)在某些场景下就可能崩溃。
除法也是一个例子:
if (b != 0 && a / b > 2)当 b 等于 0 时,第一个条件为假,后面的除法不会执行,避免了除零错误。
7.4 逻辑操作符与位操作符的对比
我一直建议初学者做一张对比表,把这两类操作符彻底区分开。它们在考试和项目里都容易混。
| 操作符 | 作用对象 | 结果 |
|---|---|---|
&|^ | 每个二进制位 | 按位结果 |
&&|| | 整体真或假 | 0 或 1 |
~ | 每个二进制位 | 按位取反 |
! | 整体真或假 | 0 或 1 |
举例:3 & 2的结果是 2,因为二进制011 & 010 = 010;但3 && 2的结果是 1,因为两个都非 0。~1的结果是一个很大的负数(具体看 int 位数),但!1的结果是 0。这两类操作符没有任何等价关系,忘了谁都会翻车。
8. 常见问题排查与避坑经验
8.1 优先级导致的常见错误场景
以下这几个场景我几乎每年都能见到,建议把它们当标准答案背下来:
| 代码 | 逻辑上的真实含义 | 纠正方式 |
|---|---|---|
x & 1 == 0 | x & (1 == 0),结果是 0 | (x & 1) == 0 |
1 << 2 + 1 | 1 << (2 + 1),结果是 8 | (1 << 2) + 1 |
*p++ | *(p++),先取值后移指针 | 明确意图再决定 |
a < b < c | 先算a < b得 0 或 1 | a < b && b < c |
if (x = 5) | 给 x 赋值,条件恒真 | if (x == 5)或if (5 == x) |
排查这些问题时,我喜欢先加括号验证一下,再把每个子表达式用 printf 打印出来,对比手工推算和实际输出的差别。不要靠猜,也不要直接抄网上的结论,自己跑一遍比什么都靠谱。
8.2 调试操作符表达式的方法
当你面对一个“看起来很奇怪”的表达式时,建议按三步排查:
先把表达式拆开,用中间变量保存子结果。比如int r = a << 2 + 1可以先写成int s = a << (2 + 1);,再去推值,这样思路清晰多了。
然后用编译器的警告选项辅助排查,GCC 和 Clang 都可以用-Wall -Wextra,很多赋值等于的误写、优先级可疑的表达式都能被提醒。
最后是主动证明:写一个最简 demo,用几个已知数值去验证表达式的求值顺序。操作符的坑大多数可以通过边界值测试暴露出来,重点测 0、1、负数、最大值、最小值这些特殊输入。
8.3 实操练习题与验证
把这一篇的知识浓缩成三道题,建议先自己算,再写出代码验证。
第一题:表达式求值。
int a = 1, b = 2, c = 3; int x = a + b * c; // 7 int y = (a + b) * c; // 9 int z = a << 2 + 1; // 8第二题:用位运算判断一个整数是否为奇数,并让第 3 位清零。
#include <stdio.h> int main(void) { int n = 15; if (n & 1) printf("奇数\n"); else printf("偶数\n"); n &= ~(1 << 3); printf("%d\n", n); // 15 -> 7 return 0; }第三题:猜一下输出,再跑一遍看看是否和你预期一致。
#include <stdio.h> int main(void) { int i = 3; printf("%d %d\n", i++, ++i); return 0; }这道题我要专门提醒:它属于未定义行为,不要在实战里这样写。如果在面试题里碰见,正确回答是“结果未定义,不讨论具体值”,而不是费劲去推测编译器会怎么算。
我个人的教学体会是,操作符这部分死记优先级表效果很有限,真正起作用的是“动手验证 + 形成条件反射”。文章里这些坑,最好每个都写一个小 demo 在本地编译器里跑一遍,跑完再回头记优先级表,你会觉得那张表根本不是背出来的,而是代码跑出来的规律。下一篇操作符详解下,我会把条件操作符、逗号表达式、下标引用、函数调用和结构成员操作符一起串讲,顺带补充一些和指针混在一起的优先级难题。等你把这上下两篇消化完,再看实际项目里的复杂表达式,就不会再心跳加速了。