C 语言有一个让初学者又爱又怕的特点:它的操作符(运算符)种类多得惊人。算术的、赋值的、关系的、逻辑的、位的、条件的、逗号的……随手一数就是四十多种。但恰恰是这些操作符,赋予了 C 语言无与伦比的表达能力和底层控制力——你能用一行代码表达"把某个整数的第 5 位翻转",这在很多高级语言里是不可想象的。
然而,操作符越灵活,代码就越容易写出"你知道它应该怎么算、但编译器不一定像你以为的那样算"的歧义代码。很多人写 C 语言多年,对原码反码补码的理解还停留在"背口诀"的阶段,对整型提升、算术转换更是一知半解。这篇文章就是要帮你从"能用"升级到"真懂"。
你已经掌握了基本的算术运算符(+ - * / %)、逻辑运算符(&& || !)、赋值和关系比较,也知道内存中一个字节有 8 个比特位——这些是理解位运算的前置基础。现在我们要在这张地基之上,把操作符的完整版图铺开。这篇文章会很长,因为操作符不是"背 40 个符号"的事,而是一整套"编译器怎么看你写的表达式"的底层规则。我们慢慢讲,每讲完一类操作符,我会留下思考题,你可以先自己推演一遍再看后面的答案区。
操作符的分类
先把所有操作符摊在桌面上,建立一个全局印象:
| 分类 | 操作符 | 说明 |
|---|---|---|
| 算术操作符 | + - * / % | 加减乘除取模 |
| 移位操作符 | << >> | 左移、右移 |
| 位操作符 | & ` | ^ ~` |
| 赋值操作符 | = += -= *= /= %= <<= >>= &= ` | = ^=` |
| 单目操作符 | ! ++ -- & * + - ~ sizeof (类型) | 只有一个操作数 |
| 关系操作符 | > >= < <= == != | 比较大小 |
| 逻辑操作符 | && ` | |
| 条件操作符 | ? : | 三目运算 |
| 逗号表达式 | , | 多个表达式顺序执行 |
| 下标引用 | [] | 数组元素访问 |
| 函数调用 | () | 函数调用 |
| 结构成员 | . -> | 结构体成员访问 |
这张表本身不难,但请留意两个容易被忽略的"隐藏角色":
第一,&、*、()、[]、.、-> 全都是操作符,不是"语法符号"。这意味着它们也参与优先级和结合性的游戏——*p.f 到底是先取指针还是先访问成员?答案是先访问成员(. 的优先级比 * 高),这个坑我们后面会专门讲。
第二,表中的"单目操作符"里有两个和指针绑定的角色——取地址 & 和解引用 *。它们属于"压轴操作符",会在指针章节正式登场,这一讲先把它们的存在记在账上。
其中移位操作符和位操作符涉及二进制运算,是本文的重点。要理解它们,我们得先把二进制的基础知识补上。
二进制和进制转换
10 进制是我们日常使用的计数方式——满 10 进 1,每一位由 0~9 的数字组成。2 进制则满 2 进 1,每一位只有 0 和 1。同一个数值,在不同进制下有不同表示:
15 的 2 进制:1111
15 的 8 进制:17 (C 语言中用 017 表示,前面加 0)
15 的 10 进制:15
15 的 16 进制:F (C 语言中用 0xF 表示,前面加 0x)
二进制转十进制:按权重展开。十进制 123 为什么是"一百二十三"?因为每一位有权重——10^2、10^1、10^0。二进制同理,不过每位权重是 2 的幂:
2^3 2^2 2^1 2^0
1 1 0 1
→ 1×8 + 1×4 + 0×2 + 1×1 = 13
所以二进制 1101 等于十进制的 13。
十进制转二进制:用短除法——不断除以 2,记录余数,直到商为 0,然后把余数从下往上读:
13 ÷ 2 = 6 ... 余 1 ↑
6 ÷ 2 = 3 ... 余 0 │
3 ÷ 2 = 1 ... 余 1 │
1 ÷ 2 = 0 ... 余 1 │
└→ 1101(从下往上读)
下面这段代码可以打印任意整数的二进制表示,帮你直观感受:
#include <stdio.h>
// 打印整数的二进制表示(仅用于理解,不需要背诵)
void PrintBinary(int n)
{
int i;
// 从高位到低位逐位打印
for (i = 31; i >= 0; i--)
{
printf("%d", (n >> i) & 1); // 右移 i 位后取最低位
if (i % 4 == 0) printf(" "); // 每 4 位加空格,方便阅读
}
printf("\n");
}
int main()
{
int a = 13;
printf("十进制 %d 的二进制是:", a);
PrintBinary(a); // 00000000000000000000000000001101
return 0;
}这里有一处可以停下来想一想的地方:(n >> i) & 1 为什么能取出第 i 位?n >> i 把第 i 位移到了最低位,再和 1 按位与,就把其他位全部清零、只留下最低位。这是后面所有位运算技巧的基本套路——移位定位 + 掩码取值。请把这两个动作记在心上。
二进制转八进制:从右往左每 3 位一组。01101011 → 分组为 01 101 011 → 八进制 0153。为什么是 3 位一组?因为 2³ = 8,3 个二进制位恰好能表达 0~7,正好对应一位八进制数。
二进制转十六进制:从右往左每 4 位一组。01101011 → 分组为 0110 1011 → 十六进制 0x6B。同理,2⁴ = 16,4 个二进制位恰好对应一位十六进制数(09 加上 AF)。
#include <stdio.h>
int main()
{
int n = 0b01101011; // C 语言中 0b 前缀表示二进制(GCC/Clang 扩展,
// 自 C23 起成为标准写法)
// 标准写法用十六进制:
// int n = 0x6B;
printf("八进制:%o\n", n); // 输出 153(八进制格式说明符 %o)
printf("十进制:%d\n", n); // 输出 107
printf("十六进制:%x\n", n); // 输出 6b
return 0;
}说明:
0b前缀在 C23 之前是 GCC/Clang 的编译器扩展,MSVC 的老版本不支持(新版已支持)。想要写"跨编译器可移植"的二进制字面量,标准做法是用十六进制,因为每 4 位二进制恰好对应 1 位十六进制,转起来毫无压力。
思考题(进制)
0xFF等于十进制多少?0x100呢?- 二进制
10101010转十六进制是多少?(提示:从右往左每 4 位分组) - 为什么
0xFFFFFFFF在 32 位int里表示-1?(先想,看完下一节你就明白了)
原码、反码、补码
这是理解移位操作和位运算的核心前提。很多人只知道"补码 = 反码 + 1",但不理解为什么要这样设计。让我们从头梳理。
整数的二进制表示有三种:原码、反码、补码。对于有符号整数,最高位(最左边)是符号位——0 表示正,1 表示负。
以 -5 为例(假设用 8 位表示,即 char 类型):
原码:10000101 ← 最高位符号位为 1(负),数值位为 0000101(=5)
反码:11111010 ← 符号位不变,其余位全部取反(0→1,1→0)
补码:11111011 ← 反码加 1
关键规则:
- 正整数的原码、反码、补码完全相同。比如
+5=00000101(三种编码一致); - 负整数三种编码各不相同,遵循上述转换规则。
那么——为什么计算机用补码存储?答案简单而深刻:为了让加法和减法能用同一套电路处理。
CPU 内部只有加法器,没有减法器。假设我们要算 5 - 3,CPU 的实际做法是 5 + (-3)。如果采用原码:
00000101 (5 的原码)
+ 10000011 (-3 的原码)
= 10001000 ← 结果是 -8?完全错误!
但如果用补码:
00000101 (5 的补码)
+ 11111101 (-3 的补码)
= 00000010 ← 溢出的高位丢弃,结果 = 2,正确!
补码让符号位也能参与正常运算,不需要额外的硬件判断正负号。
而且补码和原码的互转方法完全一样——取反加 1:
-5 的补码:11111011
取反: 00000100
加1: 00000101 → 5(原码的数值部分)
这意味着计算机硬件只需要一套"取反+加1"的电路,就能完成双向转换——极其优雅的设计。
再补一个只属于补码的特点:补码的表示范围关于 0 不对称。8 位有符号数的范围是 -128 ~ 127,10000000 这个补码对应的不是"负 0",而是 -128。所以不存在"负零"这种冗余表示,这让补码比原码多表示一个数。这也是为什么 int 的最小值 INT_MIN 取绝对值还是负数——-INT_MIN 会溢出。
#include <stdio.h>
int main()
{
int a = 5;
int b = -5;
// 正数:原反补相同
printf(" 5 的十六进制:%#010x\n", a); // 0x00000005
// 负数:存储的是补码
printf("-5 的十六进制:%#010x\n", b); // 0xfffffffb
// fffffffb = 1111...11111011(32位补码)
// 验证:补码取反加1 = 原码数值部分
printf("-5 原码数值部分:%d\n", ~b + 1); // 输出 5
return 0;
}思考题(原码反码补码)
~0等于几?如果 0 的补码是全 0,取反后是全 1,全 1 的补码就是 -1,所以~0 == -1。- 写出 -1、-128(8 位)的补码。
- 为什么
~5 + 1 == -5?(取反加 1 是取负数的通用手段)
移位操作符
移位操作符直接操作二进制位,操作数必须是整数。
左移 <<
规则:左边抛弃,右边补 0。
#include <stdio.h>
int main()
{
int num = 10; // 二进制:...00001010
int n = num << 1; // 左移1位:...00010100 = 20
printf("num = %d\n", num); // 10(原变量不变)
printf("n = %d\n", n); // 20
// 更直观的演示
int a = 3; // 二进制:...00000011
printf("3 << 1 = %d\n", a << 1); // 6 (...00000110)
printf("3 << 2 = %d\n", a << 2); // 12 (...00001100)
printf("3 << 3 = %d\n", a << 3); // 24 (...00011000)
// 规律:左移 n 位 = 乘以 2^n
return 0;
}左移一位等价于乘以 2——在需要高性能的场景下,n << 1 比 n * 2 更快(虽然现代编译器会自动优化,但理解这个等价关系很重要)。
但这里必须画一条红线,讲清楚 C 标准是怎么规定的:
- 对无符号整数左移:结果严格等于
E1 × 2^E2对2^N取模(N 是位宽),溢出是定义良好的(多余的 1 直接丢掉)。 - 对有符号非负整数左移:C99 起,如果
E1 × 2^E2能被该类型表示,结果就是那个值;如果发生溢出(符号位被推成 1),行为是未定义的。
int x = 1;
int y = x << 31; // ❌ 未定义行为!符号位被推上去,标准不保证任何结果所以"左移 n 位 = 乘以 2^n"这句话,严谨的说法是"在不溢出的前提下成立"。写代码时想用左移做乘法,先问自己:这个数会不会大到溢出的程度?
右移 >>
右移比左移复杂,因为存在两种方式:
- 算术右移(大多数编译器使用):左边用符号位填充,右边丢弃;
- 逻辑右移:左边用 0 填充,右边丢弃。
#include <stdio.h>
int main()
{
int num = 10; // 二进制:...00001010
int n = num >> 1; // 右移1位
printf("num = %d\n", num); // 10
printf("n = %d\n", n); // 5 (...00000101)
// 负数右移的行为取决于具体实现
int neg = -10; // 补码:...11110110
printf("-10 >> 1 = %d\n", neg >> 1); // 在 VS 上是 -5(算术右移)
// 算术右移:左边补符号位 1,右边丢弃 → ...11111011 = -5
return 0;
}关于右移,C 标准的规定是:
- 对无符号整数右移:一定是逻辑右移,左边补 0,结果是定义良好的。
- 对有符号非负整数右移:算术右移(左边补 0,效果和逻辑右移一样)。
- 对有符号负数右移:实现定义(implementation-defined)——绝大多数主流编译器(GCC、Clang、MSVC)都做算术右移(左边补符号位),但标准没有强制要求。所以依赖"负数右移补 1"的代码,理论上不可移植,实践中基本安全。
负数的算术右移还有个有趣的性质:-10 >> 1 == -5,看起来"除以 2"了。但注意算术右移是向下取整的除法(向 -∞ 方向),而 C 的 / 是向 0 截断——所以 -3 >> 1 == -2(向下取整),而 -3 / 2 == -1(向 0 截断)。两者在负数时不相等!这一点后面讲除法时还会提到。
重要警告:不要移动负数位!
int num = 10;
num >> -1; // 未定义行为!编译可能不报错,但运行时行为完全不可预测C 语言标准明确指出,移位操作符的右操作数如果是负数或者大于等于左操作数的位宽,行为是未定义的:
int x = 1;
x << 32; // ❌ 未定义行为!右操作数 32 ≥ 位宽 32
x >> 40; // ❌ 未定义行为!思考题(移位)
1 << 4等于几?0x10又是几?两者有什么关系?- 用移位实现:判断一个整数是否是 2 的幂。(提示:2 的幂的二进制只有一个 1,比如 8 = 1000)
-1 >> 1在算术右移下是多少?为什么?
位操作符:&、|、^、~
位操作符让你直接操控每一个比特位。与逻辑操作符(&& || !)不同的是,位操作符是逐位运算的,结果仍是一个数;逻辑操作符看的是"真/假",结果只有 0 或 1。这一字之差,导致了两类操作符永远不能互换。
按位与 &——全 1 才为 1:
#include <stdio.h>
int main()
{
int a = 6; // 二进制:...00000110
int b = 3; // 二进制:...00000011
// & ...00000010 = 2
printf("6 & 3 = %d\n", a & b); // 2
// 常见用途:判断奇偶
if (a & 1) // 和 1 做按位与,检查最低位
printf("%d 是奇数(最低位为 1)\n", a);
else
printf("%d 是偶数(最低位为 0)\n", a); // 6 是偶数
return 0;
}按位或 |——有 1 就为 1:6 | 3 = 7(...0110 | ...0011 = ...0111)。
按位异或 ^——相同为 0,不同为 1:6 ^ 3 = 5(...0110 ^ ...0011 = ...0101)。
异或有一个神奇的性质:a ^ a = 0,a ^ 0 = a,并且异或满足交换律和结合律。利用这些性质可以实现不借助第三个变量的交换:
#include <stdio.h>
int main()
{
int a = 10;
int b = 20;
printf("交换前:a = %d, b = %d\n", a, b);
// 不创建临时变量,交换 a 和 b(异或法)
a = a ^ b; // a 变成 a^b
b = a ^ b; // b 变成 (a^b)^b = a^0 = a
a = a ^ b; // a 变成 (a^b)^a = b^0 = b
printf("交换后:a = %d, b = %d\n", a, b);
return 0;
}不过说实话——这种写法的可读性很差,而且只在整数上有效。实际项目中建议还是用临时变量(编译器会优化掉的),但作为面试题和对异或性质的理解,这个技巧值得知道。
异或还有一个更实用的场景——在一堆成对出现的数中找唯一落单的数。比如数组里所有数都出现两次,只有一个数出现一次,怎么快速找出来?把所有数异或一遍,成对的全部抵消成 0,剩下的就是那个落单者:
#include <stdio.h>
int main()
{
int arr[] = {5, 7, 5, 9, 7, 3, 9};
int i;
int result = 0;
// 全部异或:成对的抵消,剩下的就是落单的
for (i = 0; i < sizeof(arr) / sizeof(arr[0]); i++)
result ^= arr[i];
printf("落单的数 = %d\n", result); // 输出 3
return 0;
}这个技巧的时间复杂度是 O(n)、额外空间 O(1),比"排序后找"或"哈希计数"都干净利落,面试高频。
按位取反 ~——0 变 1,1 变 0:~0 = -1(因为补码全 1 就是 -1)。~ 是单目操作符,只有一个操作数。
位运算和逻辑运算的区别值得用一张表钉死:
| 场景 | 位运算 | 逻辑运算 |
|---|---|---|
| 操作数 | 必须是整数 | 任意"真/假"表达式 |
| 运算方式 | 逐位运算 | 整体看真值 |
| 结果 | 一个整数 | 只有 0 或 1 |
| 短路 | 无 | 有(后面会讲) |
| 例子 | 6 & 3 = 2 | 6 && 3 = 1 |
6 && 3:6 非 0 为真,3 非 0 为真,真 && 真 = 真 = 1。而 6 & 3 = 2。初学者最容易犯的错误就是把 && 写成 &——比如 if (x > 0 & x < 10),语法上完全合法,但语义是"逐位与",结果可能出乎意料。检查条件一律用 &&。
现在来看几个位运算的实战练习。
练习一:统计二进制中 1 的个数。有三种方法,从简单到最优:
方法一(有缺陷——负数处理不对):
#include <stdio.h>
int main()
{
int num = 10; // 二进制:1010
int count = 0;
while (num)
{
if (num % 2 == 1) // num%2 等价于取最低位
count++;
num = num / 2; // num/2 等价于右移一位
}
printf("二进制中1的个数 = %d\n", count); // 2
return 0;
}方法一为什么对负数有缺陷?因为 C99 起整数除法是向 0 截断:-1 / 2 = 0,-1 % 2 = -1。所以 num = -1 时,-1 % 2 == -1,永远不会等于 1,count 漏计;而 num / 2 一路向 0 收缩,循环很快终止。结论:用取模/除法处理负数逐位提取,结果不可靠。
方法二(稳定但必须循环 32 次):
#include <stdio.h>
int main()
{
int num = -1; // -1 的补码是 32 个 1
int i = 0;
int count = 0;
for (i = 0; i < 32; i++)
{
if (num & (1 << i)) // 左移 1 作为掩码,检查第 i 位是否为 1
count++;
}
printf("二进制中1的个数 = %d\n", count); // 32
return 0;
}方法二的思路是"固定掩码扫描":1 << i 生成第 i 位为 1 的掩码,和 num 按位与,非 0 说明第 i 位是 1。它对任何整数(包括负数)都正确,缺点是固定要跑满 32 次。
方法三(最优解):利用 n & (n-1) 每次消去最低位的一个 1:
#include <stdio.h>
int main()
{
int num = -1;
int count = 0;
while (num) // num 不为 0 则还有 1
{
count++;
num = num & (num - 1); // 每次消去最低位的一个 1
// 例:num=1100(12), num-1=1011(11), 1100&1011=1000 消去了最低位的 1
}
printf("二进制中1的个数 = %d\n", count); // 32
return 0;
}n & (n-1) 为什么能消去最低位的 1?以 num = 12 = 1100 为例:num - 1 = 1011——减 1 会把最低位的那个 1 变成 0,并把它右边所有的 0 变成 1。两者按位与后,最低位的 1 及它右边全被清零,其余高位不动。于是每执行一次就消灭一个 1。这个方法的时间复杂度是 O(k),k 是 1 的个数,远优于固定循环 32 次。在很多算法题中都极其有用——比如判断一个数是不是 2 的幂:(n & (n-1)) == 0(2 的幂只有一个 1,消掉就成 0 了)。
练习二:将二进制特定位清零或置位。将 13(二进制 ...00001101)的第 5 位设为 1,再改回 0。这里说的"第 5 位"指从右边数第 5 个二进制位——注意位编号习惯:如果从 0 开始编号,它就是位下标 4,所以掩码用 1 << 4:
#include <stdio.h>
int main()
{
int a = 13; // 二进制:00000000000000000000000000001101
// 将第 5 位(位下标 4)置为 1:用掩码 (1<<4) = ...00010000
a = a | (1 << 4); // 按位或后第 5 位变 1
printf("第 5 位置 1 后:a = %d\n", a); // 29 (...00011101)
// 将第 5 位清为 0
a = a & ~(1 << 4); // ~(1<<4) = ...11101111,按位与后第 5 位变 0
printf("第 5 位清 0 后:a = %d\n", a); // 13 (...00001101)
// 将第 5 位翻转
a = a ^ (1 << 4); // 异或:第 5 位 1↔0 翻转
printf("第 5 位翻 1 次后:a = %d\n", a); // 29
return 0;
}(常见笔误:把"第 5 位"误写成 1 << 5。1 << 5 对应的是位下标 5,也就是从右边数第 6 位,会把 13 变成 45。写之前先在纸上标好位下标,从 0 开始数,就不会弄错。)
这三条操作对应三种场景,值得背下来:
- 置 1:
x |= (1 << k)(掩码按位或) - 清 0:
x &= ~(1 << k)(掩码取反后按位与) - 翻转:
x ^= (1 << k)(掩码异或)
这种"设置掩码 → 位运算"的模式,是嵌入式开发中操控寄存器(比如 GPIO 引脚、中断使能位)的基本功。
练习三:从 32 位颜色值中提取字节。假设一个像素颜色打包在 int 里,格式是 0xRRGGBB(红、绿、蓝各占 8 位),怎么把三个分量分别取出来?
#include <stdio.h>
int main()
{
unsigned int color = 0x336699; // R=0x33, G=0x66, B=0x99
// 提取蓝色:低 8 位,直接和 0xFF 按位与
unsigned int blue = color & 0xFF;
// 提取绿色:先右移 8 位把绿色挪到低位,再与 0xFF
unsigned int green = (color >> 8) & 0xFF;
// 提取红色:右移 16 位
unsigned int red = (color >> 16) & 0xFF;
printf("R=0x%02X G=0x%02X B=0x%02X\n", red, green, blue);
// 输出:R=0x33 G=0x66 B=0x99
return 0;
}"移位 + 与 0xFF"就是"提取某个字节"的标准动作,图像处理里天天见。
思考题(位运算)
x & (x-1)除了统计 1 的个数,还能干什么?(提示:判断 2 的幂)- 如何不用
%判断奇偶?(提示:x & 1) - 两个整数交换,除了异或法和临时变量法,还有加减法:
a=a+b; b=a-b; a=a-b;。它有什么隐患?(提示:溢出——但更重要的是想想为什么异或法没有这个隐患,其实异或法也没有!因为异或不产生进位) - 一个数自己异或自己是几?一个数异或 0 呢?
单目操作符
只有一个操作数的操作符叫单目操作符,C 语言里一共这些:
! ++ -- & * + - ~ sizeof (类型)
逐个认识一下:
!:逻辑取反(!0为 1,!非0为 0);++/--:自增自减,有前置(++i)和后置(i++)之分;+/-:正负号(一元运算符),注意和加减法的区别;~:按位取反,刚学过;sizeof:求类型或变量的字节大小(sizeof(int)是 4);(类型):强制类型转换,比如(double)3把整数 3 转成 3.0;&和*:取地址和解引用操作符,它们和指针绑定在一起,这一讲先不做介绍,等你学到指针那一章,这两个"压轴操作符"自然会登场。
自增自减的完整语义
++ 和 -- 是最容易写错的操作符,因为它们的"返回值"和"副作用"是分离的:
- 前置
++i:先把i加 1,再返回加 1 后的新值。表达式++i的值就是i+1。 - 后置
i++:先返回加 1 前的旧值,副作用(把i加 1)在之后某个时刻生效。表达式i++的值是旧的i。
#include <stdio.h>
int main()
{
int i = 1;
// 前置:先加后取
printf("++i = %d\n", ++i); // 输出 2(i 先变 2,表达式的值也是 2)
printf("i = %d\n", i); // 输出 2
// 后置:先取后加
int j = 1;
printf("j++ = %d\n", j++); // 输出 1(表达式的值是旧的 1)
printf("j = %d\n", j); // 输出 2(副作用已生效)
return 0;
}i++ 和 ++i 单独成句(i++; 或 ++i;)时没有区别;区别只在被当作子表达式使用时才显现。编译器通常把 ++i 翻译成"加 1 返回",把 i++ 翻译成"保存旧值、加 1、返回旧值"——所以独立使用时优先写 ++i(理论上少一次寄存器保存,虽然现代编译器会优化,但养成习惯没坏处)。
sizeof 的深层真相
sizeof 值得单独细说,因为笔试面试几乎必考,而且它有一堆反直觉的特性。
特性一:sizeof 是操作符,不是函数。 证据就是它可以省略括号:sizeof a 合法,而 sizeof(int) 必须带括号(类型必须带括号)。唯一例外是操作数是类型名时括号不能省。
特性二:sizeof 在编译期求值,且不计算操作数表达式。 这是最反直觉的一条。看代码:
#include <stdio.h>
int main()
{
int i = 0;
// sizeof 只关心类型,不关心值
int n1 = sizeof(i++); // i++ 会被执行吗?
printf("i = %d, n1 = %zu\n", i, n1); // i = 0!(i++ 根本没执行)
// 对比:普通表达式会执行
int n2 = i++; // 这次 i++ 执行了
printf("i = %d, n2 = %d\n", i, n2); // i = 1
return 0;
}sizeof(i++) 不会让 i 增加——编译器只需要知道 i++ 的类型是 int,返回 4,整个表达式根本不会求值。同理 sizeof(arr[100]) 不会访问越界内存,sizeof(fun()) 不会真的调用函数。这带来两个推论:
sizeof的"不计算"特性意味着它永远不会有运行时副作用,可以放心用;- 反过来,
sizeof也算不出任何"运行时才知道"的东西。
特性三:VLA(变长数组)例外。 C99 允许 int n; scanf("%d", &n); int a[n]; 这样的变长数组,此时 sizeof(a) 必须在运行期才能得出(因为 n 是运行时才知道的)。这是 sizeof 唯一的运行期场景。
特性四:sizeof 的结果类型是 size_t,一个无符号整数类型,通常就是 unsigned long 或 unsigned long long。所以打印用 %zu。千万别写成 %d——那是类型不匹配(未定义行为),在某些平台上大数组会打出负数。
特性五:sizeof(表达式) 不区分"变量"和"类型"的写法陷阱。 sizeof(int *) 和 sizeof(int*) 是一回事,但 sizeof(int*) 是"指向 int 的指针的大小"(4 或 8),千万别和 sizeof(int)(4)搞混。字符串相关的 sizeof("abc") 是 4(含 \0),这个后面讲指针时还会再考。
强制类型转换
(类型)表达式 可以把表达式的结果转成指定类型:
#include <stdio.h>
int main()
{
int a = 10;
int b = 3;
// 整数除法:两个 int 相除,结果是 int,小数部分被丢掉
printf("%d\n", a / b); // 3
// 强转一个操作数后,变成浮点除法
printf("%.2f\n", (double)a / b); // 3.33
// 也可以这样写(推荐,语义更清楚)
printf("%.2f\n", a / (double)b); // 3.33
return 0;
}注意强转的优先级:(double)a / b 等价于 ((double)a) / b,因为强转比除法优先级高;但 (double)(a / b) 是先除后转——结果是 3.0,不是 3.33!强转的括号覆盖范围要盯紧。
思考题(单目操作符)
sizeof和strlen有什么区别?(提示:一个是操作符一个是函数,一个编译期一个运行期——答案在后续指针篇详细展开,先自己想想)- 下面代码输出什么?为什么?
(提示:这是未定义行为,不要试图"猜"它的输出——详见本文表达式求值一节)int i = 5; printf("%d\n", i++ + ++i); (char)300等于几?(提示:char 是 8 位,300 截断取低 8 位,300 = 256 + 44,所以是 44)
赋值操作符与复合赋值
= 是最常用也最容易被坑的操作符。先记三件事:
第一,= 是赋值,== 是判断相等。 把 == 写成 = 是 C 语言排名第一的笔误。更要命的是这个错误通常能编译通过:
int x = 5;
if (x = 10) // ❌ 不是判断,是赋值!x 变成 10,条件为真
printf("x 是 10\n");x = 10 作为条件,其值是 10,非 0 即真——于是 if 恒成立,x 被悄悄改成了 10。防手滑的写法是把常量写在左边:if (10 == x),这样万一写成 if (10 = x),编译器立刻报错(不能给常量赋值)。不过这种写法可读性差,很多团队禁止;更好的习惯是开编译警告(GCC 的 -Wall 会对 if (x = 10) 发出 "suggest parentheses around assignment" 提示)。
第二,赋值表达式本身有值,而且右结合。 a = b = c; 先从右往左:b = c 的值是 c,然后 a = (b = c)。所以赋值链可以连续写。利用"赋值有值"还能写出紧凑代码:
int a;
int b;
// 把读到的数同时赋给 a 和 b(下面的写法合法但风格见仁见智)
// b = (a = scanf("%d", &a));第三,复合赋值是"读-改-写"的缩写。 a += 5 等价于 a = a + 5;a <<= 2 等价于 a = a << 2。所有复合赋值号都是同一个模式。注意 a op= b 中 a 只求值一次(这是标准规定),所以 arr[i++] += 5 里 i++ 只会执行一次——这个细节平时用不到,但要知道它的存在。
还有一个初学者常踩的坑:= 的优先级低于关系操作符。a = b < c 等价于 a = (b < c)——先比较,再把 0/1 赋给 a。如果你写 a = b < c; 想表达的是别的意思,先想清楚再写。
思考题(赋值)
int x = 3; x += x *= 2;执行后 x 是多少?(提示:x *= 2把 x 变成 6,表达式值为 6;然后x += 6,x = 12。但注意:这条表达式对 x 有多次修改,属于未定义行为范畴——不要在生产代码里写这种)- 为什么
while (scanf("%d", &n) == 1)能写成while (scanf(...) == 1)而不能省略== 1?(提示:scanf 的返回值是成功读入的项数)
关系操作符与浮点比较
> >= < <= == != 的结果是 int(0 或 1)。关系操作符的优先级比算术低、比赋值高,所以 a + b > c * d 等价于 (a + b) > (c * d),不需要加括号。
关系操作符最大的坑是链式比较。数学上写 a < b < c 表示"b 在 a 和 c 之间",但 C 语言里:
int a = 1, b = 2, c = 3;
if (a < b < c) // 你以为:1<2<3 成立
// 实际:(a < b) < c = (1) < 3 = 1,成立——这次侥幸对了看一个反例:
int a = 3, b = 2, c = 1;
if (a < b < c) // 你以为:3<2<1 不成立
// 实际:(a < b) = 0(假),0 < 1 = 1(真)!a < b < c 被解析成 (a < b) < c:先算 a < b 得 0 或 1,再拿这个 0/1 去和 c 比。结果几乎永远和你想要的相反。C 语言没有"链式比较"语法,正确的写法是 a < b && b < c。
浮点数的 == 也比较容易踩坑:0.1 + 0.2 == 0.3 在 C 里是假!因为 0.1 和 0.2 在二进制浮点里都只是近似值,两者之和约等于 0.30000000000000004。浮点比较的正确姿势是看差值是否小于某个容差:
#include <stdio.h>
#include <math.h>
int main()
{
double a = 0.1 + 0.2;
double b = 0.3;
if (fabs(a - b) < 1e-9) // 差值足够小就认为相等
printf("a 约等于 b\n");
else
printf("a != b\n");
return 0;
}思考题(关系操作符)
- 为什么
'A' < 'a'成立?(提示:ASCII 码 A=65,a=97) if (a < b == c)合法吗?它表示什么?(合法,等价于(a < b) == c——但写这种代码的人建议拉去和3[arr]一起面壁)
逻辑操作符与短路求值
&&(逻辑与)、||(逻辑或)、!(逻辑非)在操作符分类表里出现过,但它们的"短路求值"特性值得单独占一节——因为它是 C 语言笔试的高频考点,也是日常写代码最容易产生隐藏 Bug 的地方。
短路求值(Short-Circuit Evaluation):A && B 中,如果 A 为假,整个表达式必为假,B 根本不会执行;A || B 中,如果 A 为真,整个表达式必为真,B 根本不会执行。
#include <stdio.h>
int main()
{
int a = 0;
int b = 10;
// a 为 0(假),&& 右边不执行
int r1 = a && (b = 20);
printf("r1 = %d, b = %d\n", r1, b); // r1 = 0, b = 10(b 没被改!)
// a 为 0(假),|| 右边执行
int r2 = a || (b = 30);
printf("r2 = %d, b = %d\n", r2, b); // r2 = 1, b = 30(b 被改了)
return 0;
}短路求值最经典的工程用途是保护性判断——把"可能出错的访问"放在短路的右边:
// 链表为空时不能解引用 p->next,先判断 p 非空
if (p != NULL && p->next != NULL)
{
// 安全访问
}
// 除法前先判断除数非 0
if (n != 0 && sum / n > threshold)
{
// 安全计算
}p != NULL && p->next != NULL:当 p 是 NULL 时,第一个条件为假,第二个条件(会解引用 NULL 的那个)根本不会执行——这就是短路求值保的命。
另一个考点是**&& 和 || 的结果一定是 0 或 1**,不是"非 0 原值"。这和无符号/有符号位运算的结果形成鲜明对比:
int x = 3;
int y = 5;
int a = x && y; // 3 非 0 为真,5 非 0 为真 → 1
int b = x & y; // 3 & 5 = 1(逐位:011 & 101 = 001)思考题(逻辑操作符)
- 下面代码输出什么?为什么?
(提示:int i = 1; int j = 2; int k = 3; printf("%d\n", i < j || i > k && k < j);&&优先级高于||,等价于(i < j) || ((i > k) && (k < j))→ 真,输出 1。注意不能把&&高于||记成"先算与再算或"——优先级只管分组,求值顺序另说) x && y和x || y在什么情况下结果相同?(提示:x、y 同真或同假时——真值表只有一行不同)
逗号表达式
逗号表达式用逗号隔开多个子表达式,从左到右依次执行,整个表达式的结果是最后一个子表达式的结果。
#include <stdio.h>
int main()
{
int a = 1;
int b = 2;
int c = (a > b, a = b + 10, a, b = a + 1);
// 执行顺序:
// 1. a > b → 结果丢弃 (false)
// 2. a = b + 10 → a = 12
// 3. a → 结果丢弃 (12)
// 4. b = a + 1 → b = 13,整个表达式结果是 13
printf("c = %d\n", c); // 13
printf("a = %d\n", a); // 12
printf("b = %d\n", b); // 13
return 0;
}逗号表达式的优先级是所有操作符中最低的(比赋值还低)。所以 int c = 1, 2, 3; 不是逗号表达式——那是变量声明列表,声明了 c=1、2、3 三个变量(而且 2 和 3 没有类型会报错)。想用逗号表达式必须加括号:int c = (1, 2, 3); 此时 c = 3。没有括号的逗号,在声明里是分隔符,在表达式里才是逗号操作符。
逗号表达式最实用的场景是简化循环条件:
// 普通写法:需要在循环体末尾重复代码
a = get_val();
count_val(a);
while (a > 0)
{
// 业务处理
a = get_val();
count_val(a);
}
// 逗号表达式写法:把获取值和计数合并到 while 条件中
while (a = get_val(), count_val(a), a > 0)
{
// 业务处理——获取值和计数已在条件中完成
}注意 while 条件是 (a = get_val(), count_val(a), a > 0)——三个子表达式依次执行,最后一个是判断条件。这里有个容易忽略的点:逗号表达式左边的子表达式的结果是被丢弃的,如果那个子表达式有副作用(像 a = get_val()),副作用依然生效,只是它的值不用。
不要在不需要的地方滥用逗号表达式——它会牺牲可读性。
思考题(逗号表达式)
int x = (1, 2, 3);和int x = 1, 2, 3;的区别?a = (b = 3, c = 4);执行后 a 是几?b 和 c 呢?(提示:逗号表达式结果是最后一个,a = 4)for (i = 0, j = 10; i < j; i++, j--)里的逗号是干什么的?(提示:初始化列表和更新列表可以用逗号写多个变量)
下标访问 []、函数调用 ()
很多人没意识到,[] 和 () 本质上也是操作符。arr[5] 的操作数是 arr 和 5。更有意思的是,arr[5] 等价于 *(arr + 5),也等价于 *(5 + arr),甚至等价于 5[arr]:
#include <stdio.h>
int main()
{
int arr[10] = {0, 10, 20, 30, 40, 50};
// 以下四种写法完全等价
printf("%d\n", arr[2]); // 正常写法:20
printf("%d\n", *(arr+2)); // 指针写法:20
printf("%d\n", 2[arr]); // 反直觉但合法:20!
// arr[2] = *(arr + 2) = *(2 + arr) = 2[arr]
return 0;
}2[arr] 为什么合法?因为 C 标准把 E1[E2] 严格定义为 *((E1) + (E2))——下标只是"加法 + 解引用"的语法糖。既然加法可交换,2[arr] 和 arr[2] 自然等价。别在实际代码里写 2[arr]——它只说明你懂了原理,但可读性为零。真正要记住的是:下标操作的本质就是指针运算,这为后面指针篇理解 p[i] 与 *(p+i) 等价埋下伏笔。
函数调用操作符 () 的操作数至少有一个——函数名。如果有参数,后面的参数也是操作数。() 是优先级最高的操作符之一,它除了调用函数,还承担"强制改变结合顺序"的作用:int (*p)[10] 里那个括号就是干这个的。
思考题(下标/函数调用)
arr[-1]合法吗?什么情况下有意义?(提示:语法合法,语义取决于arr-1是否指向数组内的元素;指向数组外就是未定义行为)printf调用里的()里逗号是逗号操作符吗?(提示:函数参数列表的逗号是分隔符,不是操作符,参数求值顺序未指定)
结构成员访问操作符
结构体是 C 语言中自定义复杂数据类型的方式。比如描述一个学生:
#include <stdio.h>
#include <string.h>
// 定义一个学生类型
struct Stu
{
char name[20]; // 名字
int age; // 年龄
char sex[5]; // 性别
char id[20]; // 学号
}; // 分号不能丢!
int main()
{
// 定义并初始化结构体变量
struct Stu s1 = {"张三", 20, "男", "2024001"};
// 使用 . 操作符直接访问成员
printf("姓名:%s\n", s1.name); // 张三
printf("年龄:%d\n", s1.age); // 20
// 通过指针间接访问——使用 -> 操作符
struct Stu* ps = &s1; // 指针指向 s1
ps->age = 21; // 等价于 (*ps).age = 21;
strcpy(ps->name, "李四"); // 等价于 strcpy((*ps).name, "李四");
printf("修改后姓名:%s\n", s1.name); // 李四
printf("修改后年龄:%d\n", s1.age); // 21
return 0;
}. 和 -> 的记忆技巧:. 用于结构体变量本身,-> 用于指向结构体的指针。ps->age 等价于 (*ps).age——先解引用拿到结构体,再用 . 访问成员。-> 就是这两步操作的语法糖。
这里埋着一个优先级陷阱:. 的优先级高于 *。所以 *ps.age 会被解析成 *(ps.age)——而 ps 是指针不是结构体,ps.age 是编译错误。正确写法是 (*ps).age。所以 C 语言才设计了 -> 这个语法糖,让你不必每次写括号。
struct Point { int x; int y; };
struct Point p = {1, 2};
struct Point *pp = &p;
// 下面两种写法等价,但第二种不用写括号
pp->x = 10; // 等价于 (*pp).x = 10;
(*pp).x = 20; // 显式解引用后访问最后补几个结构体的基础语法,后面讲自定义类型时还会深入:
- 声明类型的同时定义变量:
struct Point { int x; int y; } p1;末尾的p1就是一个直接创建的结构体变量;也可以之后再单独定义:struct Point p2; - 初始化:按成员顺序给值
struct Point p3 = {10, 20};,或者用指定初始化器(C99)乱序给值struct Stu s2 = {.age = 20, .name = "lisi"}; - 嵌套初始化:结构体的成员可以是另一个结构体,初始化时用花括号层层嵌套:
struct Node
{
int data;
struct Point p; // 成员是结构体
struct Node* next; // 成员还可以是指向自身的指针(链表节点的雏形)
};
struct Node n1 = {10, {4, 5}, NULL}; // 嵌套初始化:{4,5} 对应成员 p思考题(结构成员访问)
(*ps).age和ps->age和ps.age三者的区别?(前两者等价且合法,第三种是编译错误——ps 是指针)- 为什么
->被发明出来?(提示:.优先级高于*,*ps.age会解析错)
除法与取模的边界行为
/ 和 % 属于算术操作符,但它们的行为细节值得单独讲——因为笔试常考,且 C99 前后行为不同。
第一,% 只能用于整数,5.5 % 2 是编译错误(浮点取模要用 fmod)。两个整数相除结果还是整数,小数部分丢弃。
第二,C99 起,整数除法是"向 0 截断"。 也就是说,商直接舍掉小数部分,不管是正数还是负数:
#include <stdio.h>
int main()
{
printf("%d\n", 5 / 2); // 2(向 0 截断)
printf("%d\n", -5 / 2); // -2(向 0 截断,不是 -3!)
printf("%d\n", 5 / -2); // -2
printf("%d\n", -5 / -2); // 2
return 0;
}很多初学者以为 -5 / 2 等于 -3(按"绝对值相除再取整"的直觉),但 C99 之后的标准是向 0 截断:-5 / 2 == -2。这是有历史原因的:C89 里负数的除法方向是实现定义的(有的编译器向 0,有的向 -∞),C99 统一为向 0,从此可移植。
第三,取模结果的符号与被除数(左操作数)一致。 C99 规定 a % b 满足恒等式 a = (a / b) * b + a % b。既然除法向 0 截断,% 的结果符号就跟着被除数走:
printf("%d\n", 5 % 2); // 1
printf("%d\n", -5 % 2); // -1(符号跟被除数 -5)
printf("%d\n", 5 % -2); // 1(被除数是 5)
printf("%d\n", -5 % -2); // -1所以判断奇偶用 x % 2 时要注意:-5 % 2 == -1,它不等于 1 但也不等于 0。如果想判断"是不是 2 的倍数",标准写法是 x % 2 == 0(偶数),而不是 x % 2 == 1(奇数,负数会漏判)——或者干脆用位运算 x & 1,它对任何整数都可靠。
第四,b 为 0 时,a / b 和 a % b 都是未定义行为——编译不报错,运行时可能崩溃(浮点除零通常得到 inf/nan,整数除零直接段错误或异常)。写代码时先判断除数为 0 是基本功。
第五,除法会触发整型提升——两个 char 相除会先提升成 int 再除。char c = 100; c / 3 的结果类型是 int,值是 33。如果要存回 char,需要自己注意截断。
思考题(除法与取模)
-7 / 2和-7 % 2分别是什么?(向 0 截断:-3 和 -1)- 怎么判断一个数是否是 3 的倍数且大于 0?(
x > 0 && x % 3 == 0) - 整数除法能不能用
>>代替?对正数a >> 1 == a / 2,对负数呢?(负数算术右移是向下取整,-3 >> 1 == -2,而-3 / 2 == -1,两者不同!)
操作符的属性:优先级、结合性
当表达式中出现多个操作符时,计算顺序由两个属性决定:
- 优先级:谁先算(高优先级先算);
- 结合性:优先级相同时,从左往右还是从右往左。
3 + 4 * 5; // * 优先级高于 +,所以先算 4*5=20,再算 3+20=23
5 * 6 / 2; // * 和 / 优先级相同,左结合,先算 5*6=30,再算 30/2=15
a = b = c; // = 是右结合,先算 b=c,再算 a=(b=c)下面是 C 语言完整的优先级表(从高到低),建议收藏并反复对照:
| 优先级 | 操作符 | 结合性 |
|---|---|---|
| 1(最高) | () [] . -> 后缀++ -- | 左→右 |
| 2 | 前缀++ -- ! ~ 一元+ - * & sizeof (类型) | 右→左 |
| 3 | * / % | 左→右 |
| 4 | + - | 左→右 |
| 5 | << >> | 左→右 |
| 6 | < <= > >= | 左→右 |
| 7 | == != | 左→右 |
| 8 | & | 左→右 |
| 9 | ^ | 左→右 |
| 10 | | | 左→右 |
| 11 | && | 左→右 |
| 12 | || | 左→右 |
| 13 | ?: | 右→左 |
| 14 | = 及所有复合赋值 | 右→左 |
| 15(最低) | , | 左→右 |
这张表有几个必须背下来的关键点:
&&高于||,但两者都低于==/!=,更低于关系操作符。所以a == b && c < d按直觉分组即可,不需要括号。- 位操作符的优先级很反直觉:
&低于==!所以if (x & 1 == 0)会被解析成if (x & (1 == 0))——1 == 0为假(0),x & 0恒为 0,这个 if 永远不成立。位运算和关系/逻辑混合时,一律加括号。 *(解引用)和&(取地址)是单目操作符,优先级比乘除法还高——但注意它们和变量名结合时的"声明"语义与"使用"语义不同(指针篇详述)。- 赋值是右结合,逗号是左结合且最低。
// 反直觉位运算实例:判断最低位
int x = 5;
if (x & 1) // ✅ 正确,先 & 再当条件
if (x & 1 == 0) // ❌ 解析为 x & (1==0),永远为假
// 指针成员访问
*p.f; // ❌ 解析为 *(p.f)(f 是成员)——见结构成员一节
(*p).f; // ✅ 正确有歧义就用圆括号。 圆括号既是最高优先级,也是最好的文档。
思考题(优先级与结合性)
*p++怎么解析?(提示:后缀++优先级高于解引用*,等价于*(p++)——先解引用 p 再让 p 后移,遍历数组的经典写法)a = b == c怎么解析?(提示:==高于=,等价于a = (b == c))-x * y和-(x * y)相同吗?(相同,因为一元-和*的优先级都高于+,但-x * y里一元减先作用于 x)
表达式求值
知道优先级和结合性之后,你就安全了吗?并没有。
整型提升
C 语言规定:在表达式求值中,所有小于 int 的整型(char、short)会先被提升为 int(或 unsigned int)。 这是因为 CPU 的 ALU(算术逻辑单元)设计上天然以 int 宽度(通常是 32 位)操作。
标准(C11 6.3.1.1)的精确说法是:如果 int 能表示该类型的所有值(char、short 的位数少于 int 时成立),就提升为 int;否则提升为 unsigned int。在 x86/x64 这些主流平台上,char 和 short 一律提升为 int。
#include <stdio.h>
int main()
{
char a = 5, b = 10, c;
c = a + b; // a 和 b 先被提升为 int,相加后结果截断为 char 存入 c
// 更关键的例子:负数整型提升
char c1 = -1;
// c1 的补码(8位):11111111
// 整型提升为 int(有符号,高位补符号位 1):
// 11111111111111111111111111111111 = -1(依旧是 -1)
printf("c = %d\n", c); // 15
printf("c1 = %d\n", c1); // -1
return 0;
}整型提升的规则:有符号类型高位补符号位(正补 0,负补 1);无符号类型高位补 0。之所以按符号位填充,是为了保持数值不变——-1 从 8 位变 32 位,必须还是 -1,所以高位全补 1。
整型提升带来的一个经典陷阱是 char 变量的取值范围:
char c = 127; // 有符号 char,最大 127
c = c + 1; // 提升为 int:127+1=128,赋回 char 时截断为 -128
printf("%d\n", c); // 输出 -128(有符号 char 的溢出是未定义行为,但主流编译器就是截断)
unsigned char uc = 255;
uc = uc + 1; // 提升为 int:255+1=256,赋回 unsigned char 截断为 0
printf("%d\n", uc); // 输出 0(无符号回绕是定义良好的)注意两行的区别:有符号溢出是未定义行为,无符号回绕是定义良好的(模 2^N)。这条边界要划清楚。
算术转换
当操作数的类型不同但都 >= int 时,会走"寻常算术转换"(usual arithmetic conversions)。网上很多资料把它简化成一条链:int → unsigned int → long → unsigned long → float → double → long double——这个简化不准确,尤其是把 float 混进"整数链"和忽略"能否表示"的判断。C11 标准(6.3.1.8)的精确规则是这样的:
- 先对两个操作数做整型提升;
- 如果一方是
long double,另一方转成long double;否则如果一方是double,另一方转成double;否则如果一方是float,另一方转成float——浮点规则独立存在,int和float混合时,int 转成 float; - 如果双方都是整数(提升后):
- 类型相同 → 结束;
- 同为有符号或同为无符号 → 谁 rank(秩)大转成谁(rank:
long long>long>int>short>char,无符号 rank 与对应的有符号相同); - 一有符号一无符号:设无符号类型为 U、有符号类型为 S:
- 若 U 的 rank ≥ S 的 rank,公共类型是 U;
- 否则若 S 能表示 U 的所有值,公共类型是 S;
- 否则公共类型是 S 对应的无符号类型。
看三个实际例子(以 32 位平台、int 与 long 同宽为背景):
#include <stdio.h>
int main()
{
// 例1:int + unsigned int → unsigned int
// (rank 相同,无符号的 rank ≥ 有符号的 rank)
int x = -1;
unsigned int u = 1;
printf("%u\n", x + u); // -1 转成 unsigned int(0xFFFFFFFF)后相加:
// 0xFFFFFFFF + 1 = 0x100000000,取模 2^32 = 0
// 输出 0!这就是"负数遇无符号变无符号"的经典坑
// 例2:int + float → float
printf("%.1f\n", 3 + 0.5f); // 3 转成 float:3.5
// 例3:unsigned int + long → unsigned long(32 位平台上)
// (U 的 rank < S 的 rank,但 S(long,32位有符号)无法表示 U(unsigned int)
// 的全部值 0~4294967295,所以公共类型是 unsigned long)
// 在 64 位平台上 long 是 64 位,能表示 unsigned int 全部值 → 结果是 long
return 0;
}例 1 是最著名的坑:-1 < 0u 是假!因为 -1 被转换成了无符号 0xFFFFFFFF(约 42 亿),4294967295 < 0 当然是假。任何"负数和有符号数比较"都可能是陷阱——比较前先确认两边都不会被转成无符号。
问题表达式——即使你懂优先级也可能写出 Bug
先看一个"看起来毫无问题"的表达式:
a * b + c * d + e * f;优先级告诉我们:* 一定比 + 先算。但仅此而已——它无法决定三个 * 和两个 + 之间谁先谁后。合法的计算顺序就有两种:
先 a*b、c*d,然后 a*b + c*d,再 e*f,最后相加
或者:先 a*b、c*d、e*f,再依次做两次加法
如果 a~f 都是纯变量,两种顺序结果一样;可一旦某个操作数是带副作用的表达式(比如函数调用、++),结果就可能不同。这就是"优先级 ≠ 求值顺序"的第一课。
问题一:c + --c
int c = 5;
int r = c + --c;操作符优先级告诉我们先 --c 再 +,但 + 的左操作数是 --c 执行前的 c 还是执行后的 c?C 标准(C11 6.5p2)说得很清楚:如果同一个标量对象在一次表达式求值中被修改(副作用),而这个修改与对同一对象的其他读取(值计算)彼此"未排序",行为是未定义行为。c + --c 里 --c 修改 c,+ 的左操作数读取 c,两者之间没有序列点——这是未定义行为,不是"结果未指定"。GCC 会警告 "operation on 'c' may be undefined"。
问题二:多个自增在同一表达式
int i = 1;
int ret = (++i) + (++i) + (++i);这段代码在 VS2022 上输出 ret=12,在 GCC 上输出 ret=10。同样的代码,不同的编译器,不同的结果——因为这是未定义行为,任何结果都不算"错"。不要试图给未定义行为找规律——它没有规律,换个优化级别可能又变。
问题三:函数调用顺序不确定
#include <stdio.h>
int fun()
{
static int count = 1;
return ++count; // 每次调用返回递增的值:2, 3, 4...
}
int main()
{
int answer;
answer = fun() - fun() * fun();
// 优先级能告诉我们先算 fun()*fun(),再算减法
// 但是!三个 fun() 的调用顺序是未指定的
printf("%d\n", answer);
return 0;
}三个 fun() 谁先谁后是未指定的(unspecified,和未定义不同——结果必然是"某种合法顺序下的值",不会越界崩溃,但每次运行/每个编译器可能不同)。可能的调用顺序有 6 种,answer 可能是 2-3×4、3-2×4、4-2×3…… 取决于编译器。
再来一个更极端的"重量级"例子——同一个变量在一条表达式里被反复自增自减还带赋值:
#include <stdio.h>
int main()
{
int i = 10;
i = i-- - --i * (i = -3) * i++ + ++i;
printf("i = %d\n", i);
return 0;
}这个表达式里,i 被修改了至少 4 次,i-- 和 i++ 之间、++i 和整个赋值之间都存在求值顺序的竞争。它在不同编译器上跑出来的结果五花八门,甚至在同一个编译器的不同优化级别下都会变——这是典型的未定义行为,没有任何"正确"答案,纯属用来展示"别这么写"的反面教材。
核心教训:不要在一个表达式中对一个变量做多次修改。 判断一个表达式是否安全的简单规则——如果同一个变量在同一表达式中出现超过一次,并且至少有一次是修改操作(++、--、=),那这个表达式就有风险。拆成多行写,让代码清晰、正确、可移植。
补充一个实用建议:开编译器的警告开关(GCC/Clang 用 -Wall -Wextra,MSVC 用 /W4),这类问题表达式大都会得到警告。警告不是用来"无视"的——-Wall 报的 -Wsequence-point(序列点)警告,直接对应的就是上面这些未定义行为。
思考题(表达式求值)
int i = 0; int a = i++ + i++;输出 a 是多少?(未定义行为,不要猜)char c = 200; int n = c + 1;n 是多少?(提升后 200+1=201,但如果 char 有符号且 200 溢出——声明char c = 200;本身就是未定义/实现定义,换成unsigned char才是 201)unsigned int a = 10; int b = -5; a > b是真是假?(假!b 转成无符号 4294967291)
讲了这么多,最后用一个位运算的实战来收尾——用位运算实现权限管理。用一个整数的不同位表示不同权限,是位运算最常见的实际应用之一:
#include <stdio.h>
// 定义权限位(每位的编号)
#define PERM_READ 0 // 第 0 位:读权限
#define PERM_WRITE 1 // 第 1 位:写权限
#define PERM_EXECUTE 2 // 第 2 位:执行权限
#define PERM_ADMIN 3 // 第 3 位:管理员权限
// 添加权限:将指定位设为 1
void AddPermission(int *permissions, int perm_bit)
{
*permissions |= (1 << perm_bit); // 按位或,目标位置 1
}
// 移除权限:将指定位清为 0
void RemovePermission(int *permissions, int perm_bit)
{
*permissions &= ~(1 << perm_bit); // 按位与取反,目标位清 0
}
// 检查权限:判断指定位是否为 1
int HasPermission(int permissions, int perm_bit)
{
return (permissions >> perm_bit) & 1; // 右移后取最低位
}
// 打印权限(用于演示)
void PrintPermissions(int permissions)
{
printf("当前权限:[");
if (HasPermission(permissions, PERM_READ)) printf(" 读");
if (HasPermission(permissions, PERM_WRITE)) printf(" 写");
if (HasPermission(permissions, PERM_EXECUTE)) printf(" 执行");
if (HasPermission(permissions, PERM_ADMIN)) printf(" 管理");
printf(" ]\n");
}
int main()
{
int perms = 0; // 初始无任何权限
PrintPermissions(perms); // [ ]
AddPermission(&perms, PERM_READ); // 添加读权限
AddPermission(&perms, PERM_WRITE); // 添加写权限
PrintPermissions(perms); // [ 读 写 ]
printf("有管理员权限吗?%s\n",
HasPermission(perms, PERM_ADMIN) ? "有" : "没有"); // 没有
RemovePermission(&perms, PERM_READ); // 移除读权限
AddPermission(&perms, PERM_ADMIN); // 添加管理员权限
PrintPermissions(perms); // [ 写 管理 ]
return 0;
}这个权限系统为什么好用?因为一个 int 就能表示 32 种独立权限,增删查都只要一条位运算指令,还天然支持组合权限(比如 PERM_READ | PERM_WRITE 一次授予两种)。Linux 的文件权限(rwx)、网络协议标志位、硬件寄存器配置等领域全部是这套思路。它的本质,就是把前面学的"补码存储、移位定位、掩码增删"三件事串成一条流水线。
走到这里,我们其实已经把这四十多个操作符全部过了一遍:从二进制和补码打底,到移位和位运算操控比特;从单目操作符的 sizeof 玄机,到逗号表达式和下标运算的本质;从优先级结合性,到整型提升、算术转换和表达式求值的雷区。操作符是 C 语言的"词汇表",你对这张词汇表的理解有多深,你就能用多精确的方式表达你的意图,同时也能避开那些潜藏在优先级和类型转换暗处的地雷。
最后留一个综合练习,把本讲的知识串起来自测:
用位运算实现:给定一个整数
x,返回它"二进制中最高位的 1 所在的位置"(比如 x=13=1101,最高位 1 在 bit 3,返回 3)。可以用循环,也可以思考更聪明的做法。(提示:先右移逐位探测是 O(n) 的朴素做法;想一想怎么用x & (x-1)消位来找)
如果你能把本文所有思考题独立推演出来,操作符这一关就算真正过了。下一站,是 C 语言里"让人又爱又恨"的指针——而取地址 & 和解引用 * 这两个压轴操作符,正是指针的大门。
思考题参考答案与详解
下面是本文全部思考题的分组详解(含篇末综合练习),建议先独立推演、再对照确认。
进制
1. 0xFF 等于十进制多少?0x100 呢?
0xFF = 15×16 + 15 = 255;0x100 = 1×16² + 0×16 + 0 = 256。十六进制每一位是 16 的幂。
2. 二进制 10101010 转十六进制是多少?
从右往左每 4 位一组:1010 1010,各对应十六进制 A A,结果为 0xAA。
3. 为什么 0xFFFFFFFF 在 32 位 int 里表示 -1?
32 位 int 用补码存储,0xFFFFFFFF 是 32 位全 1,而补码全 1 恰好对应 -1(也是 ~0 的结果)。这一点在你掌握补码后自然就通了。
原码、反码、补码
1. ~0 等于几?
~0 == -1。0 的补码全 0,按位取反得全 1,补码全 1 就是 -1。
2. 写出 -1、-128(8 位)的补码。
-1 的补码:11111111(原码 10000001 → 反码 11111110 → +1 得 11111111)。-128 的补码:10000000(-128 只有补码能表示,原码/反码都表示不了它,这也说明补码范围比原码多一个数)。
3. 为什么 ~5 + 1 == -5?
对任何整数都有 ~x == -x-1(因为取反就是"(-1)-(原值)"),所以 ~x + 1 == -x。代入 5:~5 = -6,~5 + 1 = -5。这正是"取反加 1 得相反数"的通用手段,也是补码转换的底层来源。
移位操作符
1. 1 << 4 等于几?0x10 又是几?两者关系?
1 << 4 = 16;0x10 = 16。两者相等——0x10 的二进制正是 10000,即十进制的 16。
2. 用移位判断一个整数是否是 2 的幂。
2 的幂的二进制只有一个 1(如 8 = 1000)。利用 x & (x-1) 消去最低位 1 的性质:若 (x > 0) && ((x & (x-1)) == 0),则 x 是 2 的幂(唯一那个 1 被消掉后变 0)。
3. -1 >> 1 在算术右移下是多少?为什么?
结果是 -1。-1 的补码是 32 个 1,算术右移左边补符号位 1,右移一位后还是全 1,等于 -1。所以全 1 的数无论怎么算术右移都是 -1。
位操作符
1. x & (x-1) 除了统计 1 的个数,还能干什么?
能判断是不是 2 的幂:(x & (x-1)) == 0。因为 2 的幂只有一个 1,消掉最低位那个 1 后就是 0。它本身就是"每次消去最低位一个 1"的原子操作,可用来枚举/清位。
2. 如何不用 % 判断奇偶?
用 x & 1:最低位是 1 为奇数、是 0 为偶数,即 (x & 1) 为真 → 奇数。它对正负整数都可靠(% 处理负数有坑,位运算没有)。
3. 加减法交换(a=a+b; b=a-b; a=a-b;)有什么隐患?
隐患是整数溢出:若 a+b 超出类型可表示范围,溢出后 b、a 都算错(有符号溢出是未定义行为)。而异或法 a=a^b; b=a^b; a=a^b; 没有这个隐患——异或按位运算不产生进位、永不"溢出",且靠 a^a==0、a^0==a 无损还原。
4. 一个数自己异或自己?异或 0?
a ^ a == 0(自身异或抵消); a ^ 0 == a(异或 0 保持原值)。这两个性质是"找落单的数"和异或交换的基础。
单目操作符
1. sizeof 和 strlen 有什么区别?
sizeof 是操作符(可省略括号,如 sizeof a),编译期求值,返回的是类型/变量占的字节数,不计算操作数(sizeof(i++) 不会让 i 增加)。strlen 是函数,运行期求值,要遍历字符串直到 '\0' 才返回长度。而数组做函数参数退化成指针后,sizeof 只能得到指针大小——这也是两者最易混淆的点。
2. int i = 5; printf("%d\n", i++ + ++i); 输出什么?
未定义行为,不要试图猜。因为 i 在同一表达式里既被读(++i 前/后、i++)又被多次修改(两次自增),且彼此之间没有序列点。任何输出(10、11、12……)都不算"错",换个编译优化级别结果都可能变。
3. (char)300 等于几?
char 是 8 位,300 的二进制是 100101100(9 位),强制转换截断取低 8 位 00101100 = 44。若 char 有符号,低 8 位的符号位是 0,结果仍为 44。
赋值操作符与复合赋值
1. int x = 3; x += x *= 2; 后 x 是多少?
按直觉从右往左:x *= 2 使 x=6(该子表达式值为 6),再 x += 6 得 x=12。但注意:这个表达式对 x 有多次修改且无序列点,属于未定义行为范畴,不同编译器结果可能不同——生产代码不要这么写,这里只是演示复合赋值符的展开关系。
2. 为什么 while (scanf("%d", &n) == 1) 不能省略 == 1?
scanf 的返回值是"成功读入的项数":读到了整数返回 1;输入结束到文件末尾返回 EOF(通常是 -1)。若省略 == 1 写成 while(scanf("%d",&n)),当读到 EOF 时返回 -1,-1 非 0,循环条件仍为真 → 错误地继续、陷入死循环。必须用 == 1(或 > 0)识别"恰好成功读入 1 个数",才能在输入结束时正确退出。
关系操作符
1. 为什么 'A' < 'a' 成立?
字符比较按 ASCII 码进行:'A' 是 65,'a' 是 97,65 < 97,所以成立。
2. if (a < b == c) 合法吗?表示什么?
合法。== 优先级低于 <,等价于 (a < b) == c:先算 a<b 得 0/1,再用这个 0/1 去和 c 比较相等。可读性极差,不推荐写。
逻辑操作符
1. 下面代码输出什么?为什么?
int i = 1, j = 2, k = 3;
printf("%d\n", i < j || i > k && k < j);&& 优先级高于 ||,等价于 (i<j) || ((i>k) && (k<j))。i<j 即 1<2 为真,|| 短路,整个表达式为真,输出 1。(即使算右边 1>3 假、3<2 假,结果也为真。)
2. x && y 和 x || y 什么时候结果相同?
当 x、y 同为真或同为假时:同真都为 1,同假都为 0。只有一个真一个假时不同(一个为 1、一个为 0)。
逗号表达式
1. int x = (1, 2, 3); 和 int x = 1, 2, 3; 的区别?
int x = (1,2,3); 是逗号表达式(有括号),结果为最后一项,x=3。int x = 1, 2, 3; 是变量声明列表——它试图声明 x、2、3 三个变量,其中 2、3 不是合法声明符,会编译报错。没有括号的逗号在声明里是分隔符,在表达式里才是逗号操作符。
2. a = (b = 3, c = 4); 执行后 a、b、c 各是多少?
先 b=3,再 c=4,逗号表达式结果是最后一个 c=4,赋给 a。所以 a=4、b=3、c=4。
3. for (i = 0, j = 10; i < j; i++, j--) 里的逗号是干什么的?
是 for 语句初始化部分(i=0, j=10)和更新部分(i++, j--)里的分隔符,用逗号把多个变量的初始化/更新写在一起。此时逗号扮演的是"语法分隔"角色,不具备"逗号表达式求值"的语义(其值被丢弃,for 的括号已把它限定为一条条语句序列)。
下标访问、函数调用
1. arr[-1] 合法吗?什么情况下有意义?
语法上合法——arr[-1] 等价于 *(arr-1)。其是否"有意义"取决于 arr-1 是否指向数组内的元素:只有当你先让某个指针指向数组内部、再用负偏移回到它时才是有意义的(如 p = &arr[5]; p[-2] 指向 arr[3])。一般情况下 arr[-1] 访问数组首元素之前的内存,是未定义行为(越界)。
2. printf 调用里的 () 内逗号是逗号操作符吗?
不是。函数参数列表里的逗号是分隔符(分隔各个实参),不是逗号操作符;并且实参的求值顺序未指定(unspecified)。只有出现在"表达式"语境里的逗号才是逗号操作符。
结构成员访问
1. (*ps).age、ps->age、ps.age 三者的区别?
(*ps).age 与 ps->age 等价且都合法(先对指针解引用拿到结构体,再取成员);ps.age 是编译错误——ps 是指针,. 要求左边是结构体而不是指针。所以访问指针所指向结构体的成员,必须用 -> 或 (*ps).xxx。
2. 为什么 -> 被发明出来?
因为 . 的优先级高于解引用 *,若写 *ps.age 会被解析成 *(ps.age)(先 . 再 *),而 ps 是指针没有 .age,编译报错。为避免每次写 (*ps).age 都要打括号,C 引入了 -> 语法糖:ps->age 严格等价于 (*ps).age。
除法与取模
1. -7 / 2 和 -7 % 2 分别是什么?
C99 起整数除法向 0 截断:-7 / 2 == -3;取模结果的符号跟随被除数:-7 % 2 == -1。
2. 怎么判断一个数是 3 的倍数且大于 0?
x > 0 && x % 3 == 0。
3. 整数除法能不能用 >> 代替?
对正数可以:a >> 1 == a / 2。对负数不行:负数右移是算术右移(向下取整),-3 >> 1 == -2;而 C 的除法 / 向 0 截断,-3 / 2 == -1。两者在负数时不相等,所以不能用 >> 完全代替整数除法。
优先级与结合性
1. *p++ 怎么解析?
后缀 ++ 优先级高于解引用 *,等价于 *(p++):先用 p 当前指向的地址取值,再让 p 后移一位。这是遍历数组/链表的经典写法(先取当前元素,指针移动到下一个)。
2. a = b == c 怎么解析?
== 优先级高于 =,等价于 a = (b == c):先比较 b==c 得 0 或 1,再把结果赋给 a。
3. -x * y 和 -(x * y) 相同吗?
数值相同:-x*y 等价 (-x)*y,而 (-x)*y == -(x*y)(乘法与负号可交换)。写法不同但计算结果一致。
表达式求值
1. int i = 0; int a = i++ + i++; 输出 a 是多少?
未定义行为,不要猜。i 在同一表达式里被修改两次且无序列点,任何结果都没意义。换个编译器/优化级别都可能不同。
2. char c = 200; int n = c + 1; n 是多少?
若 char 有符号(且 char 最大 127),char c = 200 本身就是实现定义/未定义(溢出),此时的 c 可能是负数(截断成 -56),n 就是 -55 之类。若 char 是无符号(unsigned char c = 200),则整型提升后 200 + 1 = 201。稳妥写法是用 unsigned char,得到确定的 201。
3. unsigned int a = 10; int b = -5; a > b 是真是假?
假。比较时 b 被"寻常算术转换"提升为 unsigned int:-5 → 0xFFFFFFFB(≈ 4294967291),那么 10 > 4294967291 为假。这就是"负数遇无符号变无符号大数"的经典陷阱。
篇末综合练习:求最高位 1 所在的位置
给定整数 x(如 13 = 1101,最高位 1 在 bit 3,返回 3)。朴素法是从最高位向下逐位探测:
int highestBit(int x)
{
int i;
if (x == 0) return -1; // 全 0 没有 1,约定返回 -1
for (i = 31; i >= 0; i--) // 从 bit31 向下找第一个 1
if (x & (1 << i))
return i;
return -1;
}
/* 13 -> 3,8 -> 3,0 -> -1 */更高效(不依赖位数逐位循环)的"倍增"思路:先把最高位 1 到 bit0 全部填成 1,再剥掉除最高位以外的所有 1,得到"只有最高位的 2 的幂",最后对其求以 2 为底的对数即得位置:
int highestBitFast(unsigned int x)
{
if (x == 0) return -1;
x |= x >> 1; // 把最高位 1 到后一位全填成 1
x |= x >> 2;
x |= x >> 4;
x |= x >> 8;
x |= x >> 16; // 此时最高位 1 到 bit0 全为 1
x ^= x >> 1; // 异或剥壳,只保留最高位那个 1
// 现在 x 是唯一的 2 的幂,求它的 bit 位置:
int pos = 0;
while (x > 1) { x >>= 1; pos++; } // 等价于取 log2(x)
return pos;
}
/* 13 -> 3,8 -> 3 */两种写法结果一致,第二种无论 int 多宽都恒定只需有限几步(依次右移 1、2、4、8、16 位即可覆盖到 32 位 int 的最高位);学完指针和算法后你会更适应这种位技巧。
还没有评论 — 第一条由你来留。