指针让 C 语言能够更高效地操作计算机底层硬件。由于计算机硬件的操作很大程度上依赖于地址,指针便提供了一种对地址进行操作的方法。在某种意义上,指针是 C 语言的精髓,理解它至关重要。
对很多 C 语言初学者来说,指针可能显得有些难以理解,一不小心就容易在指针的指向关系里绕晕。这篇文章旨在对指针做一些总结,分享一些个人见解,希望能帮助你理清思路。如果你对指针背后的机制感兴趣,欢迎到云栈社区的C/C++板块与更多开发者交流。
一、指针的基本介绍
在程序中,当我们声明一个变量(例如 int a = 1;),将数据 1 存储到变量 a 中时,计算机内部会把这个数据保存到内存(RAM)里。数据存放到某个位置,就必然会涉及地址。
这就像你网购的快递:快递到了需要存放在某个驿站。你的快递就是数据,驿站就是变量,而这个驿站必须有一个明确的地址,否则全国那么多驿站,你怎么知道自己的快递在哪一个?
这样一来,地址的概念应该清晰了吧?
现在,请思考一下:地址(例如 0x00000001)本身不也是一个数据吗?那么,是否也可以用一个变量来存储“地址”这个数据呢?答案是肯定的,这个变量就是指针。指针是一种数据类型,用于存储另一个变量的内存地址。也就是说,指针的内容就是另一个变量的地址。
关键一点:指针本身也是一个变量,所以指针变量也有自己的地址。它的特殊之处仅仅在于,它存放的是另一个变量的地址。理解了这句话,就抓住了指针的核心。
前面提到指针是一种数据类型。为了方便,我们约定在这种类型后面加上 * 号来表示该类型的指针。于是就有了 char 型指针(char *)、double 型指针(double *)和 int 型指针(int *)等等。
试着敲一下下面这段代码,可以加深对指针的认识:
int a = 1; // 定义一个int型变量
int *p = &a; // 定义一个int型指针p,&a表示对a取地址,指针p的内容是a的地址
// int *p; p = &a; // 第二行也可以这样写,意思一样
printf("%p\n", &a); // 打印a的地址
printf("%p\n", p); // 打印指针p指向的地址
// %p是打印地址(指针地址),是十六进制的形式
C/C++ 中规定了 * 操作符来从对应指针类型存放的地址中取出相应的数据。如果再定义一个变量 int b = *p;,由于指针 p 存储了 a 的地址,*p 就是取出 a 的值,于是 b 的值就变成了 1。* 操作也被称为解引用。
二、指针的相关操作(运算)
算数运算:+、-、++、--
指针的运算特别容易搞错,千万不能以为和普通类型(比如 int 型数据)的运算一样。
指针的加减运算:
- 指针+1 / 指针-1,加/减的是整个指针类型的长度。与其说是指针的加减法,不如说成指针的偏移更合适。接下来看一个非常明显的例子:
char a[5] = {1, 2, 3, 4, 5}; // 定义一个char型数组,这里的a实质上是一个指针,指向这个数组的首元素a[0]的指针
char *p = a;
printf(“%d\n”, *p); // 输出1 --> a[0]
printf(“%d\n”, *(p + 1)); // 输出2 --> a[1]
......
看输出的结果很容易看出规律:指针 p 指向 a[0],特别注意 p+1 后,指针指向了 a[1],所以 *(p+1) = a[1] = 2。请注意,这里并不是 *(p+1) = a[0] + 1 = 2,虽然这个巧合让两个答案一样。但如果把数组内容换一下,结果就不会相同了。如果是 (*p) + 1,那么就是 (*p) + 1 = a[0] + 1 = 2。同理,可以尝试 p+2、p+3……
你还可以尝试定义其他类型的数组(比如 int 型:int a[5] = {1, 2, 3, 4, 5};),看看是不是遵循同样的规律。这样你就能明白指针加减的是该指针类型的长度,也就是在进行偏移。甚至可以尝试定义结构体数组,将会有更深的理解。
减法就不用多说了。理解了指针 p+1/p-1,那么指针 p++/p-- 其实是一样的,都是偏移。
三、多级指针
说起多级指针,当年我大一学 C 语言时,学到二级指针就已经被绕晕了。如果当时谁给我写个 int ********p; 出来,我估计会直接崩溃到放弃。
我们先从二级指针说起。前面讲过,指针也是一种数据类型,是一种变量,也有自己的地址。既然有地址,而指针就是存放另一个变量地址的,那为什么不能再用一个指针来存放这个指针的地址呢,对吧?所以就有了二级指针,也就是指向指针的指针。
OK,来点生活化的比喻。快递柜大家都用过吧?快递小哥给你发一个取件码,你就能拿到快递。

这里的每一个柜子就是一块内存,取件码就是地址,柜子里的快递就是存储在内存中的内容或数据。
假如快递小哥把你的快递放到“058柜子”,然后给你发这个柜子的取件码,那么你输入取件码就可以取到快递。

如果快递小哥逗你一下,故意给你发“057柜子”的取件码,然后在“057柜子”里放一张纸条,上面写着“快递在058柜子”。这时候你肯定是按照纸条指示,从“058柜子”里拿到快递。
这里的“057柜子”就是指针,指针里面存放着另一个变量(058柜子)的地址。

如果快递小哥给你发“056柜子”的取件码,在“056柜子”里放一张纸条写:“快递在057柜子”;又在“057柜子”里放一张纸条写:“快递在058柜子”。
这里的“056柜子”就是二级指针,“057柜子”就是一级指针,“058柜子”就是指针所指向的普通变量。

