4. 指针运算:指针的算术运算、比较与数组访问

指针到底能不能做加减法?很多人刚学的时候会懵。我当年也犯过糊涂——指针不就是个地址吗?地址加1,那不就是下一个地址?嗯,理论上没错,但实际没那么简单。

指针的运算,说白了就是「地址的偏移」。但偏移多少,取决于指针的类型。这一点,是理解指针运算的核心。

4.1 指针的算术运算:++、--、+、-

指针支持四种算术运算:++--+-。但注意,指针不支持乘法和除法。为什么?因为地址乘2没有物理意义。

4.1.1 指针加/减整数

假设有一个 int *p,指向某个 int 变量。p + 1 并不是地址加1,而是加 sizeof(int) 个字节。在我的机器上,sizeof(int) = 4,所以 p + 1 实际地址增加了4个字节。

#include <stdio.h>

int main() {
    int arr[] = {10, 20, 30, 40, 50};
    int *p = arr;  // 指向 arr[0]

    printf("p   = %p, *p   = %d\n", (void*)p, *p);
    printf("p+1 = %p, *(p+1) = %d\n", (void*)(p+1), *(p+1));
    printf("p+2 = %p, *(p+2) = %d\n", (void*)(p+2), *(p+2));

    return 0;
}

输出结果中,地址差正好是4字节。这就是指针运算的「刻度」——刻度大小 = 指针指向的类型大小

核心公式指针 + n 等价于 地址 + n * sizeof(指针指向的类型)

4.1.2 自增自减:++ 和 --

p++++p 的区别,跟普通变量一样。但用在指针上,有个经典陷阱。

int arr[] = {1, 2, 3, 4, 5};
int *p = arr;

// 后置++
int val1 = *p++;   // 先取 *p (即 arr[0]),然后 p 指向 arr[1]
// 前置++
int val2 = *++p;   // 先 p 指向 arr[2],然后取 *p

printf("val1 = %d, val2 = %d\n", val1, val2);

我曾经在项目中写过一个循环,用 *p++ 遍历数组,结果因为优先级问题,少处理了一个元素。排查了半天才发现是 ++* 的优先级搞混了。记住:后置++优先级高于 *,但先取值后自增

注意:指针自增自减只能用于指向数组元素的指针。如果指针指向单个变量,自增后指向的位置是未定义的,访问它会导致未定义行为。

4.1.3 指针减法

两个指针相减,得到的是元素个数,不是字节数。前提是这两个指针指向同一个数组。

int arr[5] = {10, 20, 30, 40, 50};
int *p1 = &arr[1];  // 指向 20
int *p2 = &arr[4];  // 指向 50

ptrdiff_t diff = p2 - p1;  // 结果是 3,不是 12
printf("p2 - p1 = %td\n", diff);  // 输出 3

ptrdiff_t<stddef.h> 中定义的有符号整数类型,专门用来存储指针减法的结果。我建议你养成用 ptrdiff_t 的习惯,别用 int,因为指针差值可能超过 int 的范围。

4.2 指针比较

指针可以用关系运算符比较:==!=<><=>=。但有个前提——只有指向同一个数组(或同一个对象)的指针才能比较

4.2.1 相等与不等比较

==!= 用来判断两个指针是否指向同一个地址。这在检查指针是否为 NULL 时很常用。

int *p = NULL;
if (p == NULL) {
    printf("指针为空,不能解引用\n");
}

4.2.2 大小比较

指向同一个数组的指针,可以用 <> 判断谁在前谁在后。这在实现二分查找或边界检查时非常有用。

int arr[5] = {1, 2, 3, 4, 5};
int *start = arr;
int *end = arr + 5;  // 指向数组末尾的下一个位置

// 检查指针是否在合法范围内
int *p = arr + 2;
if (p >= start && p < end) {
    printf("指针在数组范围内\n");
}

小技巧arr + 5 指向数组最后一个元素的下一个位置,这个地址是合法的(可以比较),但不能解引用。这种「尾后指针」在 C 标准库中很常见,比如 qsort 的参数就用了这种设计。

