第 3 章 · 指针运算

指针能做算术,这是 C 最像汇编的地方,也是 bug 最密集的地方。先看一个我真实踩过的坑:函数收到 void *data,里面是 int 数组,我想跳过第 3 个元素,写了 data + 3 然后强转读出来,结果永远是第 0 个元素后面第 3 个字节。原因:void* 和 char* 的步长是 1 字节,int* 的步长是 4。指针 +1 加的不是 1,是「一个元素」,这个缩放规则是本章的地基,本章也顺手把原文小节编号错乱(2.x)修正回来。

3.1 步长:类型决定一次 +1 走多远

指针加减整数,编译器自动乘上指向类型的大小:

int a[5] = {10, 20, 30, 40, 50};
int *p = a;
p + 1;        // 地址 +4 字节(int 占 4),指向 a[1]
char *c = (char *)a;
c + 1;        // 地址 +1 字节,只前进一个字节
double *d = (double *)a;
d + 1;        // 地址 +8 字节,越过了两个 int!

同一个地址,三种类型三种步长。p + n 的严格语义是「从 p 起第 n 个元素的位置」,等价于按字节算的 (char*)p + n * sizeof(*p)。想按字节走,显式转 char*;想按元素走,保持原类型。2.3 章那个坑的修法:(int *)data + 3。

3.2 a[i] 是语法糖:i[p] 竟然也合法

C 标准规定下标运算符定义为 a[i] ≡ *(a + i),于是:

p[i]  ≡  *(p + i)  ≡  *(i + p)  ≡  i[p]     // 加法交换律,后者也是合法 C

i[p] 能编译能运行,这个冷知识的意义在于揭示本质:下标只是指针加法的糖,而加法可交换。顺带解开一个千古问题:数组下标为什么从 0 开始?a[0] = *(a + 0) = 首元素本身,若从 1 开始,a[1] = *(a+1) 就要跳过第一个元素,需要额外的 −1 补偿。0 起步是「地址直接可用」的自然结果,不是习惯问题。

3.3 指针相减:差的是元素个数

同一数组内的两个指针相减,结果是元素个数(类型 ptrdiff_t):

int a[5] = {10, 20, 30, 40, 50};
int *p = &a[1], *q = &a[4];
printf("%td\n", q - p);       // 3,不是 12

相减自动除以步长,这个特性让「距离」的计算和类型无关地自然。两个红线:相减只在同一数组(或同一对象)内有定义,不同数组的指针相减是未定义行为(第 14 章立案);比较同理,< > 只对同数组指针有意义,==/!= 则任意指针都比得了。

有了加减和比较,数组的指针版遍历就齐了:

for (int *p = a; p < a + 5; p++)   // p 是「游标」,a+5 是终点
    printf("%d ", *p);

它和 for (i = 0; i < 5; i++) a[i] 生成几乎相同的汇编(p++ 就是地址加 4),选哪种纯看可读性。

3.4 one-past-the-end:唯一合法的「越界」

指针运算最锋利的边界:算出一个越界指针本身,就已经是未定义行为(不用等解引用)。唯一的合法例外是 one-past-the-end:

int a[5];
int *end = a + 5;    // 合法:末尾后一格,可比较、可作循环终点
int *bad = a + 6;    // UB:多走了一步
int *also = a - 1;   // UB:往前越界同样不行
*end;                // UB:合法的地址,不合法的解引用

one-past-the-end 的存在是为了让 p != end 的循环终止条件成为可能(C++ 迭代器的 end() 继承了这个设计)。越界 UB 的阴险在于「不一定当场崩」:越界读个相邻变量,程序看起来一切正常,雷埋下了;更狠的是编译器按「越界不会发生」做优化(计组课第 2 章讲过 UB 的本质),循环边界检查被删掉,防护代码形同虚设。

3.5 void* 的算术:标准说不,编译器说不算数

标准 C 里 void* 不能加减(不知道步长),GCC/Clang 当扩展允许它按 1 字节走,可移植代码别赌这个。要按字节操作,先转 char*:

void *mem = malloc(64);
char *c = mem;      // 按字节处理的正确姿势
c + 1;              // 合法

常见错误

  • 对 void 或 char 做元素级跳转**。本章开头那个 bug 的变体每年都在新代码里出现;口诀:「按字节走用 char*,按元素走用原类型,跳之前想一步长」。
  • 用 <= 写循环终点。for (p = a; p <= a + 5; p++) 解引用到 a[5],恰好踩进 one-past-the-end 的雷区,数组后若紧邻其他变量,悄悄污染它们。
  • 以为指针相减给的是字节数。结果是元素个数;要字节差,先都转 char* 再减。

练习

  1. 动手:声明 int、double、short 三个指针指向同一数组首地址,各 +1 后打印地址差值,验证步长;再把 int* 强转 char* 后 +3,打印,验证按字节走。
  2. 手算:int a[4][3]; int *p = &a[0][0]; 要访问 a[2][1],指针写法是什么(算清偏移 2*3+1)?再用 *(a[2] + 1) 验证两条路等价。
  3. 动手:写一个「乱序写」的小实验:数组 a[4] 后紧跟数组 b[4],故意让指针越界写 a[4](即 b[0]),打印 b 的内容,亲眼看看「越界 UB」把谁的内存改了(此实验仅本地玩,生产代码别写)。
  4. 思考:Java/C# 明令禁止指针算术,换来的是越界检查与安全;C 保留它,换来的是 lea 一条指令的地址计算。结合计组课第 3 章(乘除很贵)想想:C 的哪些场景真的离不开「手动算地址」?哪些场景其实是历史包袱?