
提到“深入理解指针四”老读者应该都知道这个系列我一直在掰开揉碎讲指针。翻了一下之前的留言前三篇聊完基础类型、数组和函数传参之后被问得最多的问题集中在几个地方指针和数组到底怎么纠缠的、二级指针到底干嘛用的、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 worldargv是一个char **argv[0]是一个char *指向程序路径字符串argv[1]指向第一个参数。遍历参数就是遍历这个二级指针指向的指针数组。这也是我说“数组在传参时会退化成指针”这个规则的延伸char *argv[]作为形参等价于char **argv。理解了这个后面看很多库函数的参数列表就不懵了。3. 指针运算与内存寻址搞懂步长就搞懂了一半3.1 指针加减到底加的是什么指针不是普通整数p 1在 C 语言里不是“地址值加 1”而是“地址值加上sizeof(*p)”。也就是说指针加法的步长由它指向的类型决定。int *pp 1地址增加 4 字节char *pp 1地址增加 1 字节double *pp 1地址增加 8 字节struct User *pp 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然后想遍历数组元素那已经到数组外面去了属于未定义行为。还有一个常见的坑是sizeofsizeof(arr); // 整个数组大小比如 16 sizeof(arr); // 指针大小864位平台这在函数传参时很关键。数组传给函数后就会“退化”成指针函数里再用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. 智能指针把内存管理交给 RAII7.1 裸指针的三大麻烦裸指针raw pointer用起来灵活但麻烦也不少忘记free/delete内存泄漏。提前释放后面再访问悬空指针。多处持有同一个指针释放时机不一致重复释放直接崩溃。C 从 C11 开始引入智能指针核心思想是 RAII把资源内存的获取和释放绑定到对象的生命周期上。对象构造时申请资源对象析构时自动释放资源。只要智能指针对象是一个局部变量它出了作用域就会自动析构那一大片内存就会被自动释放。7.2 unique_ptr、shared_ptr、weak_ptr 怎么搭配使用std::unique_ptr是独占所有权模型一个内存同一时刻只有一个unique_ptr指向它不允许复制只能移动。这样所有权转移清晰不会出现两个指针抢着释放同一块内存的问题。#include memory std::unique_ptrint p std::make_uniqueint(42); // std::unique_ptrint q p; // 编译错误不允许复制 std::unique_ptrint q std::move(p); // 所有权转移p 置空std::shared_ptr是共享所有权模型内部使用引用计数。每多一个shared_ptr指向同一块内存引用计数加 1析构时计数减 1计数减到 0内存才真正释放。std::shared_ptrint p std::make_sharedint(42); std::shared_ptrint q p; // 计数变为 2用make_shared而不是直接new 构造shared_ptr主要原因有两个一是代码更简洁异常安全二是一次性分配控制块和数据内存内存布局更紧凑性能更好。shared_ptr有个经典问题叫循环引用。看这个例子struct Node { std::shared_ptrNode next; }; auto a std::make_sharedNode(); auto b std::make_sharedNode(); a-next b; b-next a;a和b互相持有对方的shared_ptr导致它们的引用计数永远不为 0内存永远不会释放泄漏了。解决办法是使用std::weak_ptr。weak_ptr不增加引用计数只提供“临时访问”的能力。使用时通过lock()获取一个shared_ptr如果原对象已经释放lock()返回空指针。把上面的结构改成std::weak_ptrNode next;就能打破循环让析构顺利执行。练习题经常考的“手写智能指针”本质就是实现一个带引用计数的shared_ptr简化版核心成员是裸指针和计数指针难点在拷贝构造、赋值运算、析构这几个特殊成员函数的正确写法。面试被问到的时候先把所有权模型说清楚再开始写思路会比较顺畅。8. 常见问题与排查技巧实录8.1 崩溃类的核心是分清空指针、野指针、悬空指针先看一张速查表问题原因典型表现空指针指针为 NULL访问 NULL 地址崩溃打印行定位到解引用语句野指针指针变量未初始化存着随机地址有时能跑有时崩非常随机悬空指针指针仍指向已释放的内存访问时值可能对也可能被改写后崩溃越界访问指针偏移超出它指向的内存范围栈内存一般立刻崩堆内存可能潜伏很久我自己排查这类问题常用的方法是先看编译器的报错和崩溃栈定位到具体文件和行号。在崩溃行附近打印指针的数值判断是不是0x0。如果指针值是0x20、0x78563412这种明显不像正常地址的数几乎可以确定野指针或越界写把指针破坏了。用内存检测工具跑一遍比如 Valgrind 或 AddressSanitizer。AddressSanitizer 用起来很简单编译时加个参数就行gcc -fsanitizeaddress -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 的变量窗口里你能直接看到某个指针指向的地址、解引用后的值、指向数组的前几个元素。这种“可视化”比纯靠脑补内存图要直观得多。特别是二级指针和数组指针调试器里一展开层级结构清清楚楚。