3. 指针运算与数组:指针加减、数组名退化、下标访问的底层原理
指针和数组,这对冤家在C/C++里纠缠了几十年。我见过太多人在它们身上栽跟头,包括我自己。刚入行那会儿,我写了个网络缓冲区,数组名当指针传来传去,结果内存越界查了整整两天。嗯,今天咱们就把这层窗户纸捅破。
3.1 数组名到底是个啥?
说白了,数组名就是数组首元素的地址。但这里有个坑——它不完全是指针。
核心区别:
- 数组名是常量,不能自增自减
- 指针是变量,可以随便改
- 对数组名取sizeof,得到的是整个数组的大小
- 对指针取sizeof,得到的是指针本身的大小(32位下4字节,64位下8字节)
int arr[5] = {1, 2, 3, 4, 5};
int *p = arr;
printf("sizeof(arr) = %zu\n", sizeof(arr)); // 20 (5 * 4)
printf("sizeof(p) = %zu\n", sizeof(p)); // 8 (64位系统)
// arr++ // 编译错误!数组名不是左值
p++; // 合法,p指向arr[1]
为什么会这样?因为数组名在大多数表达式中会「退化」成指向首元素的指针。但注意,sizeof和&操作符是例外。
3.2 指针加减的底层逻辑
指针加减,不是简单的整数加减。它是以「指向类型的大小」为步长的。我习惯叫它「类型感知的算术」。
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr;
printf("p = %p\n", p); // 假设 0x1000
printf("p+1 = %p\n", p+1); // 0x1004 (int占4字节)
printf("p+2 = %p\n", p+2); // 0x1008
你想想看,如果指针指向的是结构体呢?
struct Data {
int id;
char name[32];
double score;
};
struct Data arr[3];
struct Data *p = arr;
// p+1 的偏移量是 sizeof(struct Data),不是1个字节!
printf("sizeof(struct Data) = %zu\n", sizeof(struct Data));
// 在我的机器上是 48 字节(考虑对齐)
我曾经踩过的坑: 写网络协议解析时,把一个char*强制转换成struct header*,然后做指针加减。结果因为结构体对齐,算出来的偏移量全错了。从那以后,我处理二进制协议一定用memcpy或者union,绝不用指针强转。
3.3 下标访问的真相
很多人以为arr[i]是数组特有的语法。其实它就是个语法糖。
底层原理: arr[i] 等价于 *(arr + i)
这就解释了为什么你可以写i[arr]——虽然没人这么干,但语法上完全合法。
int arr[5] = {1, 2, 3, 4, 5};
// 下面三种写法完全等价
printf("%d\n", arr[2]); // 3
printf("%d\n", *(arr + 2)); // 3
printf("%d\n", 2[arr]); // 3 (别这么写,会被同事打的)
我的建议: 永远用arr[i]这种标准写法。虽然i[arr]能证明你懂底层原理,但代码是给人看的,不是用来炫技的。
3.4 二维数组的指针运算
二维数组的指针运算,很多人觉得头疼。其实记住一句话就行:逐层解引用。
int matrix[3][4] = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
// matrix 的类型是 int (*)[4],指向一行(4个int)
// matrix[0] 的类型是 int*,指向第一个元素
printf("matrix = %p\n", matrix); // 首行地址
printf("matrix+1 = %p\n", matrix+1); // 第二行地址,偏移 4*4=16字节
printf("*(matrix+1) = %p\n", *(matrix+1)); // 第二行首元素地址
printf("*(*(matrix+1)+2) = %d\n", *(*(matrix+1)+2)); // 7
我画了个图,帮你理清这个关系:
3.5 指针越界的典型场景
指针运算最危险的地方,就是越界。我总结了几种常见情况:
| 场景 | 代码示例 | 风险等级 |
|---|---|---|
| 数组下标越界 | arr[10] 但数组只有5个元素 |
高 |
| 指针加减超出范围 | p += 100 但只分配了10个元素 |
高 |
| 空指针运算 | NULL + 5 |
极高 |
| 类型转换后指针运算 | (int*)char_arr + 1 |
中 |
我曾经犯过的错: 写一个字符串处理函数,用指针遍历字符数组。循环条件写成了 while (*p++),结果字符串没有'\0'结尾,指针一路狂奔,把栈上的返回地址都给改了。程序崩溃时,我盯着core dump看了半小时才反应过来。
3.6 安全编码建议
说了这么多,最后给几条实用的建议:
- 能用下标就别用指针——下标至少能让你一眼看出访问的是第几个元素
- 指针运算一定要有边界检查——我习惯在循环里保留一个end指针
- 数组传参时,把长度一起传过去——别指望函数内部能知道数组大小
- 警惕「差一错误」——
arr + len指向的是越界位置,不是最后一个元素
// 安全遍历的写法
int arr[10];
int *end = arr + 10; // 指向越界位置,用于比较
for (int *p = arr; p < end; p++) {
// 处理 *p
}
// 而不是
for (int i = 0; i <= 10; i++) { // 越界!
// arr[i]
}
我的习惯: 写指针运算时,我总会在注释里标出当前指针指向的位置和边界。比如 // p指向buf[3],buf大小16。这习惯救过我很多次。
指针运算和数组,说白了就是地址的算术。但地址错了,程序就崩了。理解底层原理,养成安全编码的习惯,你就能少踩很多坑。