4.3 指针与数组元素访问

数组名在大多数情况下会被隐式转换为指向首元素的指针。这就是为什么 arr[i]*(arr + i) 完全等价。

4.3.1 下标运算的本质

编译器看到 arr[i],会把它翻译成 *(arr + i)。所以 arr[i]i[arr] 是等价的——虽然没人会写后者。

int arr[3] = {100, 200, 300};

printf("arr[1]   = %d\n", arr[1]);     // 200
printf("*(arr+1) = %d\n", *(arr+1));   // 200
printf("1[arr]   = %d\n", 1[arr]);     // 200,语法上合法但别这么写

我记得刚入行时,看到同事代码里写了 i[arr],还以为是 bug。后来查了标准才知道,a[b] 就是 *(a + b),加法可交换,所以 b[a] 也成立。但为了可读性,千万别这么干。

4.3.2 用指针遍历数组

用指针遍历数组,比用下标更灵活,在某些场景下性能也更好。

int arr[5] = {2, 4, 6, 8, 10};
int *p;

// 方式一:用下标
for (int i = 0; i < 5; i++) {
    printf("%d ", arr[i]);
}
printf("\n");

// 方式二:用指针
for (p = arr; p < arr + 5; p++) {
    printf("%d ", *p);
}
printf("\n");

两种方式结果一样。但指针方式有个好处:你可以在循环中修改指针指向,而不用维护一个额外的索引变量。我在处理环形缓冲区时,就经常用指针方式,配合取模运算,代码简洁很多。

4.3.3 指针与多维数组

二维数组的指针访问,稍微绕一点。但记住一点就行:二维数组名是指向第一行的指针,行指针的类型是 int (*)[N]

int matrix[3][4] = {
    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12}
};

// 用指针访问 matrix[1][2]
int val = *(*(matrix + 1) + 2);  // 结果是 7
printf("matrix[1][2] = %d\n", val);

拆开来看:matrix + 1 指向第1行(从0开始),*(matrix + 1) 得到第1行的首地址(类型是 int*),再加2就是第1行第2个元素的地址,再解引用得到值7。

总结一下:指针运算的核心就是「类型决定步长」。无论是加减整数、自增自减、还是指针相减,都离不开这个原则。数组下标访问本质上就是指针运算的语法糖。

4.4 知识体系图

下面这张图帮你理清本章的知识脉络:

指针运算知识体系 指针运算 算术运算 比较运算 数组元素访问 指针 + n / 指针 - n p++ / ++p / p-- / --p 指针 - 指针 = 元素个数 == / != 判断是否指向同一地址 < / > / <= / >= 判断前后关系 必须指向同一数组或对象 arr[i] 等价于 *(arr + i) 指针遍历 vs 下标遍历 多维数组的指针访问 核心:类型决定步长,步长 = sizeof(指向的类型)

4.5 避坑指南

指针运算的坑,我踩过不少。挑几个典型的说说:

  • 越界访问:指针加减后,如果超出了数组范围,解引用就是未定义行为。我曾经在嵌入式项目中,因为指针越界写坏了相邻变量的值,导致系统随机死机。排查了两天才找到原因。
  • 空指针运算:对 NULL 指针做加减运算是未定义行为。虽然有些编译器不报错,但千万别依赖。
  • 不同类型指针相减:两个指向不同类型的指针相减,编译器会报错。即使强制转换,结果也毫无意义。
  • void* 指针的运算:标准 C 不允许对 void* 做算术运算,因为不知道步长。GCC 有扩展允许,但为了可移植性,建议先转成 char* 再运算。

我的习惯:写指针运算时,我会在注释里标明指针当前指向的位置。比如 p = arr + 3; // p 指向 arr[3]。这样半年后回来看代码,不用重新推导一遍。

指针运算其实不复杂,核心就一句话:类型决定步长。把这个原则刻在脑子里,指针的加减乘除(哦不对,没有乘除)就都通了。


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