指针是 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,之后 *p 和 a 就是同一个东西。修改 *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* p和int *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) 要写 &a。scanf 需要知道 a 的地址,才能把读到的值存进去。
指针运算
指针不仅能存地址,还能做加减运算。但指针的加减和普通数字不一样,p + 1 不是地址加 1 个字节,而是跳过一个元素:
为什么 p + 1 跳一个元素而不是一个字节?因为编译器知道 p 指向的是 int(4 字节),所以 p + 1 自动跳过 4 字节到下一个 int。如果 p 是 char*,那 p + 1 就只跳 1 个字节。
类型决定了指针运算的步长,这就是声明指针时必须指定类型的原因。
指针与数组
在 C 语言中,数组名在大多数场景下会自动「退化」为指向第一个元素的指针。这就是为什么数组传参和指针传参效果一样:
记住两条规则:
arr[i]等价于*(arr + i),下标访问的本质是指针运算- 基本类型传值(副本),数组传地址(指针)。这也是
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**。下面的例子用到了 malloc 和 free,它们分别用于分配和释放内存,下一篇会详细讲,这里先关注二级指针的用法:
小结
指针的本质就是存地址的变量。掌握这张表,指针就不可怕了:
| 概念 | 说明 |
|---|---|
&a | 取 a 的地址 |
*p | 取 p 指向的值(解引用) |
int* p = &a | p 指向 a,*p 和 a 等价 |
p + 1 | 移动一个元素(不是一个字节) |
arr[i] ≡ *(arr+i) | 下标本质是指针运算 |
int* arr[5] | 指针的数组(数组里装指针) |
int (*p)[5] | 数组的指针(指针指向数组) |
int** pp | 指向指针的指针 |
下一篇我们来学内存管理,学习 malloc、free 这些和指针配套使用的内存操作函数。