指针这东西,几乎每个学C/C++的人都被它折磨过。我招人面试的时候,不管职位高低,最后基本都会问一句:“你跟我说说,指针变量到底存了什么?”能答清楚的,代码功底基本不会差;答不清楚的,哪怕简历写得再花哨,我也得在心里掂量掂量。但反过来,一旦真正把指针的底层逻辑想通了,后面再看数组、字符串、函数回调、内存管理,全都是同一套东西。这篇就把指针从头到尾捋一遍:从指针变量的本质、指针和数组的分分合合,到二级指针、函数指针、结构体指针这些进阶玩法,再讲C++智能指针的用法与陷阱,最后落到双指针算法和空指针排查这些实战场景。内容以C/C++为例,但里边的思维方式,写Java、Go、Rust的人一样用得上。
1. 先搞懂指针的“出身”:地址、类型与那个星号
1.1 指针变量存的不是值,是房间号
一上来就要破除一个经典误区:很多人把“指针”当成一个神秘的东西,觉得它像魔法一样不可捉摸。其实指针变量就是一个普通变量,只不过它里面装的东西有点特别——不是整数、不是字符,而是内存地址。你可以把它想象成快递柜上的房间号。你的数据是包裹,放在柜子的某个格子里,指针就是那张写着“3号柜12格”的小纸条。你要取包裹,不是伸手去所有柜子里翻,而是凭纸条上的房间号,精准走到那个格子前。这个“凭地址取数据”的动作,在C语言里叫解引用(dereference),用*号完成。
搞清楚“变量”“地址”“指针”三者的关系,是所有指针知识的地基。变量名只是给人看的符号,CPU不认这个,它只认地址。你用int a = 10;声明一个变量,编译器会帮你在内存里划出4个字节,并把这块区域的起始地址和变量名a绑定。当你在代码里写a的时候,编译器做的事情就是“去那个地址把那4个字节读出来”。而int *p = &a;则是把a的地址存进另一个变量p。所以p的值是一个地址,也就是一个房间号;*p的值才是房间里的东西,也就是a本尊。
1.2 声明、赋值与类型:星号到底跟着谁
接着聊声明。int *p;这行代码做了两件事:声明一个叫p的变量,并且告诉编译器,p里面将来存的应该是一个int类型变量的地址。很多人纠结写int* p还是int *p,这其实只是风格问题,真正重要的是*号跟变量名结合,而不是跟类型结合。如果写int* p, q;,初学者会以为p和q都是指针,但实际上只有p是,q只是个普通int。这就是为什么很多老手推荐写int *p, *q;,让每个星号都跟着变量名走,从语法根源上避免误会。
赋值这块,最有迷惑性的是p、*p、&a这三者的区分:
int a = 10; int *p = &a; *p = 20; // 等价于 a = 20这里的&a是“取出a的房间号”,&运算符是取地址。p指向a之后,*p和a就是同一个东西的两个名字——你改*p = 20,a也变成20。这就是指针的核心价值:通过间接层,让程序能够在不知道变量在哪个函数里、哪个作用域中的情况下,仍然精准操作那份数据。
注意:
p是地址的值,*p是p指向的那个变量,&a是a的地址。写代码时每一处都要问自己:我现在想要的是房间号,还是房间里的东西?
1.3 未初始化指针:拿着假纸条开保险柜
新手最常遇到的崩溃,就是声明了指针但没赋值就解引用:
int *p; *p = 10; // 危险!p指向哪里?不知道!这相当于你手里拿着一张写着“随便某个房间号”的纸条,就跑去开柜子门,运气不好开到的可能是别人家的保险柜,程序直接段错误。防这个问题的原则很朴素:指针要么指向一个明确存在的对象,要么置为NULL(C++ 里用nullptr),解引用之前必须检查:
int *p = nullptr; if (p != nullptr) { *p = 10; }这一小段看起来简单,实际上后面所有内存安全措施——空指针检查、智能指针、Rust 的所有权——都是在回答同一个问题:这张纸条上的房间号,到底是不是真实存在的、是不是还是我当初记下来的那个。把这个问题刻在脑子里,指针就算入门了。
2. 数组和指针的“半同居”关系:下标、偏移与指针运算
2.1 数组名到底是不是指针
面试里被问烂的问题:数组名是不是指针?答案很微妙。int arr[5];里,arr在绝大多数表达式语境下会“退化”(decay)成指向第一个元素的指针,也就是arr等价于&arr[0]。但在sizeof(arr)里,它又变回整个数组,sizeof(arr)是20(假设int占4字节),不是8。arr也不是一个变量,你不能写arr++。所以准确说法是:数组名会隐式转换为指向首元素的指针,但它本身不是指针变量。
这个特性让很多半吊子教程把“数组就是指针”这种错误结论直接灌输给新人。实际上两者在类型系统里泾渭分明,只是C语言为了方便操作数组,提供了“数组名自动转换成首元素指针”的规则。理解这一点,后面所有关于数组参数传递的坑都能避开——比如函数形参写成int arr[],其实和int *arr完全等价,因为传进来的就是那个退化了的一级指针。
2.2 指针移动与指定位输出
有了这个基础,就能理解热搜里的“指针移动、指定位输出字符”。假设:
char *p = "hello"; printf("%s", p + 2); // 输出 llo这个操作本质上就是让指针偏移。指针的加减不是简单的整数加减:p + n实际上是p + n * sizeof(元素类型)。char占1个字节所以看不出区别,但如果是int *q指向int数组,q + 1在地址上会跳4个字节。这个“步长”由指针类型决定,也是很多人写出越界读写的根源——你以为是挪一个位置,结果整个元素都跳过去了。
我实际开发里经常用这个特性来遍历缓冲区,或者做协议解析时跳过固定头部。比如收到一个网络包,头部8字节,数据从第9字节开始,直接char *data = pkt + 8;就能拿到数据区起点,比memcpy出来再处理省事得多,也少一次拷贝。但这里有个容易翻车的细节:移动指针之前,必须确认剩余长度够不够,否则一printf("%s", p + 2)就可能越过字符串结尾读到野内存里去。
2.3 指针数组与数组指针:括号决定命运
这是另一个必考混淆点,我每次讲到这里都要停下来让学员深呼吸。看这两个声明:
int *pArr[3]; // 指针数组:数组里有3个元素,每个元素都是 int* int (*pArr2)[3]; // 数组指针:一个指针,指向包含3个int的数组区别就在括号。[]的优先级高于*,所以没加括号时,pArr先跟[3]结合成数组,里面的元素再是int*;加了括号,pArr2先跟*结合成指针,再指向[3]这个数组类型。一个“装着指针的数组”,一个“指向数组的指针”,方向完全相反。
指针数组最简单的应用场景就是字符串数组:
const char *names[3] = {"Alice", "Bob", "Cathy"};这里的names是3个指针,每个指针指向一个字符串字面量。注意这不是真正的二维数组,它是“3个指针各指向一块独立的字符区”。而数组指针呢,我一般用来操作二维数组的行:
int matrix[3][4]; int (*row)[4] = matrix; // row指向第一行,row+1指向第二行操作起来非常直观,尤其是要在函数之间传递二维数组时,数组指针是比“数组形参退化成一维指针”更精确的表达。
2.4 字符串处理中的只读陷阱
指针数组存放字符串有个大坑:字符串字面量是只读的。下面这个写法在C标准里属于未定义行为,很多编译器直接崩溃:
char *s = "hello"; s[0] = 'H'; // 未定义行为!字面量可能放在只读区正确做法是用数组拷贝一份:
char s[] = "hello"; s[0] = 'H'; // 安全,s是数组,内容是拷贝来的这个细节我问过不少候选人,能准确说清楚的人不到一半。原因在于char *s只有8个字节(64位平台上指针占8字节),它指向的是编译期就安放在“字符串常量区”的那份数据;而char s[]是在栈上复制了完整的6个字节(包含结尾的\0),修改的完全是自己的拷贝。区别就差在这“指向”和“拥有”上,这也是下一节所有权概念的雏形。
3. 进阶指针:二级指针、结构体指针与函数指针
3.1 指针的指针:为什么要修改指针本身
二级指针,也就是int **pp,听起来唬人,其实拆开看就是“指针的指针”。你有一个变量p存放地址,那么&p就是p这个变量本身的地址,用int **来存。什么时候需要二级指针?核心场景只有一个:你需要在函数内部修改调用者那边的指针本身。
典型例子是写一个分配内存的函数。如果写成这样:
void init(int *p) { p = malloc(sizeof(int)); // 改的是副本 } int *p = NULL; init(p); // 外面p还是NULL为什么?因为C语言函数参数都是值传递。函数里的p是外面p的一份拷贝,你在拷贝上改地址,外面的原件纹丝不动。必须这样:
void init(int **pp) { *pp = malloc(sizeof(int)); // 通过被指向的地址,改掉外面那个指针变量 } int *p = NULL; init(&p); // 传p的地址过去这个问题的本质和“函数里改int变量不生效”是一模一样的——想改普通int,传int*;想改int*,就传int**。一句话:你要修改什么,就传什么的地址。这个规律还可以继续外推,三级指针int ***也是同理,只是真的用到机会极少。
3.2 结构体指针与->运算符
结构体指针在业务代码里出现的频率极高。先看定义:
struct Student { int age; char name[32]; }; struct Student stu; struct Student *sp = &stu;访问成员有两种写法:(*sp).age和sp->age,后者就是前者的语法糖。->运算符几乎离不开,凡是链表、二叉树、队列这些自引用结构,都是靠结构体指针串起来的。
链表节点的经典定义:
struct Node { int data; struct Node *next; };这个next字段就是典型的结构体指针。它指向下一个节点,整个链表靠这些指针串起来。删除节点、反转链表、合并有序链表,全都是在操作这些指针字段,一个没接好链表就断了。我在讲数据结构的时候常说:链表其实不是数据结构,是“指针操作练习”。你如果能把链表的增删改查写顺,说明你对结构体指针和内存生命周期已经有手感了。
3.3 函数指针与函数指针数组:把代码当数据传
函数也有地址,所以也可以有指针。看这个声明:
int (*funcPtr)(int, int); funcPtr = add; int result = funcPtr(3, 4);funcPtr指向一个“参数是两个int、返回值是int”的函数。这个机制的真正价值是回调。比如写一个排序算法,比较规则由调用方传入:
void sort(int array[], int n, int (*compareFn)(int, int));算法内部不关心具体规则,只负责调用compareFn。这是C语言实现“策略模式”的原始手段,后来的C++std::function、JavaComparator、Pythonsorted的key,本质上干的是同一件事。
函数指针再进一步就是函数指针数组:
void (*handlers[4])(void);这是一个有4个元素的数组,每个元素都是指向无参无返回值函数的指针。实际场景见过没有?命令解析器。终端里敲help、run、exit,程序查一张表,表里存的是命令字符串和对应处理函数的指针,然后直接handlers[idx]()调用。比一长串if-else干净得多,而且新增命令只需要往表里加一行。我自己写工具的时候特别喜欢这种结构,可读性和可维护性都上一个档次。
3.4 复杂声明怎么读:右左法则
遇到int (*(*fp)(void))[10];这种鬼东西怎么办?我一般教人用“右左法则”:从最内层的标识符开始,先向右看,遇到)再向左看,一层层剥。fp是一个指针,指向一个函数,该函数无参数、返回值是一个指针,该指针指向一个包含10个int的数组。只要把这条链理清楚,再怪异的声明都能读出来。
实际项目里几乎不会写这么变态的声明,但面试官喜欢拿它来筛“只会背语法、不懂组合规律”的人。我的建议是:看得懂即可,自己写代码时多用typedef拆开,没必要炫技。比如把int (*)[10]定义成typedef int IntArray10[10];,再定义IntArray10 *fp;,表达同样意思,但可读性完全不同。代码是写给人看的,不是写给编译器看的。
4. C++里的指针急救包:智能指针的原理、用法与陷阱
4.1 裸指针的三宗罪
C++有了类、有了异常,裸指针的麻烦被放大了。第一,new出来的对象,如果在delete之前抛了异常,delete就走不到,内存泄漏;第二,拷贝指针会让两个指针指向同一块内存,析构时double delete直接崩溃;第三,函数里返回局部变量的地址,调用方拿到一个悬垂指针,解引用就是未定义行为。
这三宗罪不是“稍微小心就能避免”的问题,而是结构性的。于是C++11引入智能指针,核心思路就是:把动态内存的生命周期绑定到一个栈上对象的生命周期上,栈上对象析构时,自动释放它管理的内存。这样就算抛异常,栈展开也会自动调用析构函数,内存不会漏。
4.2 unique_ptr / shared_ptr / weak_ptr 怎么选
C++11 提供三种智能指针,很多人不知道区别,我直接给结论:
| 指针类型 | 所有权模型 | 典型用法 | 性能特征 |
|---|---|---|---|
std::unique_ptr | 独占所有权,只允许移动,不允许拷贝 | 工厂函数返回值、对象字段 | 零额外开销,和裸指针一样 |
std::shared_ptr | 共享所有权,引用计数自动管理 | 多个模块需要同时持有同一对象 | 控制块有原子计数开销,拷贝有成本 |
std::weak_ptr | 不拥有对象,只观察 | 打破循环引用、缓存弱引用 | 需要从shared_ptr构造,访问时需lock() |
选择策略很朴素:默认unique_ptr;确确实实有多处共享需求,才用shared_ptr;有环或有“只观察不拥有”的需求,用weak_ptr。没有第三种情况需要你手动new和delete。很多团队把“禁止裸new”写进代码规范,我觉得完全合理。
4.3 使用陷阱与面试高频题
陷阱一:不要把同一个原生指针同时交给unique_ptr和shared_ptr管理,双重释放立刻教做人。
陷阱二:shared_ptr的引用计数不是所有操作都线程安全。同一对象被多个线程只读地共享没问题,但如果你从两个线程同时给不同的shared_ptr赋值指向同一个对象,计数更新需要额外加锁,否则可能计数丢失,提前析构。
陷阱三:循环引用。两个对象互相持有对方的shared_ptr,计数永远到不了0,内存在程序退出前都不会释放:
struct Node { std::shared_ptr<Node> next; }; // 两个节点互相指向,形成环,计数都为1,永远不释放解法是让其中一个方向改成weak_ptr。weak_ptr不增加计数,所以不会阻止对象释放。
std::enable_shared_from_this也是面试常客。场景是:类内部需要把自己的shared_ptr传给别的对象(比如注册回调),但直接用this去构造shared_ptr会绕开控制块,导致同一个对象有两个独立的控制块,销毁时双重释放。正确做法是继承std::enable_shared_from_this,然后调用shared_from_this():
class Foo : public std::enable_shared_from_this<Foo> { std::shared_ptr<Foo> getSelf() { return shared_from_this(); } };4.4 智能指针实现原理
聊实现原理之前先明确一点:unique_ptr本质就是一个只移动的包装类,析构函数里做delete,因为不允许拷贝,所以永远只有一个所有者。shared_ptr要复杂些:它内部有两个指针,一个指向对象,一个指向控制块。控制块里有强引用计数、弱引用计数和删除器。
为什么必须分两个指针而不是把计数放进对象内部?因为shared_ptr需要支持指向基类子对象的指针,同时让控制块保持不变,这样才能用dynamic_pointer_cast做安全转型。另外,weak_ptr为什么能知道对象是否还活着?因为它检查的是控制块里的强引用计数,而不是直接碰对象指针。控制块在最后一个weak_ptr销毁时才会释放,所以它始终可以安全访问。
我面试的时候爱问一个问题:shared_ptr的引用计数是原子的,那对象本身线程安全吗?答案是,引用计数线程安全不代表对象内容线程安全。你把同一个对象的shared_ptr传给两个线程,它们同时修改对象内部字段,依然需要加锁。这个八股考点看着基础,实际上决定了你会不会在生产环境写出数据竞争。
5. 算法世界里的指针:双指针与快慢指针
5.1 双指针法的典型场景
看到“指针”不要只想到C语言,算法题里的双指针一样是热搜常客。双指针的核心思想是:用两个下标或者指针,在数组/链表上按不同速度或不同方向移动,把暴力解从 O(n²) 降到 O(n)。
对撞指针:一个从左往右,一个从右往左。典型题目是“有序数组两数之和”。给定一个升序数组和一个目标值,找两个数等于目标。最左加最右,大于目标就右指针左移,小于就把左指针右移,每次比较都排除一行搜索空间。写成代码:
int left = 0, right = n - 1; while (left < right) { int sum = nums[left] + nums[right]; if (sum == target) return {left, right}; if (sum < target) left++; else right--; }滑动窗口是双指针的另一种形态:left和right都往右移动,维护窗口内的信息。典型题目是“无重复字符的最长子串”。右指针扩张窗口,遇到重复字符时左指针收缩,全程只需要维护一个哈希表,复杂度从 O(n³) 直降到 O(n)。
5.2 快慢指针原理:环形跑道上的追逐
快慢指针是一个走一步、一个走两步。最著名的应用是判断链表是否有环。为什么可行?如果链表有环,快指针最终会追上慢指针。你可以把它想成在环形跑道上跑步,速度不同的人最终一定会相遇。没有环,快指针会先碰到NULL,结束判断。
除了判环,还能找环的入口:相遇后,把一个指针移回起点,两个指针都改成每次走一步,再相遇的地点就是环入口。这个证明值得自己推一遍:设链表头到环入口距离为a,环入口到相遇点距离为b,相遇时慢指针走了a + b,快指针走了a + b + k*环长,而快指针路程是慢指针的2倍,所以环长 = a + b + ...,最后就能推出第二次相遇在入口。面试十有八九会追问这段,讲不出来的话前面判环代码写得再漂亮也会减分。
5.3 指针碰撞与内存分配器
热搜词里的“指针碰撞”,严谨的说法是 bump pointer。它其实不是算法题,而是内存分配器里的一种常用策略。分配器维护一个“下一个可用内存地址”的指针,每次分配就在这个指针后面划一块,然后把指针往后移。这个移动的过程看起来就像指针一步步往前拱,所以叫指针碰撞。只要内存规整,分配就是一次指针加法,效率极高。反过来,如果内存不规整,碎片化严重,才需要空闲列表这类复杂结构。Java HotSpot 虚拟机的新生代内存分配就有用这个思路。
顺带说一句,很多语言里的引用、切片、游标,本质都是指针的某种安全封装。理解了指针的移动规律,学什么语言都快。比如 Go 的 slice 就是一个“数据指针 + 长度 + 容量”的结构体,你把它传来传去时,那个底层数组地址就是指针运算在背后支撑。
6. 日常开发中的指针事故现场:空指针、野指针与排查思路
6.1 空指针不是“空”的指针
“空指针”三个字听着像“什么都没指向”,本质上就是地址为0。在C/C++里解引用空指针是未定义行为,通常表现为段错误;在Java/C#里是NullPointerException/ 空引用异常。本质上都是:你手里拿着一张写着0号房间的纸条,但0号房间根本不允许任何人进入。
很多新人会困惑:为什么空指针不能“优雅地”给个返回值?因为空指针解引用是CPU层面直接触发的保护机制,程序在硬件级别被拦停了,根本轮不到语言运行时优雅处理。所以最好的策略永远是:不要解引用空指针,使用前先判空。
6.2 定时器执行查询报空指针的排查链路
热搜里有一句很具体:“timer执行查询时报空指针”。这种问题我见过太多次了,典型场景是这样:定时器回调里去做数据库查询,结果抛空指针。很多人第一反应是“数据库没连上”,其实十有八九不是。我的建议排查链路是:
第一步,先看异常栈,找到空指针发生在哪一行。如果栈里显示的是查询方法内部,先别急着查数据库,那只是表象。
第二步,检查回调里用到的对象引用是不是在定时器启动之前就已经被释放或者被置空了。这是最经典的坑:主流程里为了释放资源,把某个成员置成null,但定时器还在跑,下一秒回调就踩中。定时器类场景尤其容易触发,因为回调是异步的,你无法靠代码执行顺序判断对象状态。
第三步,检查定时器回调执行线程是否正确。有的框架要求UI操作必须在主线程,你在线程池里操作UI控件,空指针只是最轻的症状,重则直接SIGSEGV。
第四步,数据库查询本身返回的结果集如果是空的,代码里直接用了rs.getXXX(),一样会报空指针。这是结果集判空问题,跟数据库连接无关。
这四步走完,九成问题都能定位。核心教训:空指针不是靠“凭感觉猜”解决的,是顺着引用生命周期一步步查出来的。任何对象的引用,只要可能被别的地方置空或销毁,使用前就得判空,或者换一种更安全的生命周期管理方式。
6.3 从源头减少指针事故的防守习惯
说点实在的预防手段:
- 指针初始化,声明时立刻给
nullptr或让它指向明确对象,杜绝“未初始化即解引用”。 - 释放内存后立即置空,防止悬垂指针二次使用。
- 用智能指针包装所有权,减少手动
delete,让资源生命周期自动管理。 - 编译期开启 AddressSanitizer(
-fsanitize=address),能直接帮你抓到越界和 use-after-free。我见过的内存类bug,八成靠它第一轮就能揪出来。 - 代码评审时重点 review 所有“返回指针、传出指针、缓存指针”的地方,这些都是事故高发带。
- 如果一个指针要在多个模块间传递,写下它的“生命周期契约”:谁分配、谁释放、能存活多久、是否允许为空。几句话的事,能省掉无数个深夜排查。
提醒:如果团队里有新人,让他先从“指针三问”开始练:这个指针是谁分配的?它的生命周期到什么时候?还有谁持有它的引用?能把这三问回答清楚,就能避开绝大多数指针事故。
最后分享一点我自己的体会。带团队这些年,我从来不要求新人背语法细节,而是让他动手画内存图。每遇到一个指针问题,把变量、地址、引用关系画在纸上,问题通常自己就暴露了。指针这个东西,纸上谈兵没有用,一定要走到代码里,走到崩溃现场去看。你多踩几次段错误、多调几次空指针,就会发现它其实一点都不神秘。希望这篇对你接下来的 C/C++ 之路有点用。