
说实话每次写指针相关的内容我都觉得是把自己按在地上摩擦一遍。前两篇把指针的基础、指针与数组、指针与函数的关系讲得差不多了这一篇《指针三》我不想再重复“指针就是地址”“指针变量存的是地址”这种入门话术而是把指针真正“组合”起来用。数组指针、函数指针数组、二级指针、智能指针、快慢指针这些进阶形态才是面试和实际项目里真正卡人的地方。这篇适合已经有C/C基础、能把指针的基本语法写明白但遇到“指针套指针”“指针套函数”“指针套数组”就头晕的人。看完这篇你会明白为什么有人能拿着指针写出很优雅的代码也终于能看懂那些让你头皮发麻的声明了。1. 先把“为什么需要指针”这个问题彻底想明白1.1 指针不只是地址而是一种“间接访问”的授权很多教材把指针定义成“存放变量地址的变量”这个定义没错但容易让人忽略一个更本质的东西指针存在的意义是让你能够间接地操作一块内存而不需要知道它在哪、也不需要把整块数据搬来搬去。举个生活化的例子。你让同事去仓库拿东西你可以直接把东西搬过来放你桌上按值传递也可以把仓库钥匙给他传指针。搬一次东西东西越大成本越高给钥匙则无论仓库多大都只是一把钥匙。C语言诞生的时候硬件资源极其紧张这种“传钥匙”的思路直接决定了语言的效率上限。这里要注意指针变量本身也是变量它也占内存。64位系统下所有指针都是8字节32位系统下是4字节。这个数字和它指向的数据类型无关char*是8字节double*也是8字节。很多人写代码时忽略了这一点总觉得char*应该比double*小实际上指针的大小只跟系统位数有关。1.2 指针的运算规则就是“按类型步进”指针能做加减运算但它的加减步长不是1字节而是sizeof(类型)。这就是为什么int* p执行p1实际上地址增加了4字节而char* p执行p1只增加1字节。这个规则很多人背了但没有深想。步长的设计是为了让“连续元素”的遍历变成一件自然的事情——你有一个int数组指针加1就应该指向下一个int而不是下一个字节。所以指针运算本质上是在“元素”维度上移动而不是在“字节”维度上移动。实际使用中我总结了一条经验如果代码里出现了强制类型转换后的指针运算十有八九是在做底层字节操作比如网络协议解析、二进制文件读取。这个时候你就得用char*或者unsigned char*来做字节级定位因为只有char类型的指针步长才是1字节。曾经我解析一份自定义二进制协议用int*去做步进解析结果一直错位排查了很久才发现问题出在指针步长上。1.3 指针不是为了炫技而是为了三件事第一修改调用方的数据。C语言是值传递想在函数里改外面的变量必须传指针或传数组名。第二避免复制大对象。一个结构体几百字节按值传一次复制一次函数嵌函数就复制好几份传指针只需要8字节。第三动态内存管理。malloc、new拿到的都是一块内存的地址你不用指针根本没法访问它。这三点想通了你就明白为什么C/C代码里指针满天飞。不用指针才是不正常的。2. 指针的组合形态从“指针数组”到“函数指针数组”2.1 指针数组和数组指针先分清谁是谁这是最经典的一道送命题。我直接说结论int* p[4]是一个数组数组里有4个元素每个元素是int*叫指针数组。int (*p)[4]是一个指针指向一个“有4个int元素”的数组叫数组指针。括号就是用来打破默认结合的。[ ]的优先级高于*所以不加括号的int* p[4]先被解析成数组加了括号(*p)先被解析成指针。指针数组最常见的用法是存放一组字符串地址。看这段代码#include stdio.h int main(void) { const char* names[4] {Alice, Bob, Cathy, David}; for (int i 0; i 4; i) { printf(name[%d] %s\n, i, names[i]); } return 0; }这里的names是一个长度为4的数组每个元素都是一个const char*指向了字符串字面量在只读区的首地址。整个数组占用的内存是4个指针的大小32字节而字符串本身并没有复制到数组里。这就是指针数组和二维字符数组的本质区别char arr[4][20]是实打实分配了80字节并复制内容而指针数组只是存了4个地址。2.2 数组指针用它访问二维数组更顺手数组指针看起来绕但它的典型场景是“把二维数组的某一行当作一个整体来传递”。举个例子#include stdio.h void print_row(int (*row)[5]) { for (int i 0; i 5; i) { printf(%d , (*row)[i]); } printf(\n); } int main(void) { int matrix[3][5] {{1,2,3,4,5},{6,7,8,9,10},{11,12,13,14,15}}; for (int i 0; i 3; i) { print_row(matrix[i]); } return 0; }matrix[i]本来就是一个一维数组的数组名取地址matrix[i]得到的就是指向“含5个int的数组”的指针。这里使用(*row)[i]来访问第i个元素注意必须加括号因为*row[i]会被解释成“取row[i]的指针内容”意思完全不同。其实在大多数场景写int matrix[3][5]时函数参数可以直接写成int matrix[][5]编译器会自动降级成数组指针。但面试官喜欢让你写出完整的指针形式以此判断你是否真正理解二维数组的内存布局。2.3 函数指针把函数当参数传来传去函数指针是很多人卡住的地方但理解了一个概念就通了函数名就是函数的入口地址就像数组名是数组首元素的地址一样。声明一个函数指针的语法是int (*func_ptr)(int, int);这表示func_ptr是一个指针指向了一个“接收两个int参数、返回int”的函数。你要把某函数赋给它直接写函数名就行int add(int a, int b) { return a b; } int sub(int a, int b) { return a - b; } int (*func_ptr)(int, int) add; func_ptr sub;为什么要函数指针最常见的理由是回调机制。比如标准库的qsort它自己不关心你要排什么类型的数据它只负责比较过程的调度具体怎么比通过函数指针回调你的比较函数。#include stdio.h #include stdlib.h int compare_int(const void* a, const void* b) { int ia *(const int*)a; int ib *(const int*)b; return (ia ib) - (ia ib); } int main(void) { int arr[] {5, 2, 9, 1, 7}; qsort(arr, 5, sizeof(int), compare_int); for (int i 0; i 5; i) printf(%d , arr[i]); return 0; }函数指针的存在让“策略可替换”变得极其简单这也是C语言做面向对象风格编程的核心手段。结构体里放函数指针就成了C语言版的“虚函数表”。2.4 函数指针数组查表代替switch当你有多个结构相同的函数并且要根据一个编号选择执行哪一个时函数指针数组比switch优雅得多。核心场景比如命令解析、消息处理、指令分发。#include stdio.h void cmd_open(void) { printf(open cmd\n); } void cmd_close(void) { printf(close cmd\n); } void cmd_save(void) { printf(save cmd\n); } void cmd_quit(void) { printf(quit cmd\n); } int main(void) { void (*cmds[])(void) {cmd_open, cmd_close, cmd_save, cmd_quit}; int id 2; if (id 0 id 4) { cmds[id](); } return 0; }这里的cmds是一个函数指针数组每个元素是一个void (*)(void)类型的函数指针。当id2时直接调用cmds[2]()等价于cmd_save()。如果你有几十个命令switch会写成半屏而查表只需要一行。而且给表增加新命令只需要在数组初始化里追加一个函数名不需要改逻辑分支。这里要注意一个限制函数指针数组要求所有函数的签名参数和返回值完全一致。如果你遇到签名不一致的函数可以全部包装成统一的接口用void*传具体参数。这是C语言里最常见的“适配器”做法。2.5 二级指针什么时候真的需要“指针的指针”二级指针int**在初学阶段看起来像多余的东西但它有个非常基础且必须的用途当你需要在函数里修改“指针本身”的值必须把指针的地址传进去。最典型的场景是链表头节点插入void insert_head(Node** head, int val) { Node* new_node malloc(sizeof(Node)); new_node-val val; new_node-next *head; *head new_node; }*head就是原来的头节点我们通过*head new_node把头指针指向了新节点。如果你只传Node* head函数里修改的head是形参的副本调用方根本感知不到。这是所有写过链表的人都会踩的坑。那为什么不能返回Node*再赋值可以但那样要写head insert_head(head, val);调用方必须记得接收返回值。用二级指针的好处是把“改变指针”这件事封装在函数内部调用方不用为每一个修改头节点的操作都去做一次赋值。在C#里其实也有类似需求只是形式不同。C#的引用类型本来就是“传引用”普通场景不需要额外的指针层级。只有在unsafe代码块里用fixed固定内存、操作原生缓冲区时你才会看到int*、char*这些真实指针而且C#的指针用法比C语言更克制不能在非unsafe上下文使用还要配合stackalloc和fixed确保内存安全。理解C的二级指针之后再看C#的指针就会觉得是很保守的简化版。2.6 指针数组存放字符串命令行参数argv就是一个活例子很多人第一次被指针数组震到就是C语言主函数的第二个参数int main(int argc, char* argv[])你知道argv是一个数组数组元素是char*每个元素指向一个以\0结尾的字符串。整个命令行参数就是靠着这个“指针数组存放字符串”的结构传给程序的。下面这段代码可以打印任意命令行的参数#include stdio.h int main(int argc, char* argv[]) { for (int i 0; i argc; i) { printf(argv[%d] %s\n, i, argv[i]); } return 0; }运行时所有命令行字符串各有各的内存位置argv里的每个指针分别指向它们。这个结构说明了一个很重要的道理字符串本身没有一个独立的“字符串容器类型”它天然就是“字符数组指针”的组合。3. C智能指针把内存这摊事交给RAII3.1 为什么C/C开发最后都转向智能指针手动new/delete、malloc/free的风险不是“忘了”而是“忘了之后程序还继续跑”。内存泄漏这种问题不像空指针一样爆崩溃它会缓慢积累直到系统内存被耗尽后给你一个莫名其妙的问题现场。C11之后标准库提供了unique_ptr、shared_ptr、weak_ptr三个工具把“内存所有权”这个容易出错的环节交给编译器去推断和收尾。智能指针的核心思想是RAIIResource Acquisition Is Initialization资源获取即初始化。在这个思路下资源在构造函数里获取在析构函数里释放。当智能指针对象离开作用域时它的析构函数自动执行于是堆内存自动回收无论你是正常return还是中间抛了异常。这就像你有了一个自动关灯的酒店房间无论你是正常退房还是临时跑路保洁系统都会在你离开后把灯关掉。3.2 unique_ptr独占所有权轻量又安全unique_ptr表达的是“这块内存同一时间只归一个指针所有”。它禁止拷贝只允许移动。#include memory #include iostream struct Data { int id; Data(int i) : id(i) { std::cout Data constructed: id \n; } ~Data() { std::cout Data destroyed: id \n; } }; int main() { std::unique_ptrData p std::make_uniqueData(42); std::cout p-id \n; // std::unique_ptrData q p; // 编译错误拷贝被禁止 std::unique_ptrData q std::move(p); // 转移所有权 // 此时 p 为空q 持有对象 return 0; }make_unique是推荐的创建方式它相比直接new多了几个好处代码更简短、异常安全不会有裸指针在中间暴露、内存分配和对象构造统一管理。因为unique_ptr没有任何引用计数的开销它和裸指针的性能几乎相同所以大部分场景都优先用unique_ptr即可。只有确实需要“多个所有者共享一块内存”时才考虑shared_ptr。3.3 shared_ptr和weak_ptr共享所有权与环引用陷阱shared_ptr内部维护一个引用计数每多一个shared_ptr指向同一块内存计数加一当最后一个引用离开作用域计数归零对象被销毁。但它最大的坑是环引用。如果A中持有指向B的shared_ptrB中又持有指向A的shared_ptr这两者就形成循环谁也不会等计数归零内存永远不释放。解决方式就是引入weak_ptr。weak_ptr不增加引用计数它只是“观察者”需要访问对象时用lock()临时提升为shared_ptr。如果对象已经被释放lock()返回空shared_ptr。#include memory struct Node { std::shared_ptrNode next; std::weak_ptrNode prev; // 防止环引用 ~Node() { /* 析构 */ } }; int main() { std::shared_ptrNode a std::make_sharedNode(); std::shared_ptrNode b std::make_sharedNode(); a-next b; b-prev a; // 这里用 weak_ptr不会增加 a 的引用计数 return 0; }如果这里prev也用shared_ptr那么a和b都会因为互相持有而永远不释放。这是面试和项目中最高频的智能指针陷阱。3.4 手写一个迷你智能指针理解本质面试题里经常让人手写一个简版智能指针核心就是“构造时接管资源析构时释放资源并处理拷贝/移动语义”。写一个简单的只支持unique语义的版本template typename T class MiniUnique { private: T* ptr; public: explicit MiniUnique(T* p nullptr) : ptr(p) {} ~MiniUnique() { delete ptr; } // 禁止拷贝 MiniUnique(const MiniUnique) delete; MiniUnique operator(const MiniUnique) delete; // 允许移动 MiniUnique(MiniUnique other) noexcept : ptr(other.ptr) { other.ptr nullptr; } MiniUnique operator(MiniUnique other) noexcept { if (this ! other) { delete ptr; ptr other.ptr; other.ptr nullptr; } return *this; } T operator*() const { return *ptr; } T* operator-() const { return ptr; } T* get() const { return ptr; } };这个迷你版本虽然远不能覆盖标准库的所有细节比如自定义删除器、数组支持等但它讲清楚了RAII的价值析构函数是自动调用的所以对象生命周期结束的那一瞬间资源释放代码一定会执行。这是手写内存管理永远没法保证的。3.5 智能指针使用方法和常见坑点几个实战中的关键习惯不要用同一个裸指针去初始化多个shared_ptr。这样会创建两个独立的控制块意味着同一块内存被释放两次直接崩溃。shared_ptr的引用计数是线程安全的但对象本身的操作不是。多个线程同时读写同一个对象仍然需要外部加锁。不要轻易在函数参数里传shared_ptr。如果只是读对象传const T或者裸指针或用shared_ptrconst T每次都按值传shared_ptr会增加一次引用计数在循环里大量调用时会有可感知的开销。unique_ptr转shared_ptr是自然的但反过来不行。如果你在写公共接口优先收unique_ptr内部如果需要共享语义再转shared_ptr这样调用方有最大自由度。4. 快慢指针与双指针算法题里的万能套路4.1 快慢指针的核心原理快慢指针本质上就是用两个速度不同的指针同向遍历线性结构制造出一个“速度差”利用这个速度差来检测特定结构最常见的是环形检测。为什么快慢指针能检测环想象两个人在环形跑道上跑步一个人速度是另一个的两倍那么速度快的总会从后面追上慢的两者一定会相遇。如果是直线跑道快的人永远领先不会回头所以永远不相遇。这就是“有环必相遇无环定不同步”的原理。在链表里通常让快指针一次走两步慢指针一次走一步。如果链表存在环它们最终必然相遇不存在环时快指针会先到达链表尾部。4.2 环形链表检测的实现与拓展#include stdbool.h struct ListNode { int val; struct ListNode* next; }; bool hasCycle(struct ListNode* head) { if (head NULL || head-next NULL) return false; struct ListNode* slow head; struct ListNode* fast head; while (fast ! NULL fast-next ! NULL) { slow slow-next; fast fast-next-next; if (slow fast) return true; } return false; }这里的边界条件很关键fast ! NULL fast-next ! NULL缺一不可因为快指针每次走两步如果fast-next为空继续访问fast-next-next就是空指针。这个边界也是“timer执行查询时报空指针”的一个可能根源——指针在链式结构上移动时没有做当前节点和下一节点的空值双重判断。如果还要找出环的入口就需要一个经典三步骤相遇后让一个指针回到头节点两个指针再同步每次走一步再次相遇的位置就是环的入口。这个结论的推导基于两者剩余路径差面试时能推出来会让面试官高看一眼。4.3 双指针法有序数组去重与两数之和快慢指针是“同向不同速”双指针法是“不同侧相对移动”。最经典的是有序数组去重原地修改并返回新长度int removeDuplicates(int* nums, int numsSize) { if (numsSize 0) return 0; int slow 0; for (int fast 1; fast numsSize; fast) { if (nums[fast] ! nums[slow]) { slow; nums[slow] nums[fast]; } } return slow 1; }slow指针维护“结果数组的末尾”fast指针负责扫描全数组。遇到不同元素就把fast位置的值搬运到slow1的位置最终slow1就是去重后数组长度。这个思路空间复杂度O(1)时间复杂度O(n)比开新数组的方案一针见血。两数之和有序数组版则用左右双指针int* twoSum(int* numbers, int numbersSize, int target, int* returnSize) { int left 0, right numbersSize - 1; int* res (int*)malloc(2 * sizeof(int)); while (left right) { int sum numbers[left] numbers[right]; if (sum target) { res[0] left 1; res[1] right 1; *returnSize 2; return res; } else if (sum target) { left; } else { right--; } } *returnSize 0; return NULL; }左右指针的移动依据是有序性sum太小就说明左边的数太小left右移sum太大就说明右边的数太大right左移。每次移动缩小“候选区间”的一半方向不会漏掉答案。4.4 滑动窗口双指针的“区间版”双指针还有一种更高级的用法叫滑动窗口它解决的是“连续子数组/子串”类问题。基本形态是left和right都从左端开始right不断向右扩张当窗口内条件不满足时移动left收缩窗口始终保持窗口内是一个满足条件的区间。典型问题是最小覆盖子串。给定字符串s和t找出s中包含t所有字符的最小子串。此时窗口右端不断扩展直到覆盖所有目标字符然后左端不断收缩寻找更优解。这里需要对字符计数每次移动left或right都更新计数同时维护一个“缺失字符数”变量为零时表示已覆盖。滑动窗口最难的不是框架本身而是窗口收缩的时机和计数更新的顺序。我的建议是先写出右指针扩张的完整逻辑再考虑左指针收缩最后再用最小长度变量记录最优结果。不要试图一步到位那样最容易把指针移动的先后顺序搞混。4.5 双指针变种同向快慢与逆向迭代除了上面讲的相对移动和同向不同速还有两种常用变体。第一种是“对撞指针”退化成的“中心扩散法”用于找回文子串。从每个字符或每两个字符中间向两边扩散用两个指针比较左右字符是否相等。这种方法的复杂度是O(n^2)但胜在思路直白面试中容易现场实现。第二种是“逆向双指针”典型场景是合并两个有序数组时从尾部开始填充。因为从头部填充可能会覆盖尚未处理的旧数据而从尾部填充可以利用数组后部空闲区域避免额外分配临时空间。这个技巧在处理“原地合并”类问题时非常有用。5. 实战避坑指针调试与常见问题排查5.1 三类最坑指针空指针、野指针、悬空指针空指针就是值为NULL的指针访问它就崩溃这个好识别。野指针是“还没有被赋值的指针”它存的地址是随机的访问它可能碰巧没崩也可能崩得非常随机。悬空指针指向的内存已经被释放但它还在那里再访问就是“读一本已经下架的书”。这三类问题的共性在于崩溃的时间点往往离真正出错的代码非常远。指针在函数A里被错误释放函数B里才解引用崩溃你自己排查时很容易被表面位置带偏。一个防御性的习惯是释放指针后立即置为NULL。这样即使后面误用了它至少是稳定崩溃而不是随机崩溃调试难度低一个量级。在C里我还会配合断点条件来观察某个指针的内存地址变化在调试器中设置“数据断点”只要这个地址被改就会中断可以快速定位“谁动了我的指针”。5.2 函数参数里传指针改不了值的经典困惑很多新手写swap函数用void swap(int* a, int* b)是没问题的但写“修改指针本身”的函数时就忘了需要二级指针。模型其实很简单如果你要修改一个变量的值就传这个变量的地址如果你要修改一个指针变量的值就传这个指针变量的地址。换句话说变量是int函数参数就是int*变量是int*函数参数就是int**。把这个“递推”关系想清楚就没有什么指针层级能绕晕你。5.3 字符串指针相关的坑常量区不能写看这段代码char* p hello; p[0] H; // 崩溃字符串字面量通常存放在只读数据段通过char*直接修改字符串字面量内容是未定义行为在大多数平台会直接段错误。正确做法是定义成字符数组char arr[] hello; arr[0] H;这里arr是栈上的字符数组内容可修改。但注意char arr[] hello和char* p hello是两种完全不同的东西——前者会把字符串内容复制到栈上后者只是让指针指向只读区。判断一个字符串指针参数能不能改优先问一个问题这个指针指向的内存是可写区域吗如果来源是malloc或栈数组可以改如果来源是字符串字面量绝对不能改。平时写函数接口时强烈建议参数用const char*来约束一旦你想改编译器就会提醒你避免在运行时才发现问题。5.4 文件指针与资源管理文件操作中FILE*也是一种指针。最常见的错误是fopen后没有判空就直接fread。文件路径不存在、权限不足、磁盘满的时候fopen会返回NULL如果不判断就直接用文件指针轻则读到脏数据重则崩溃。FILE* fp fopen(data.bin, rb); if (fp NULL) { perror(open file failed); return -1; }文件指针的管理也可以套用智能指针的思路写一个RAII包装类在析构函数里自动fclose。我在读配置文件的工具代码里经常这么干省去了一堆goto清理。5.5 定时器回调中的空指针问题不管是C语言里用信号定时器、还是C里用异步任务把指针传给回调函数都容易踩一个坑定时器触发时原本的对象可能已经被销毁了。这就是“timer执行查询时报空指针”的典型场景。解决方式有几种如果是单线程定时器确保回调触发前对象一定存活比如延长对象生命周期如果是多线程可能要改用weak_ptr在回调里lock()判断对象是否还在最稳妥的还是在回调里无条件做一次空值判断再使用。我个人的经验是任何跨线程、跨异步边界的指针都要默认它可能已经失效。这不是悲观是一种编写并发代码的基本敬畏感。5.6 常见问题速查表症状可能原因排查方向解引用即崩溃空指针/野指针断点观察指针值确认是否NULL或可疑小地址偶发崩溃悬空指针开启地址消毒器ASAN定位释放点修改字符串崩溃修改了只读常量检查字符串来源优先用const char*内存越界但未崩指针步长错误检查类型强转和指针运算类型内存持续增长忘记释放/智能指针环引用用valgrind或Visual Studio的诊断工具找泄漏点定时器回调崩溃对象被提前销毁回调内空值判断改用弱引用实操中的一点体会写这篇文章的过程中我又重新把两三年前写的链表、环形检测、智能指针代码翻出来过了一遍发现当年很多“我以为我懂了”的东西其实只是记住了语法。真正让我对这些内容有肌肉记忆的反而是项目里那些被空指针和悬空指针折腾到凌晨的夜晚。指针这个东西看十遍不如写一遍写十遍不如错一遍。我的建议很简单不要急着去背那些复杂的声明解析规则先找几个真实场景亲手写一遍——比如用函数指针数组做一个命令行工具、用快慢指针去处理链表环、把自己的new/delete代码换成智能指针——把这些代码真正跑起来再回头看语法会觉得一切顺理成章。等你哪一天遇到指针问题不再靠猜而是能说出“这个指针可能悬空了”“这里应该用二级指针”那就说明你已经真正吃透它了。