提到“深入理解指针(四)”,老读者应该都知道,这个系列我一直在掰开揉碎讲指针。翻了一下之前的留言,前三篇聊完基础类型、数组和函数传参之后,被问得最多的问题集中在几个地方:指针和数组到底怎么纠缠的、二级指针到底干嘛用的、const 加在指针上怎么一会这里一会那里、函数指针那堆语法怎么写才不乱。这篇就把这些硬骨头一次性啃干净。讲的内容主要面向已经学过 C/C++ 基本语法、正在被指针折磨的初学者,也适合马上要面试、想把指针这块彻底捋清楚的求职者。咱们不整虚的,全是代码和内存图,把每一步都拆开看。
1. 指针数组和数组指针:名字只差一点,语义天差地别
1.1 两句话分清类型本质
先说结论:指针数组是数组,数组指针是指针。这俩概念在初学阶段极其容易搞混,很多人背了又忘、忘了又背,本质原因是不清楚运算符优先级。
看两行声明:
int *p1[5]; int (*p2)[5];第一行,因为下标运算符[]的优先级高于解引用运算符*,所以p1先和[5]结合,说明p1是一个数组,数组里有 5 个元素,每个元素是int *类型,这就是指针数组。
第二行,加了括号之后(*p2)先结合,说明p2是指针,指向一个长度为 5 的int数组,这就是数组指针,也叫行指针。
判断技巧其实就一句话:先看变量名先跟谁结合,先跟[ ]结合就是数组,先跟*结合就是指针。
用表格列一下,方便对照记忆:
| 声明 | 本质 | 含义 |
|---|---|---|
int *p[5] | 数组 | 存储 5 个int*指针的数组 |
int (*p)[5] | 指针 | 指向含 5 个int元素的数组的指针 |
int *(*p)[5] | 指针 | 指向含 5 个int*指针的数组的指针 |
你可能会问,数组指针这东西平时用得到吗?用得到,而且主要是配合二维数组用。C 语言里二维数组在内存里是连续存放的,所谓“二维”,其实是编译器帮你做了一层行列的逻辑抽象,底层还是一段连续内存。数组指针就是用来“按行”去操作这种内存结构的。
1.2 二维数组的“名字”到底代表什么
很多人学二维数组的时候,根本搞不清楚arr、arr[0]、&arr[0][0]这三个东西的区别,其实吃了不少亏。
假设有这样的定义:
int arr[3][4];arr的类型是int (*)[4],它是一个数组指针,指向第一行这个“长度为 4 的 int 数组”。arr + 1指向的是第二行,也就是说arr + 1在地址上比arr大了 16 字节(假设 int 是 4 字节)。
arr[0]的类型是int *,它指向第一行的第一个元素。arr[0] + 1指向的是arr[0][1],地址上只相差 4 字节。
&arr[0][0]是int *,就是第一个元素的具体地址。
这三个值在数值上可能一样,但在类型上完全不一样,而类型决定步长。我用一段代码实测一下:
#include <stdio.h> int main(void) { int arr[3][4] = {0}; printf("arr : %p\n", (void*)arr); printf("arr + 1 : %p\n", (void*)(arr + 1)); printf("arr[0] : %p\n", (void*)arr[0]); printf("arr[0] + 1 : %p\n", (void*)(arr[0] + 1)); printf("&arr[0][0] : %p\n", (void*)&arr[0][0]); printf("&arr[0][0] + 1 : %p\n", (void*)&arr[0][0] + 1); return 0; }在我机器上输出大概是:
arr : 0x7ffc8b2a49e0 arr + 1 : 0x7ffc8b2a49f0 arr[0] : 0x7ffc8b2a49e0 arr[0] + 1 : 0x7ffc8b2a49e4 &arr[0][0] : 0x7ffc8b2a49e0 &arr[0][0] + 1 : 0x7ffc8b2a49e4arr + 1跳了 16 字节(一行),arr[0] + 1只跳了 4 字节(一个元素)。这就是为什么遍历二维数组时,用数组指针最稳:
void print_matrix(int (*p)[4], int rows) { for (int i = 0; i < rows; i++) { for (int j = 0; j < 4; j++) { printf("%d ", p[i][j]); } printf("\n"); } }这里p[i][j]等价于*(*(p + i) + j),先按行跳,再按列解引用。
注意:函数形参写成
int (*p)[4]的时候,4不能省略。因为编译器必须知道每一行有多长,才能算出p + 1该跳多远。这也是为什么二维数组传参时“第二维必须写清楚”的根本原因。
1.3 指针数组存字符串的经典写法
指针数组最常见的实战场景就是存字符串集合。比如菜单项:
const char *menu[] = { "open file", "save file", "close file", "quit" };这里的menu是数组,每个元素是const char *,每个指针指向一个字符串字面量。为什么不直接用二维字符数组char menu[4][32]?因为字符串长度不固定,用二维数组会造成空间浪费——每行都得按最长字符串预留内存;用指针数组则只需要存储 4 个指针,每个指针指向各自的字面量,紧凑得多。
操作也很方便,遍历就是遍历指针数组:
for (int i = 0; i < 4; i++) { printf("%s\n", menu[i]); }这里有个隐藏的坑:字符串字面量在 C/C++ 中通常存放在只读区,如果写成char *menu[]且尝试修改menu[0][0],行为是未定义的,很多编译器会直接崩溃。所以建议声明成const char *,从类型层面就提示“这些字符串不能改”。
2. 指针的指针:二级指针到底解决什么问题
2.1 从内存模型看二级指针
前面讲的一切都是围绕“指针存的是地址”这个核心。那二级指针就是:指针变量自己也有地址,这个地址也可以被存起来。
看这段代码:
int a = 5; int *p = &a; int **pp = &p;内存里大概是这个画面:
变量 a : 地址 0x100 内容 5 变量 p : 地址 0x200 内容 0x100 变量 pp: 地址 0x300 内容 0x200p里存的是a的地址,pp里存的是p的地址。所以:
*p得到a,等于 5*pp得到p(就是那个指针),打印出来是0x100**pp得到a,等于 5
很多人看到**pp就晕,其实只要心里画一遍这条链:pp -> p -> a,一层层解引用就出来了。指针不管加多少个*,本质都是“存上一级的东西的地址”。
2.2 在函数里修改指针本身:这是二级指针的核心价值
一级指针可以在函数里修改它指向的那个变量的值,但改不了指针变量本身。我看过太多人写出这种“看起来对,运行时不对”的代码:
void init_ptr(int *p) { p = malloc(sizeof(int) * 10); } int main(void) { int *arr = NULL; init_ptr(arr); // arr 依然是 NULL arr[0] = 42; // 崩 return 0; }为什么崩?因为参数传递是“按值传递”,init_ptr函数里的p是arr的一份拷贝,函数内部改的是拷贝,不是arr本身。函数结束后,arr还是NULL。
要真正改掉外部的arr,就得把arr变量的地址传进去,也就是二级指针:
void init_ptr(int **pp) { *pp = malloc(sizeof(int) * 10); } int main(void) { int *arr = NULL; init_ptr(&arr); // 传入 arr 的地址 arr[0] = 42; // 正常 free(arr); return 0; }这里*pp = malloc(...)的意思是:解引用二级指针,得到外部的arr,然后把这个指针变量赋值为新分配的内存地址。这样函数返回后,外部的arr就指向堆内存了。
同样的道理,链表删除头节点这种操作,如果只传一级指针,删完头节点后,外部指向头节点的指针就悬空了,所以很多教材会写成二级指针版本,或者用带哨兵节点的写法绕开这个问题。
2.3 二级指针和字符串数组的关系
在main函数里大家都见过这种写法:
int main(int argc, char *argv[])argv的类型本质上是char **:它是一个二级指针,指向一个char *数组,而这个数组的每个元素又指向一个字符串。操作系统启动程序时,会把命令行参数按空格拆分成一个字符串列表,argv就是指向这个列表的“指针的指针”。
比如你运行./a.out hello world,内存布局是:
argv -> [0x100] [0x200] [0x300] [NULL] | | | v v v "./a.out" "hello" "world"argv是一个char **,argv[0]是一个char *,指向程序路径字符串,argv[1]指向第一个参数。遍历参数就是遍历这个二级指针指向的指针数组。
这也是我说“数组在传参时会退化成指针”这个规则的延伸:char *argv[]作为形参,等价于char **argv。理解了这个,后面看很多库函数的参数列表就不懵了。
3. 指针运算与内存寻址:搞懂步长就搞懂了一半
3.1 指针加减到底加的是什么
指针不是普通整数,p + 1在 C 语言里不是“地址值加 1”,而是“地址值加上sizeof(*p)”。也就是说,指针加法的步长由它指向的类型决定。
int *p,p + 1地址增加 4 字节char *p,p + 1地址增加 1 字节double *p,p + 1地址增加 8 字节struct User *p,p + 1地址增加sizeof(struct User)字节
看个小程序验证:
int arr[3] = {10, 20, 30}; int *p = arr; printf("%d\n", *p); // 10 printf("%d\n", *(p + 1)); // 20 printf("%d\n", *(p + 2)); // 30p + 1指向下一个元素,*(p + 1)取到20。数组下标p[i]其实就是*(p + i)的语法糖,这一点搞透了,以后看到任何p[i]的写法,都能在心里换成指针偏移的视角去理解。
3.2 数组名和取地址的微妙差异
一个老生常谈的问题:
int arr[4];arr和&arr打印出来的地址值是一样的,但它们绝对是两种不同类型的东西。
arr的类型是int *,它是数组首元素的地址。&arr的类型是int (*)[4],它是指向整个数组的指针。
关键在步长:
printf("%p\n", (void*)(arr + 1)); // 地址值 +4 字节 printf("%p\n", (void*)(&arr + 1)); // 地址值 +16 字节arr + 1跳到下一个元素,&arr + 1跳过整个数组。所以你要是写出&arr + 1然后想遍历数组元素,那已经到数组外面去了,属于未定义行为。
还有一个常见的坑是sizeof:
sizeof(arr); // 整个数组大小,比如 16 sizeof(&arr); // 指针大小,8(64位平台)这在函数传参时很关键。数组传给函数后就会“退化”成指针,函数里再用sizeof拿不到数组大小,拿到的是指针大小。所以 C 里遍历一个数组,要么同时传长度,要么用哨兵值(比如字符串的'\0')。这是我见到新手写代码踩得最多的一个坑:在main里sizeof用得好好的,封装成函数就乱套了。
3.3 双指针法:算法题里的指针进阶玩法
这里的双指针法不是二级指针,而是“两个指针一前一后/一左一右配合遍历”的算法技巧。面试和竞赛里高频出现,值得单独讲。
最典型的场景是升序数组中找两数之和:
int twoSum(int *nums, int size, int target) { int left = 0, right = size - 1; while (left < right) { int sum = nums[left] + nums[right]; if (sum == target) { return 1; } else if (sum < target) { left++; } else { right--; } } return 0; }这里的left和right就是两个“指针”(用下标模拟指针位置)。为什么能省时间?如果暴力枚举,两层循环是 O(n²)。双指针法每一轮至少移动一个指针,循环最多执行 n 次,复杂度降到了 O(n)。核心思想是:利用数组有序这个条件,通过调整两端指针来快速缩小搜索范围。
再看链表的快慢指针经典应用:检测链表是否有环。
int hasCycle(struct ListNode *head) { struct ListNode *slow = head, *fast = head; while (fast && fast->next) { slow = slow->next; fast = fast->next->next; if (slow == fast) { return 1; } } return 0; }慢指针每次走一步,快指针每次走两步。如果链表有环,快指针最终会追上慢指针,二者相等。如果无环,快指针会先到链表尾部。这个方案使用的额外空间只有两个指针,O(1) 空间复杂度。
这类题的通用套路是:两个指针从不同起点/不同速度出发,利用移动速度差来建立约束条件。刷题的时候可以专门归个类,把快慢指针、对撞指针、滑动窗口这几种都过一遍。
4. 常量指针与指针常量:const 加在星号两边完全不同
4.1 一个口诀记住 const 的作用对象
const修饰指针时,位置不同,限制的对象不同。判断方法:先忽略类型名,看 const 离谁近,离谁近就修饰谁。
具体看下面四种:
const char *p1; // p1 可变,*p1 不可变 char *const p2; // p2 不可变,*p2 可变 const char *const p3; // p2 不可变,*p3 也不可变 char const *p4; // 同 p1const char *p里的const在*左边,修饰的是char,也就是说p指向的字符不能通过p去改。你还可以让p指向别处,但不能用*p = 'x'来改内容。这也就是常说的“指向常量的指针”。
char *const p里的const在*右边,修饰的是指针变量本身,所以p一旦初始化,就不能再指向别处,但可以通过*p去修改它指向的数据。这也就是“指针常量”。
char a = 'x'; char b = 'y'; const char *p1 = &a; p1 = &b; // 没问题,指针可以改 // *p1 = 'z'; // 错,内容不能改 char *const p2 = &a; // p2 = &b; // 错,指针本身不能改 *p2 = 'z'; // 没问题,内容可以改把操作和编译错误对应起来,基本就能完全记住了。
4.2 const 的关键作用与兼容性注意
写函数参数时,const是很好的“文档工具”:
size_t str_len(const char *s);看到这个声明就知道:这个函数只读字符串,不会去改s指向的内存。这不只是给人看的,编译器也会帮你检查,如果函数体里真的写了s[0] = 'x',编译直接报错。
但const的兼容规则有个坑:const char *不能直接赋值给char *,反过来可以。因为如果允许把const char *赋给char *,那这段“只读”内存就可能被改写,破坏了const的约束。
const char *s1 = "hello"; char *s2 = s1; // 编译警告/错误:丢弃了 const 限定这是 C 语言里常被讨论的“限定符丢弃”问题。规避方式就是在设计接口时想清楚,如果函数不会修改参数,就用const;如果函数需要修改参数指向的内容,那就别加const。我自己写代码的习惯是:只读参数一律const,既安全又自解释。
5. 函数指针与函数指针数组:把函数当数据使
5.1 函数指针的声明、赋值与调用
函数也有地址,函数名就是函数的入口地址。函数指针就是用来存放这个地址的变量。
看声明语法:
int (*fp)(int, int);fp是一个指针,指向一个函数,这个函数接收两个int参数,返回int。没有括号的写法int *fp(int, int)是另一个东西——返回int *的函数,也就是后面要说的指针函数。
赋值和调用示例:
int add(int a, int b) { return a + b; } int (*fp)(int, int) = add; int result = fp(3, 4); // 直接通过函数指针调用 int result2 = (*fp)(3, 4); // 这样写也行,不过麻烦第二种写法其实是老式写法,fp(3, 4)和(*fp)(3, 4)在现代 C 里是等价的。用函数指针调用函数,感觉就像把一段逻辑当作变量传来传去,非常灵活。
标准库qsort就是函数指针最经典的应用:
int cmp_int(const void *a, const void *b) { int x = *(const int *)a; int y = *(const int *)b; return (x > y) - (x < y); } int arr[] = {3, 1, 4, 1, 5, 9}; qsort(arr, 6, sizeof(int), cmp_int);qsort并不知道你要排的是整数还是字符串,它只负责按你给的比较规则移动内存。不同的比较函数决定了排序行为,这就是“把策略作为参数”的思想。
5.2 函数指针数组:最简单的表驱动模式
当多个函数具有相同签名时,可以把它们放进数组,根据索引选择调用哪个,这就是函数指针数组。
看一个计算器例子:
int add(int a, int b) { return a + b; } int sub(int a, int b) { return a - b; } int mul(int a, int b) { return a * b; } int divide(int a, int b) { return a / b; } int (*operations[])(int, int) = {add, sub, mul, divide}; // 根据运算符索引选择对应函数 int result = operations[op_index](a, b);如果没有函数指针数组,就得写一长串switch或if/else。每次加一个运算,都要改switch分支;用函数指针数组,只需要往数组里加一个函数名,主逻辑完全不动。这种思路在 C 语言里叫表驱动,在引擎、状态机和协议解析里非常常见。
5.3 函数指针和指针函数不要再混了
这俩名字只差一个“和”,但一个是“指向函数的指针”,一个是“返回指针的函数”。
- 函数指针:
int (*p)(int),本质是指针,类型的核心是“指向函数”。 - 指针函数:
int *f(int),本质是函数,返回值是int *。
判断方法还是老规矩:看名字先跟谁结合。f先跟(int)结合,所以是函数;p被*修饰,是指针。
| 写法 | 本质 | 示例用途 |
|---|---|---|
int (*p)(int) | 函数指针 | 回调函数、表驱动 |
int *f(int) | 指针函数 | 返回字符串首地址、返回动态数组指针 |
这里必须提醒:指针函数千万不要返回局部变量的地址。局部变量在函数结束后就销毁了,返回它的地址,外面拿到的是悬空指针,一访问就可能崩溃。如果一定要返回局部变量相关的内存,用static或者动态分配内存,并且记得释放。
6. 指针传参、值传递与引用:C 和 C++ 的不同解决思路
6.1 函数内交换两个数的指针方式
先看这个经典的错误写法:
void swap(int a, int b) { int tmp = a; a = b; b = tmp; }swap在函数内部的交换跟外面的变量没有关系。因为这里的a、b是调用时传入值的拷贝,函数结束时局部变量销毁,外围变量毫发无伤。
用指针的方式可以解决:
void swap(int *pa, int *pb) { int tmp = *pa; *pa = *pb; *pb = tmp; }这里传的是地址的拷贝,但通过地址可以修改地址指向的内存,所以外围变量真的被交换了。
再强调一次:指针传参本质仍然是值传递,只是这个“值”是地址。所以在函数内部,如果你直接改指针变量本身,外面是感知不到的。只有通过*指针去修改它指向的内存,才能真正影响外部数据。
6.2 C++ 引用:把安全性交给编译器
C++ 里引入了引用,swap可以写成:
void swap(int &a, int &b) { int tmp = a; a = b; b = tmp; }引用在语法上就是“变量的别名”,调用方式像值传递,但实际效果是直接操作原变量。引用跟指针的区别主要在两个方面:
- 指针本身是变量,可以重新赋值,可以指向 NULL;引用必须在定义时初始化,之后不能再绑定到别的变量。
- 使用引用不需要写
*解引用,语法更简洁,也从根本上规避了“指针判空”这种麻烦。
所以在 C++ 项目里,能用引用就优先用引用,需要表达“可能为空值”或“需要重新指向别处”时才用指针。我自己的经验是:const T&用于只读参数,T&用于需要修改实参的场景,T*用于可能为空或需要表达所有权的场景。
不过,也有很多 C 语言老项目用指针用惯了,转回头看引用觉得“不够直观”,这完全是习惯问题。真正理解底层之后,你会发现引用就是在编译期替你把地址操作封装了,没有任何额外的运行时开销。
7. 智能指针:把内存管理交给 RAII
7.1 裸指针的三大麻烦
裸指针(raw pointer)用起来灵活,但麻烦也不少:
- 忘记
free/delete,内存泄漏。 - 提前释放,后面再访问,悬空指针。
- 多处持有同一个指针,释放时机不一致,重复释放直接崩溃。
C++ 从 C++11 开始引入智能指针,核心思想是 RAII:把资源(内存)的获取和释放绑定到对象的生命周期上。对象构造时申请资源,对象析构时自动释放资源。只要智能指针对象是一个局部变量,它出了作用域就会自动析构,那一大片内存就会被自动释放。
7.2 unique_ptr、shared_ptr、weak_ptr 怎么搭配使用
std::unique_ptr是独占所有权模型,一个内存同一时刻只有一个unique_ptr指向它,不允许复制,只能移动。这样所有权转移清晰,不会出现两个指针抢着释放同一块内存的问题。
#include <memory> std::unique_ptr<int> p = std::make_unique<int>(42); // std::unique_ptr<int> q = p; // 编译错误:不允许复制 std::unique_ptr<int> q = std::move(p); // 所有权转移,p 置空std::shared_ptr是共享所有权模型,内部使用引用计数。每多一个shared_ptr指向同一块内存,引用计数加 1;析构时计数减 1;计数减到 0,内存才真正释放。
std::shared_ptr<int> p = std::make_shared<int>(42); std::shared_ptr<int> q = p; // 计数变为 2用make_shared而不是直接new+ 构造shared_ptr,主要原因有两个:一是代码更简洁,异常安全;二是一次性分配控制块和数据内存,内存布局更紧凑,性能更好。
shared_ptr有个经典问题叫循环引用。看这个例子:
struct Node { std::shared_ptr<Node> next; }; auto a = std::make_shared<Node>(); auto b = std::make_shared<Node>(); a->next = b; b->next = a;a和b互相持有对方的shared_ptr,导致它们的引用计数永远不为 0,内存永远不会释放,泄漏了。
解决办法是使用std::weak_ptr。weak_ptr不增加引用计数,只提供“临时访问”的能力。使用时通过lock()获取一个shared_ptr,如果原对象已经释放,lock()返回空指针。
把上面的结构改成std::weak_ptr<Node> next;就能打破循环,让析构顺利执行。
练习题经常考的“手写智能指针”,本质就是实现一个带引用计数的shared_ptr简化版,核心成员是裸指针和计数指针,难点在拷贝构造、赋值运算、析构这几个特殊成员函数的正确写法。面试被问到的时候,先把所有权模型说清楚,再开始写,思路会比较顺畅。
8. 常见问题与排查技巧实录
8.1 崩溃类的核心是分清空指针、野指针、悬空指针
先看一张速查表:
| 问题 | 原因 | 典型表现 |
|---|---|---|
| 空指针 | 指针为 NULL,访问 NULL 地址 | 崩溃,打印行定位到解引用语句 |
| 野指针 | 指针变量未初始化,存着随机地址 | 有时能跑,有时崩,非常随机 |
| 悬空指针 | 指针仍指向已释放的内存 | 访问时值可能对,也可能被改写后崩溃 |
| 越界访问 | 指针偏移超出它指向的内存范围 | 栈内存一般立刻崩,堆内存可能潜伏很久 |
我自己排查这类问题,常用的方法是:
- 先看编译器的报错和崩溃栈,定位到具体文件和行号。
- 在崩溃行附近打印指针的数值,判断是不是
0x0。 - 如果指针值是
0x20、0x78563412这种明显不像正常地址的数,几乎可以确定野指针或越界写把指针破坏了。 - 用内存检测工具跑一遍,比如 Valgrind 或 AddressSanitizer。
AddressSanitizer 用起来很简单,编译时加个参数就行:
gcc -fsanitize=address -g -o test test.c ./test内存错误会在出错瞬间直接报出,非常有杀伤力。我见过很多“上线才崩、本地一跑就正常”的老大难问题,最后都是靠 ASan 直接抓到现场的。
8.2 我平时写指针代码的几个硬规矩
第一个规矩:声明指针时立刻初始化,要么给有效地址,要么给 NULL,别让它处于“未初始化”状态。
int *p = NULL;运行时如果可能解引用p,先判断p != NULL再做解引用。虽然频繁判空看着啰嗦,但能挡住一大批崩溃。
第二个规矩:谁分配谁释放。malloc和free、new和delete必须成对出现,而且要尽量出现在同一个函数或同一个模块里。如果一个函数分配了内存、另一个函数负责释放,代码稍微一改,配对关系就断了,随之而来的就是泄漏或双重释放。
第三个规矩:不要写“一次性通过再也没人看”的代码,要写“三个月后还能读懂”的代码。指针代码的阅读成本本来就高,给函数命名时说明参数是否可能为空、是否会修改参数指向的内容,能省下很多排查时间。
第四个规矩:每次写完涉及指针的代码,先在脑子里走一遍内存图。指针的值是什么,它指向什么,解引用之后类型是什么,偏移一步之后到哪。想不清楚就画在纸上或者写在注释里,不要靠猜。
8.3 小程序验证比背书更管用
学指针最容易犯的错,是只看书不验证。语法规则、运算规则背得滚瓜烂熟,一上机写代码就翻车。
我的建议是:拿一些小而完整的程序把知识点都过一遍。比如写一个程序,声明int a = 5;,然后分别打印&a、*&a、&*&a的输出,看一遍就明白&和*互为逆运算到底是什么意思。再写一个程序,把一维数组、二维数组、指针数组、数组指针的地址差值全部打出来,挨个对照。
编译出来的汇编如果你愿意看,还能发现p->member和(*p).member生成的指令完全一致,它们的区别只在语法层面。
我个人带新人的时候,最推荐的做法是:每个知识点配一个 30 行以内的小程序,写完马上跑,跑完改一改,比如把int *改成const int *,看看编译器报什么错。错误信息也是学习资料,看多了,语法坑基本就能绕开了。
还有一个很好的习惯是用调试器打断点看指针值。在 IDE 的变量窗口里,你能直接看到某个指针指向的地址、解引用后的值、指向数组的前几个元素。这种“可视化”比纯靠脑补内存图要直观得多。特别是二级指针和数组指针,调试器里一展开,层级结构清清楚楚。