指针的运算:算术、比较与数组名的秘密
指针的运算,是C语言里最让人又爱又恨的部分。爱它,是因为灵活;恨它,是因为太容易踩坑。今天我们就来彻底聊透这个话题。
一、指针的算术运算
指针的加减运算,不是简单的地址加减。它遵循一个核心规则:移动的单位是指向类型的大小。
举个例子。一个 int* 指针,加1,地址增加4(假设int占4字节)。一个 char* 指针,加1,地址增加1。这背后的逻辑,说白了就是让指针能正确跳到下一个元素。
核心公式:ptr + n 的实际地址 = 当前地址 + n × sizeof(指向的类型)
int arr[] = {10, 20, 30, 40, 50};
int *p = arr; // p指向arr[0]
p++; // 现在p指向arr[1]
printf("%d\n", *p); // 输出20
p += 2; // 现在p指向arr[3]
printf("%d\n", *p); // 输出40
p--; // 现在p指向arr[2]
printf("%d\n", *p); // 输出30
我在项目中遇到过一个问题:有人用 void* 指针做算术运算,结果编译报错。为什么?因为 void 类型的大小是未知的,编译器不知道移动多少字节。嗯,这里要注意,void* 不能直接做加减运算,除非先强制转换成具体类型。
二、指针的自增与自减
++ 和 -- 操作符,在指针语境下,行为很微妙。尤其是和 * 操作符组合时,优先级问题经常让人头疼。
int arr[] = {1, 2, 3, 4, 5};
int *p = arr;
int a = *p++; // 等价于 *(p++),先取*p的值,然后p自增
// a = 1, p现在指向arr[1]
int b = (*p)++; // 先取*p的值,然后让这个值自增
// b = 2, arr[1]变成3
int c = *++p; // 等价于 *(++p),先p自增,再取*p的值
// c = 3, p现在指向arr[2]
我个人习惯,在复杂表达式中,宁愿加括号也不去记优先级。你想想看,代码是给人读的,清晰比炫技重要得多。
我曾经在维护一段老代码时,看到一行 while (*dst++ = *src++),当时觉得写得很帅。后来调试一个内存越界问题,发现就是这种紧凑写法导致边界条件没处理好。从那以后,我写指针操作时,宁可多写几行,也要保证逻辑一目了然。
三、指针的比较
指针之间可以用关系运算符比较:==、!=、<、>、<=、>=。但有个前提:只有指向同一个数组(或同一块连续内存)的指针,比较才有意义。
int arr[5] = {0};
int *p1 = &arr[1];
int *p2 = &arr[3];
if (p1 < p2) {
printf("p1在p2前面\n"); // 这个输出
}
// 下面这种比较,行为是未定义的
int a, b;
int *pa = &a;
int *pb = &b;
if (pa < pb) { // 未定义行为!
// 不要这样做
}
为什么不同对象的指针比较是未定义行为?因为内存布局是编译器决定的,不同变量可能在栈上相邻,也可能相隔很远。比较结果没有实际意义。
指针和 NULL 的比较是安全的,也是常用的。我习惯在释放内存后,把指针置为 NULL,这样后续判断就方便了。
int *p = malloc(sizeof(int) * 10);
if (p == NULL) {
// 处理分配失败
}
// ... 使用 ...
free(p);
p = NULL; // 好习惯
四、指针与数组名的关系
这是C语言里最容易混淆的地方。数组名,到底是不是指针?
答案是:数组名不是指针,但在大多数表达式中,它会退化为指向首元素的指针。
int arr[5] = {1, 2, 3, 4, 5};
printf("%p\n", arr); // 输出数组首地址
printf("%p\n", &arr[0]); // 和上面一样
printf("%zu\n", sizeof(arr)); // 输出20(5个int)
printf("%zu\n", sizeof(&arr[0])); // 输出8(指针大小,64位系统)
看到了吗?sizeof(arr) 得到的是整个数组的大小,而不是指针的大小。这说明数组名本身不是指针。但当你把 arr 赋值给指针时,它退化为指针。
一个小技巧:用 &arr 得到的是指向整个数组的指针,类型是 int (*)[5]。而 arr 退化为 int*。两者指向的地址相同,但类型不同,做加减运算时步长不同。
int arr[5] = {0};
int *p = arr; // p是int*,步长4字节
int (*pa)[5] = &arr; // pa是int(*)[5],步长20字节
p++; // 地址增加4
pa++; // 地址增加20
我在项目中见过一个bug:有人用 sizeof(arr) / sizeof(int) 计算数组长度,但 arr 已经作为函数参数传进去了。函数参数里的数组声明,实际上是个指针。结果算出来的长度永远是1或2(指针大小除以元素大小)。
我曾经在一个通信协议解析模块里,看到同事用 sizeof(buf) 来获取缓冲区大小,但 buf 是函数参数。结果缓冲区溢出,导致堆栈被破坏,查了两天才定位到问题。教训是:数组作为函数参数时,一定要把长度也传进去。
五、知识体系总览
下面这张图,把指针运算的核心逻辑串起来了。我建议你仔细看看,尤其是箭头指向的关系。
六、避坑总结
指针运算的坑,我踩过不少。这里列几个最常见的,你写代码时多留个心眼。
- 越界访问:指针加减后,一定要确保还在合法范围内。C语言不检查边界,越界了也不会报错,但后果很严重。
- 野指针:指针没有初始化,或者指向的内存已经释放。这种指针做运算,结果不可预测。
- 类型不匹配:
int*和char*混用,步长不同,容易算错地址。 - 数组名作为函数参数:记住,传进去的是指针,不是数组。用
sizeof会得到指针大小。
我个人习惯:每次做指针运算前,先问自己三个问题:指针指向哪里?移动后指向哪里?那个位置合法吗?养成这个习惯,能避免90%的指针相关bug。
指针运算,说白了就是地址的算术。但加上类型信息后,它变得既强大又危险。理解清楚每一步的语义,写出来的代码才能既高效又安全。