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 位十六进制,转起来毫无压力。

思考题(进制)

  1. 0xFF 等于十进制多少?0x100 呢?
  2. 二进制 10101010 转十六进制是多少?(提示:从右往左每 4 位分组)
  3. 为什么 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;
}

思考题(原码反码补码)

  1. ~0 等于几?如果 0 的补码是全 0,取反后是全 1,全 1 的补码就是 -1,所以 ~0 == -1。
  2. 写出 -1、-128(8 位)的补码。
  3. 为什么 ~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"这句话,严谨的说法是"在不溢出的前提下成立"。写代码时想用左移做乘法,先问自己:这个数会不会大到溢出的程度?

右移 >>

右移比左移复杂,因为存在两种方式:

  1. 算术右移(大多数编译器使用):左边用符号位填充,右边丢弃;
  2. 逻辑右移:左边用 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. 1 << 4 等于几?0x10 又是几?两者有什么关系?
  2. 用移位实现:判断一个整数是否是 2 的幂。(提示:2 的幂的二进制只有一个 1,比如 8 = 1000)
  3. -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 = 26 && 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"就是"提取某个字节"的标准动作,图像处理里天天见。

思考题(位运算)

  1. x & (x-1) 除了统计 1 的个数,还能干什么?(提示:判断 2 的幂)
  2. 如何不用 % 判断奇偶?(提示:x & 1)
  3. 两个整数交换,除了异或法和临时变量法,还有加减法:a=a+b; b=a-b; a=a-b;。它有什么隐患?(提示:溢出——但更重要的是想想为什么异或法没有这个隐患,其实异或法也没有!因为异或不产生进位)
  4. 一个数自己异或自己是几?一个数异或 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()) 不会真的调用函数。这带来两个推论:

  1. sizeof 的"不计算"特性意味着它永远不会有运行时副作用,可以放心用;
  2. 反过来,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!强转的括号覆盖范围要盯紧。

思考题(单目操作符)

  1. sizeof 和 strlen 有什么区别?(提示:一个是操作符一个是函数,一个编译期一个运行期——答案在后续指针篇详细展开,先自己想想)
  2. 下面代码输出什么?为什么?
    int i = 5;
    printf("%d\n", i++ + ++i);
    (提示:这是未定义行为,不要试图"猜"它的输出——详见本文表达式求值一节)
  3. (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; 想表达的是别的意思,先想清楚再写。

思考题(赋值)

  1. int x = 3; x += x *= 2; 执行后 x 是多少?(提示:x *= 2 把 x 变成 6,表达式值为 6;然后 x += 6,x = 12。但注意:这条表达式对 x 有多次修改,属于未定义行为范畴——不要在生产代码里写这种)
  2. 为什么 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;
}

思考题(关系操作符)

  1. 为什么 'A' < 'a' 成立?(提示:ASCII 码 A=65,a=97)
  2. 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)

思考题(逻辑操作符)

  1. 下面代码输出什么?为什么?
    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。注意不能把 && 高于 || 记成"先算与再算或"——优先级只管分组,求值顺序另说)
  2. 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()),副作用依然生效,只是它的值不用。

不要在不需要的地方滥用逗号表达式——它会牺牲可读性。

思考题(逗号表达式)

  1. int x = (1, 2, 3); 和 int x = 1, 2, 3; 的区别?
  2. a = (b = 3, c = 4); 执行后 a 是几?b 和 c 呢?(提示:逗号表达式结果是最后一个,a = 4)
  3. 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] 里那个括号就是干这个的。

思考题(下标/函数调用)

  1. arr[-1] 合法吗?什么情况下有意义?(提示:语法合法,语义取决于 arr-1 是否指向数组内的元素;指向数组外就是未定义行为)
  2. 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

思考题(结构成员访问)

  1. (*ps).age 和 ps->age 和 ps.age 三者的区别?(前两者等价且合法,第三种是编译错误——ps 是指针)
  2. 为什么 -> 被发明出来?(提示:. 优先级高于 *,*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,需要自己注意截断。

思考题(除法与取模)

  1. -7 / 2 和 -7 % 2 分别是什么?(向 0 截断:-3 和 -1)
  2. 怎么判断一个数是否是 3 的倍数且大于 0?(x > 0 && x % 3 == 0)
  3. 整数除法能不能用 >> 代替?对正数 a >> 1 == a / 2,对负数呢?(负数算术右移是向下取整,-3 >> 1 == -2,而 -3 / 2 == -1,两者不同!)

操作符的属性:优先级、结合性

当表达式中出现多个操作符时,计算顺序由两个属性决定:

  1. 优先级:谁先算(高优先级先算);
  2. 结合性:优先级相同时,从左往右还是从右往左。
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(最低),左→右

这张表有几个必须背下来的关键点:

  1. && 高于 ||,但两者都低于 ==/!=,更低于关系操作符。所以 a == b && c < d 按直觉分组即可,不需要括号。
  2. 位操作符的优先级很反直觉:& 低于 ==!所以 if (x & 1 == 0) 会被解析成 if (x & (1 == 0))——1 == 0 为假(0),x & 0 恒为 0,这个 if 永远不成立。位运算和关系/逻辑混合时,一律加括号。
  3. *(解引用)和 &(取地址)是单目操作符,优先级比乘除法还高——但注意它们和变量名结合时的"声明"语义与"使用"语义不同(指针篇详述)。
  4. 赋值是右结合,逗号是左结合且最低。
// 反直觉位运算实例:判断最低位
int x = 5;
if (x & 1)            // ✅ 正确,先 & 再当条件
if (x & 1 == 0)       // ❌ 解析为 x & (1==0),永远为假
 
// 指针成员访问
*p.f;                 // ❌ 解析为 *(p.f)(f 是成员)——见结构成员一节
(*p).f;               // ✅ 正确

有歧义就用圆括号。 圆括号既是最高优先级,也是最好的文档。

思考题(优先级与结合性)

  1. *p++ 怎么解析?(提示:后缀 ++ 优先级高于解引用 *,等价于 *(p++)——先解引用 p 再让 p 后移,遍历数组的经典写法)
  2. a = b == c 怎么解析?(提示:== 高于 =,等价于 a = (b == c))
  3. -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)的精确规则是这样的:

  1. 先对两个操作数做整型提升;
  2. 如果一方是 long double,另一方转成 long double;否则如果一方是 double,另一方转成 double;否则如果一方是 float,另一方转成 float——浮点规则独立存在,int 和 float 混合时,int 转成 float;
  3. 如果双方都是整数(提升后):
    • 类型相同 → 结束;
    • 同为有符号或同为无符号 → 谁 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(序列点)警告,直接对应的就是上面这些未定义行为。

思考题(表达式求值)

  1. int i = 0; int a = i++ + i++; 输出 a 是多少?(未定义行为,不要猜)
  2. char c = 200; int n = c + 1; n 是多少?(提升后 200+1=201,但如果 char 有符号且 200 溢出——声明 char c = 200; 本身就是未定义/实现定义,换成 unsigned char 才是 201)
  3. 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 的最高位);学完指针和算法后你会更适应这种位技巧。