指针基础(下):指针的运算、指针与数组、指针数组与数组指针

好,咱们接着聊指针。上一章我把指针的基本概念和声明方式讲清楚了,这一章咱们深入一点,看看指针到底能干什么「运算」,以及它和数组之间那些剪不断理还乱的关系。

说实话,指针的运算在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出来的堆内存)的指针做大小比较。虽然语法上允许,但结果依赖于内存布局,毫无可移植性。说白了,这种代码换个平台就翻车。

三、指针与数组的关系

数组名和指针之间到底是什么关系?这个问题我面试过很多人,能讲清楚的不多。

简单说:数组名在大多数表达式中会被隐式转换为指向首元素的指针。但有两个例外:

  1. 对数组名使用 sizeof 时,得到的是整个数组的大小
  2. 对数组名使用 & 时,得到的是指向整个数组的指针

看代码:

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先跟*结合,是指针

括号改变了结合顺序,就这么简单。

五、知识体系总览

下面这张图把本章的核心知识点串起来了,你可以对照着梳理一下:

指针基础(下)知识体系 指针运算 加减运算 比较运算 加n:地址 + n×sizeof(类型) 减n:地址 - n×sizeof(类型) == != < > 同一数组内比较 指针与数组 数组名退化 sizeof例外 arr 等价于 &arr[0] sizeof(arr) ≠ sizeof(指针) 指针数组 vs 数组指针 指针数组 数组指针 int *p[5] 存放指针的数组 int (*p)[5] 指向数组的指针 核心要点 指针加减的单位是「类型大小」,不是字节 数组名在表达式中退化为首元素指针 区分指针数组和数组指针:看括号位置

这张图把三个核心主题串在了一起。你对照着看,指针运算解决的是「怎么移动指针」的问题,指针与数组解决的是「数组名和指针怎么互相转换」的问题,而指针数组和数组指针解决的是「怎么声明和使用复杂指针类型」的问题。三者环环相扣。

好了,指针的运算和它与数组的关系就讲到这里。这些东西光看是记不住的,我建议你打开编译器,把上面的代码都敲一遍,改改参数看看结果。尤其是数组指针和指针数组的区别,亲手写几遍就永远不会搞混了。


公众号:蓝海资料掘金营,微信deep3321