指针基础(下):指针的运算、指针与数组、指针数组与数组指针
好,咱们接着聊指针。上一章我把指针的基本概念和声明方式讲清楚了,这一章咱们深入一点,看看指针到底能干什么「运算」,以及它和数组之间那些剪不断理还乱的关系。
说实话,指针的运算在C语言里是个很微妙的东西。它不像普通整数那样加减乘除随便来,它有自己的一套规矩。我当年刚学的时候,就因为在指针运算上栽过跟头——一个段错误,查了整整一个下午。
一、指针的加减运算
指针能加能减,但它的加减单位不是字节,而是「指向的类型的大小」。这一点太重要了,我再说一遍:指针加1,不是地址加1,而是加一个sizeof(类型)。
核心规则:
- 指针 + n:地址增加 n × sizeof(指向类型) 字节
- 指针 - n:地址减少 n × sizeof(指向类型) 字节
- 指针 - 指针:结果为两个指针之间的元素个数(不是字节数)
看个例子你就明白了:
int arr[] = {10, 20, 30, 40, 50};
int *p = arr; // p指向arr[0]
printf("p = %p\n", p); // 假设 0x1000
printf("p+1 = %p\n", p+1); // 0x1004(int占4字节)
printf("p+2 = %p\n", p+2); // 0x1008
printf("*p = %d\n", *p); // 10
printf("*(p+1) = %d\n", *(p+1)); // 20
printf("*(p+2) = %d\n", *(p+2)); // 30
看到了吗?p+1跳过了4个字节,直接到了下一个int。这就是指针运算的精髓——它自动帮你做了类型大小的换算。
我个人习惯:在遍历数组时,我更喜欢用指针而不是下标。因为指针运算在底层就是一条「地址+偏移」指令,比下标运算少一次乘法。虽然现代编译器优化得差不多了,但这个习惯我一直保留着。
指针相减:算的是距离
两个指向同一数组的指针相减,结果是一个ptrdiff_t类型的整数,表示它们之间隔了多少个元素。
int arr[5] = {1,2,3,4,5};
int *p1 = &arr[1]; // 指向2
int *p2 = &arr[4]; // 指向5
ptrdiff_t diff = p2 - p1;
printf("相差 %td 个元素\n", diff); // 输出3
嗯,这里要注意:只有指向同一数组(或同一块连续内存)的两个指针才能相减。指向不同数组的指针相减,行为是未定义的。我曾经在代码审查时见过有人这么写,结果在不同平台上跑出不同的值,排查起来非常痛苦。
二、指针的比较运算
指针也能比较大小。你可以用 ==、!=、<、>、<=、>= 来比较两个指针。
比较的规则很简单:
- 相等判断:两个指针指向同一个地址时相等
- 大小判断:地址值大的指针「大于」地址值小的指针
实际开发中,指针比较最常见的场景就是遍历数组:
int arr[10];
int *start = arr;
int *end = arr + 10; // 指向数组末尾的下一个位置
while (start < end) {
*start = 0; // 清零
start++;
}
我曾经踩过的坑:不要拿指向不同内存块(比如两个不同malloc出来的堆内存)的指针做大小比较。虽然语法上允许,但结果依赖于内存布局,毫无可移植性。说白了,这种代码换个平台就翻车。
三、指针与数组的关系
数组名和指针之间到底是什么关系?这个问题我面试过很多人,能讲清楚的不多。
简单说:数组名在大多数表达式中会被隐式转换为指向首元素的指针。但有两个例外:
- 对数组名使用 sizeof 时,得到的是整个数组的大小
- 对数组名使用 & 时,得到的是指向整个数组的指针
看代码:
int arr[5] = {1,2,3,4,5};
printf("sizeof(arr) = %zu\n", sizeof(arr)); // 20(5*4)
printf("sizeof(&arr[0]) = %zu\n", sizeof(&arr[0])); // 8(指针大小)
int *p = arr; // p指向arr[0]
int (*pa)[5] = &arr; // pa指向整个数组(数组指针)
printf("p+1 偏移 %ld 字节\n", (long)((char*)(p+1) - (char*)p)); // 4
printf("pa+1 偏移 %ld 字节\n", (long)((char*)(pa+1) - (char*)pa)); // 20
看到了吗?p+1只跳过一个int,而pa+1跳过了整个数组。这就是「指向元素的指针」和「指向数组的指针」的区别。
一句话总结:arr 是数组名,它「退化」成指针时指向 arr[0];&arr 是取整个数组的地址,类型是 int(*)[5]。
四、指针数组与数组指针
这两个概念太容易混淆了。我教你一个口诀:「先看右,再看左」。
指针数组:存放指针的数组
定义:int *p[5];
怎么读?先看p,它右边是[5],所以p是一个数组,有5个元素。再看左边,每个元素是 int* 类型。所以p是一个「存放int指针的数组」。
int a = 1, b = 2, c = 3;
int *p[3] = {&a, &b, &c};
for (int i = 0; i < 3; i++) {
printf("%d ", *p[i]); // 输出 1 2 3
}
指针数组在实际项目中用得很多。比如main函数的参数 char *argv[] 就是一个指针数组,每个元素指向一个命令行参数字符串。
数组指针:指向数组的指针
定义:int (*p)[5];
怎么读?先看p,它右边是),所以先看左边,*p 说明p是一个指针。再看右边,[5]说明这个指针指向一个包含5个int的数组。所以p是一个「指向长度为5的int数组的指针」。
int arr[2][3] = {{1,2,3}, {4,5,6}};
int (*p)[3] = arr; // p指向第一行(3个int)
for (int i = 0; i < 2; i++) {
for (int j = 0; j < 3; j++) {
printf("%d ", p[i][j]); // 用指针访问二维数组
}
printf("\n");
}
数组指针最常见的用途就是处理二维数组。你想想看,如果你想把一个二维数组传给函数,参数类型就得写成数组指针的形式。
我建议你这样记:
- 指针数组:
int *p[5]—— p先跟[5]结合,是数组 - 数组指针:
int (*p)[5]—— p先跟*结合,是指针
括号改变了结合顺序,就这么简单。
五、知识体系总览
下面这张图把本章的核心知识点串起来了,你可以对照着梳理一下:
这张图把三个核心主题串在了一起。你对照着看,指针运算解决的是「怎么移动指针」的问题,指针与数组解决的是「数组名和指针怎么互相转换」的问题,而指针数组和数组指针解决的是「怎么声明和使用复杂指针类型」的问题。三者环环相扣。
好了,指针的运算和它与数组的关系就讲到这里。这些东西光看是记不住的,我建议你打开编译器,把上面的代码都敲一遍,改改参数看看结果。尤其是数组指针和指针数组的区别,亲手写几遍就永远不会搞混了。