现在明白二级指针了吧?那么,N 级指针也就那么回事,也就是指向指针的指针的指针的指针……,是不是非常简单!
int a = 1;
int *p = &a;
int **pp = &p; // 二级指针pp存放指针p的地址,即二级指针pp指向指针p
int ***ppp = &pp; // 三级指针ppp存放二级指针pp的地址,即三级指针ppp指向二级指针pp
......
总之,如果一块内存存放的是另一个变量的地址,那么它就叫做指针。一块内存要么存放实际内容/数据,要么存放的是另一个变量的地址。事情确实就像刚才说的那么简单。
【总结两点】:
- 指针本身也是一个变量,也有自己的地址,需要内存来存储。
- 指针存放的是它所指向的变量的地址,而这个所指向的变量本身也可以是一个指针。
【特别注意】:面试可能会被问到指针的大小
- 指针的大小跟指针是什么类型没有任何关系。
- 在 32 位系统系统中,所有的指针大小都是 4 个字节。原因是 32 位系统上所有变量的地址都是 32 位的(即 4 字节),而指针正是用来存储地址的。
最后,大家要明白一个概念:其实并没有什么“多级指针”这种东西,多级指针就是个指针。“多级指针”这个称呼,只是为了我们方便表达而取的逻辑名称。
四、多维数组与指针
二维数组其实可以和二级指针用相似的方法来理解。
比如 a[3][2],我们可以把它理解成一个一维数组来看待。这个一维数组里面有三个元素,只不过它的每个元素又是一个一维数组而已。
懂了上面这段话,二维数组就很好理解了。
前面我们已经知道,在一维数组 a[3] 中,a 实质上是一个指针,指向这个数组的首元素 a[0]:
int a[3] = {1, 2, 3};
// a[0] --> *a
printf(“%d\n”, *a); // 打印 1 --> a[0] 的值
// a[1] --> *(a + 1)
printf(“%d\n”, *(a + 1)); // 打印 2 --> a[1] 的值
// a[2] --> *(a + 2)
printf(“%d\n”, *(a + 2)); // 打印 3 --> a[2] 的值
那么,把二维数组 a[3][2] 当成一个特殊的一维数组来看,是不是可以得出:
int a[3][2] = {{1, 2}, {3, 4}, {5, 6}};
// a[0][0] --> (*a)[0]
printf(“%d\n”, (*a)[0]); // 打印 1 --> a[0][0] 的值
// a[1][0] --> (*(a + 1))[0]
printf(“%d\n”, (*(a + 1))[0]); // 打印 3 --> a[1][0] 的值
// a[2][0] --> (*(a + 2))[0]
printf(“%d\n”, (*(a + 2))[0]); // 打印 5 --> a[2][0] 的值
// a[2][1] --> (*(a + 2))[1]
printf(“%d\n”, (*(a + 2))[1]); // 打印 6 --> a[2][1] 的值
// ..... 二维数组其它元素类似都可以输出
结论一:a[m][n] 等价于 (*(a + m))[n] --> 这就是一个数组指针(后面会提到)。
基于前面指针和数组的变换规则,可以继续推导:
int a[3][2] = {{1, 2}, {3, 4}, {5, 6}};
// a[0][0] --> (*a)[0] --> *(*a + 0) --> 把 *a 当成整体
printf(“%d\n”, *(*a)); // 打印 1 --> a[0][0] 的值
// a[1][0] --> (*(a + 1))[0] --> *(*(a + 1) + 0)
printf(“%d\n”, *(*(a + 1))); // 打印 3 --> a[1][0] 的值
// a[2][0] --> (*(a + 2))[0] --> *(*(a + 2) + 0)
printf(“%d\n”, *(*(a + 2))); // 打印 5 --> a[2][0] 的值
// a[2][1] --> (*(a + 2))[1] --> *(*(a + 2) + 1)
printf(“%d\n”, *(*(a + 2) + 1)); // 打印 6 --> a[2][1] 的值
// ..... 二维数组其它元素类似都可以输出
结论二:a[m][n] 等价于 *(*(a + m) + n)。
五、数组指针与指针数组
-
数组指针:指针在后,说明它就是个指针。所以数组指针指向的是数组,相当于一次声明了一个指针。从前面的知识我们已经知道,二维数组 a[3][2] 中,a 实质上就是一个数组指针。
公式:指向的那个数组的元素类型 (*指针名字)[指向的数组的元素个数]
-
指针数组:数组在后,说明它就是个数组。字符数组是什么?就是存放字符的数组。那么指针数组就是存放指针类型的数组,相当于一次声明了多个指针。
公式:数组元素的类型 数组名字[数组元素个数]
char *a[3] = {“red”, “green”, “blue”};
char **pp = a; //定义二级指针pp, a本质上相当于二级指针
printf(“%s\n”, pp[0]); // 打印 red
printf(“%s\n”, pp[1]); // 打印 green
printf(“%s\n”, pp[2]); // 打印 blue
直观上区分数组指针和指针数组的方法:
由于数组下标操作符 [] 的优先级比解引用操作符 * 高,所以声明数组指针时,指针名和 * 需要用圆括号括起来。因此,看看指针名有没有被圆括号括起来,就可以区分开两者。
六、其他与总结
关于指针,想写的内容还有很多,其实这只是开了个头。比如:野指针、函数指针、函数参数传递方式、const 修饰指针、动态内存分配(malloc 和 free)、堆与栈、内存泄露……这些内容以后有机会再慢慢补齐。
指针在数据结构中的链表使用得比较多,多写一些链表的操作会对理解指针很有帮助,例如链表节点的增、删、改、查,以及单向链表、双向链表、双向循环链表、内核链表等等。

希望这篇关于指针的梳理能对你有所帮助。理解指针的关键在于多思考、多实践,亲手写代码去验证每一个概念。当你把内存地址、数据存储和指针偏移这些基础概念印在脑子里时,指针就不再是拦路虎了。