指针

指针是 C 语言的灵魂,也是很多人学 C 最头疼的部分。什么 *&**int (*p)[5],看着就晕。

别怕,这篇从头到尾把指针讲透。理解了指针,后面看 C 代码就不会被那些符号吓到了。

指针到底是什么?

要理解指针,先要理解内存。

你可以把计算机内存想象成一排带编号的格子,每个格子能存一个字节的数据。这个编号就是内存地址

地址:  1000    1001    1002    1003    1004    1005 ...
内容:  [    ] [    ] [    ] [    ] [    ] [    ] ...

当你声明一个变量 int a = 42;,编译器会在内存中找一块空间(比如从地址 1000 开始的 4 个字节),把 42 存进去:

地址:  1000    1001    1002    1003
内容:  [        42(占 4 字节)       ]

       变量 a 的起始地址

指针就是一个存储内存地址的变量。普通变量存的是数据(比如 42),指针变量存的是地址(比如 1000)。

打个比方:变量是一栋房子,里面住着数据;地址是门牌号,告诉你房子在哪。指针就是一张写着门牌号的纸条。拿着纸条,你就能找到房子,读取或修改里面的东西。

声明与基本操作

指针的核心就两个操作符:

  • &(取地址):给一个变量,得到它的地址
  • *(解引用):给一个地址,得到那个地址里存的值

它们是一对反操作:& 从变量到地址,* 从地址到值。

运行上面的代码,你会看到地址的输出类似 0x7ffd5e8a3b2c 这样的格式。这个 0x 前缀表示十六进制数。计算机领域习惯用十六进制表示地址,因为它比十进制更紧凑,和二进制的转换也更方便。你不需要深入理解十六进制,只要知道它就是一个数字编号,代表内存中的一个位置就行。

一句话总结:int* p = &a;p 指向 a,之后 *pa 就是同一个东西。修改 *p 就是修改 a,读取 *p 就是读取 a

为什么指针要有类型?

你可能会想:指针不就是存个地址吗,为什么还要区分 int*char*double*

因为光有地址还不够,还得知道从这个地址开始读多少字节。内存就是一长串字节,地址告诉你「从哪里开始」,类型告诉你「读多长」:

  • int*:从这个地址开始,读 4 个字节,按 int 方式解读
  • char*:从这个地址开始,读 1 个字节,按 char 方式解读
  • double*:从这个地址开始,读 8 个字节,按 double 方式解读
地址:  1000    1001    1002    1003    1004    1005    1006    1007
内容:  [                    一块连续的内存                        ]

int* p = 1000;     // *p 读 1000~1003 这 4 个字节,解读为 int
char* q = 1000;    // *q 只读 1000 这 1 个字节,解读为 char
double* r = 1000;  // *r 读 1000~1007 这 8 个字节,解读为 double

同一个地址,不同类型的指针读出来的结果完全不同。所以地址 + 类型 = 完整的数据访问信息,缺一不可。这也是后面讲指针运算时,p + 1 跳过的字节数取决于类型的原因。

关于声明写法int* pint *p 完全一样,只是风格不同。前者强调「p 的类型是 int 指针」,后者强调「*p 是一个 int」。本教程统一用 int* p 的写法。

NULL 指针

NULL 表示一个不指向任何有效地址的指针。就像一张空白纸条,上面没有门牌号。

直接对 NULL 指针解引用会导致程序崩溃(段错误),所以使用指针前最好先检查:

int* p = NULL;

// 直接 *p 会崩溃!使用前先检查
if (p != NULL) {
    printf("%d\n", *p);
}

指针传参

上一篇说过,C 语言函数是值传递,函数内修改参数不影响原变量。但有了指针就不同了。把变量的地址传进去,函数就能通过地址直接修改原变量:

tryChange(a) 传的是 a 的值(10),函数改的是副本;realChange(&a) 传的是 a 的地址,函数通过 *p 直接改了 a 本身。

这也解释了为什么 scanf("%d", &a) 要写 &ascanf 需要知道 a 的地址,才能把读到的值存进去。

指针运算

指针不仅能存地址,还能做加减运算。但指针的加减和普通数字不一样,p + 1 不是地址加 1 个字节,而是跳过一个元素

为什么 p + 1 跳一个元素而不是一个字节?因为编译器知道 p 指向的是 int(4 字节),所以 p + 1 自动跳过 4 字节到下一个 int。如果 pchar*,那 p + 1 就只跳 1 个字节。

类型决定了指针运算的步长,这就是声明指针时必须指定类型的原因。

指针与数组

在 C 语言中,数组名在大多数场景下会自动「退化」为指向第一个元素的指针。这就是为什么数组传参和指针传参效果一样:

记住两条规则:

  1. arr[i] 等价于 *(arr + i),下标访问的本质是指针运算
  2. 基本类型传值(副本),数组传地址(指针)。这也是 scanf%d 要加 &,读 %s 不用加 & 的原因,因为字符串是数组,本身就是地址

指针的数组 vs 数组的指针

这是 C 语言里最经典的「绕晕人」问题。两个长得很像的声明,含义完全不同:

int* arr[5];    // 指针的数组:一个数组,里面放了 5 个指针
int (*p)[5];    // 数组的指针:一个指针,指向一个含 5 个 int 的数组

指针的数组:int* arr[5]

arr 是一个数组,有 5 个元素,每个元素都是 int*(指向 int 的指针)。

怎么理解?看优先级:[] 的优先级比 * 高,所以 arr 先和 [5] 结合,变成「arr 是一个数组」;剩下的 int* 说明每个元素是 int 指针。

char* names[] 就是最典型的指针数组,数组里每个元素是一个 char*,指向一个字符串。

数组的指针:int (*p)[5]

p 是一个指针,它指向一个含 5 个 int 的数组。

怎么理解?() 强制 p 先和 * 结合,变成「p 是一个指针」;剩下的 int [5] 说明指向的东西是一个 int[5] 数组。

一句话区分

声明含义记忆方式
int* arr[5]指针的数组(数组里放指针)[] 优先级高,arr 先和 [5] 结合 → 是数组
int (*p)[5]数组的指针(指针指向数组)() 强制 p 先和 * 结合 → 是指针

诀窍:从变量名出发,看它先和谁结合。先碰到 [] 就是数组,先碰到 * 就是指针。

指向指针的指针

既然指针是个变量,当然也可以有指针指向它。int** 就是「指向 int* 的指针」:

int** 最常见的两个用途:

第一个是动态二维数组int** grid 是一个指针数组,每个 grid[i] 是一个 int* 指向一行数据。下一篇内存管理会详细讲。

第二个是在函数里修改指针本身。想在函数里修改 int,传 int*;想在函数里修改 int*,就传 int**。下面的例子用到了 mallocfree,它们分别用于分配和释放内存,下一篇会详细讲,这里先关注二级指针的用法:

小结

指针的本质就是存地址的变量。掌握这张表,指针就不可怕了:

概念说明
&a取 a 的地址
*p取 p 指向的值(解引用)
int* p = &ap 指向 a,*pa 等价
p + 1移动一个元素(不是一个字节)
arr[i]*(arr+i)下标本质是指针运算
int* arr[5]指针的数组(数组里装指针)
int (*p)[5]数组的指针(指针指向数组)
int** pp指向指针的指针

下一篇我们来学内存管理,学习 mallocfree 这些和指针配套使用的内存操作函数。