搞过C/C++的人大概都经历过这么一段时光:明明代码里全是*和&,翻来覆去就是闹不清到底谁指向谁;数组名和指针纠缠在一起,学了一个星期还是晕;再遇上const int *p和int *const p这种换位游戏,干脆直接摆烂。
如果你搜的是鼠标指针,那上面这些内容与屏幕上的小箭头无关,这篇文章说的是C/C++里的内存指针——它确实是很多初学者嘴里说的"劝退点",但一旦把底层逻辑捋顺,你会发现指针真的就一句话:地址也是一种数据,可以存储、拷贝、传递和运算。这篇文章不是再给你背一万条语法,而是从指针变量到指针的指针、从数组纠缠到函数指针、从裸指针到智能指针,把C/C++体系里所有和"指针"有关的知识节点串成一条容易理解的线,顺带把工作里最容易踩的空指针、悬垂指针坑也一起说了。
1. 先搞清楚指针变量到底存的是什么
1.1 指针就是装着地址的普通变量
很多人学指针第一课就懵,是因为教材喜欢把指针描述得很玄:"指针就是指向变量的变量""指针就是地址"……听着云里雾里。我换个说法,指针就是一个普普通通的变量,只不过它的值是另一个变量的地址。
你用int age = 26;在内存里申请了一块4字节空间,这块空间有一个编号,就像小区门口的楼栋号,这个编号就是地址。int *p;则是又申请了一块空间,里面存的就是age的楼栋号。所以:
int age = 26; int *p = &age;&age取的是age的地址,把地址存到p里。你可以打印一下试试:
printf("%p\n", (void*)&age); printf("%p\n", (void*)p);两次打印结果一模一样。这说明p和&age就是同一个值。如果非要说指针"指向"了age,这句话的主语其实是"地址值",而不是什么神秘力量。
理解了这一层,后面所有东西都能推出来:因为指针是变量,所以指针也有自己的地址;因为指针存的是地址,所以可以拷贝、比较、加减;因为地址指向某块内存,所以可以通过地址反过来操作那块内存。整个过程没有任何魔法。
1.2 指针赋值到底赋了什么
代码写久了你会发现,指针相关的bug有一大半出在"赋值"这个词上。看这段:
int num = 10; int *p = # int *q = p;q = p,是把p里存的地址拷贝给了q。现在q和p指向同一个num,也就是两块指针变量里装的是同一个门牌号。这里拷贝的是地址,不是把p这个变量本身拷贝过去,更不是创建了一个新的num。
这点特别容易和普通变量赋值混淆。int a = 10; int b = a;是把10复制一份给b,之后改a不影响b。但指针赋值完全不同,q = p之后,通过*q = 100,num就变成100了,用*p看也是100,因为它们操作的是同一块内存。
还有一种赋值是解引用后的赋值:
*p = 100;这里不是给指针赋值,而是通过指针里存的地址,找到那块内存,把100写进去。很多新手把p = &x和*p = x搞混,我现在给你一个自救方法:看到*出现在变量名前且不在声明语句里,你就脑子翻译成"解引用/找到那个地址对应的内存",而不是"给指针赋值"。声明语句里的int *p,*只是类型标注的一部分,告诉你"p是一个存着int地址的变量"。
1.3 &和*:一对相反的运算符
&是取地址,*是解引用,它们互为逆操作。*&x等于x,&*p等于p。
这个性质特别有用。假设函数签名是这样的:
void swap(int *a, int *b) { int tmp = *a; *a = *b; *b = tmp; }调用时:
swap(&x, &y);参数传递的是x和y的地址,函数内部通过解引用直接改写那块内存。如果你只传x和y,那交换的是副本,函数结束什么都留不下。这也是"值传递还是引用传递"问题的根源——C语言里所有形参都是值拷贝,但拷贝的对象是地址时,函数就能通过地址修改外部数据。看清这层,指针在函数传参里的地位就明白了。
2. 数组与指针的爱恨纠缠:指针数组、数组指针与多维数组
2.1 指针数组和数组指针,运算符优先级一锤定音
"指针数组"和"数组指针"是C语言社区经久不衰的辨析题,其实把优先级表格摆出来,5分钟就能分清。
[]的优先级高于*。所以:
int *p[3]; // 先看p,右边是[],说明p是一个数组,数组元素是int*,所以这是"指针数组" int (*p)[3]; // 括号改变顺序,p先和*结合,说明p是一个指针,指向的是"含3个int的数组",所以这是"数组指针"第一行:一个长度为3的数组,每个元素是一个int *。你可以用它放三个int变量的地址:
int a, b, c; int *p[3] = {&a, &b, &c}; p[1] = &b; // 给第二个元素赋值第二行:一个指针,它指向的是"一个整体"。这个整体是3个int排成一排,而不是3个指针:
int arr[3] = {1, 2, 3}; int (*p)[3] = &arr; (*p)[1] = 100; // 等价于 arr[1] = 100;注意,取值必须用(*p)[1],括号不能省。*p得到的是数组名(其实是指向首元素的指针),再[1]取第二个元素。平时写代码用数组指针的机会确实不多,但它在二维数组传参和动态分配二维数组时很好用,后面马上提到。
我教别人记这个知识的时候喜欢用一句话:先看变量名右边,再回来看左边。p[3]说明是个数组,数组里装的是int*;(*p)说明"变量是p、p是指针",指针指的是一个int数组整体。
2.2 指针数组存放字符串:不浪费一滴内存
指针数组最常见的实际用途,是存放一组字符串。C语言没有真正的字符串类型,字符串本质上是一个char数组,而字符串名就是char*。所以把多组字符串用指针数组串起来,是这样的:
char *fruits[] = {"apple", "banana", "cherry", "durian"};这个fruits数组的每个元素都是一个char *,分别指向各字符串字面量的首字符。好处是你不用提前知道每条字符串有多长,也不用给最长的预留空间。你用char table[4][20]这种二维数组当然也行,但固定20字节一行的空间浪费非常明显,"apple"只占6字节还要空出14字节。
我之前写一个命令解析器时用过这个结构:
char *cmd_list[] = {"help", "quit", "status", "send"};用户输入命令后,遍历cmd_list找匹配项,然后调用对应的处理函数。配合函数指针数组(后面会说),一个命令分发中枢就做完了。这种"字符串表驱动"的设计在协议解析、菜单系统、测试工具里到处可见。而且你可以用sizeof(cmd_list) / sizeof(cmd_list[0])算出元素个数,不需要魔法数字。
2.3 C++多维数组与指针:每层解引用都是一次降维
一维数组名是一个指向首元素的指针,二维数组名呢?很多人以为int a[3][4]就是int **,这是天大的误区,我见过太多人在这一步栽跟头。
int a[3][4]中,a的类型实际上是int (*)[4]——指向一个长度为4的int数组的指针。原因很简单:C/C++的数组名会退化为指向其首元素的指针,而二维数组的首元素是a[0],a[0]本身是一个长度为4的int数组,所以a退化为指向这个数组的指针。
int a[3][4] = {{1,2,3,4},{5,6,7,8},{9,10,11,12}}; int (*p)[4] = a; // p指向第一行 p + 1; // 跳过一整行(4个int),指向第二行这里的加法运算和指针步长有关。p + 1不是地址加1字节,而是加sizeof(int[4]),也就是16字节。理解这个"指针加整数的步长由它指向的类型大小决定",多维数组和指针的交互就不再神秘了。
那C++里怎么把二维数组传给函数?答案就藏在类型里。函数签名必须写清楚"数组有几列",行数可以省略:
void printMatrix(int (*m)[4], int rows); // 等价写法 void printMatrix(int m[][4], int rows); // 也是对的如果函数莫名其妙写了个void printMatrix(int **m, int rows),编译器通常会给你个警告,因为类型不匹配。想用int**接收二维数组,你得手动构造一个指针数组,把每行的首地址放进去,本质是另一种数据结构,不是同一个东西。
3. 函数指针、指针函数与函数指针数组:星号决定身份
3.1 指针函数:返回指针的函数
先看最简单的int *func()。优先级上,func()先结合,说明func是一个函数,返回值是int *。这种函数叫作"指针函数",你天天在写,只是没注意:malloc就是典型的void *返回,而它的调用者经常用指针来接收。
写指针函数最常见的坑,是返回局部变量的地址。比如:
int *bad_func() { int local = 42; return &local; // 危险!local是栈上的变量,函数返回后就失效了 }这个指针在函数结束后就成了悬垂指针,访问它属于未定义行为,可能会得到莫名其妙的值,可能直接崩溃,也可能暂时"看起来正常"。正确做法是:要么返回全局变量地址,要么用malloc/new分配堆内存由调用者管理,要么接收调用者传入的缓冲区指针。
3.2 函数指针:把函数地址存下来
int (*funcPtr)(int, int)则完全不同。括号让funcPtr先和*结合,于是它是一个指针,指向的是"返回int、接收两个int参数的函数"。
函数编译后也有地址,函数名就是它的入口地址。于是:
int add(int a, int b) { return a + b; } int (*funcPtr)(int, int) = add; // 或者写完整一点 int (*funcPtr)(int, int) = &add; // 调用 int result = funcPtr(3, 4);funcPtr这个名字让人不适应的地方在于声明语法怪。我理解这种"从内向外的阅读顺序":(*funcPtr)说明它是指针,往右看(int, int)说明它指向的函数接收两个int,往左看int说明函数返回int。
函数指针最大的价值是当作参数传递,也就是回调机制。标准库的qsort就靠这个:
int cmp_int(const void *a, const void *b) { return (*(int*)a) - (*(int*)b); } qsort(arr, n, sizeof(int), cmp_int);你把cmp_int的地址传给qsort,qsort在内部合适时机调用它来决定排序规则。这就是"策略模式"在C语言里的朴素实现。
3.3 函数指针数组:表驱动编程的基础
再往前走一步,函数指针也能放进数组。声明方式就是和int *p[3]一样的思路,只是元素类型是函数指针:
int (*handlers[])(int, int) = {add, sub, mul, div_op};有了这个数组,你就能根据下标直接调用对应函数,替代一大串if-else或switch。这种写法在状态机、命令分发、消息处理里是标配。我之前写网络协议解析器时,收到不同消息类型就查一张函数指针表:
typedef void (*msg_handler)(Msg *msg); static msg_handler handlers[MSG_TYPE_COUNT] = { [MSG_LOGIN] = on_login, [MSG_LOGOUT] = on_logout, [MSG_HEARTBEAT] = on_heartbeat, [MSG_DATA] = on_data, };收到消息后用type当索引,handlers[type](msg)一行搞定分发。新增一种消息类型,只需要增加一个枚举值和一个函数,再往表里注册,回调逻辑不需要改动。这就是表驱动的好处——数据驱动代码,而不是代码堆数据。
3.4 双指针法:指针在算法里的另一种姿态
热搜词里有个"双指针法",严格说它不只是"指针变量",而是一整套算法思想。但既然点到了,就展开说一下。
最经典的是链表探测环:快指针每次走两步,慢指针每次走一步,如果链表有环,快慢指针终会在环里相遇。没有环,快的先走到头。这个算法不需要额外空间,O(1)复杂度就能判断,面试题里经久不衰。
bool hasCycle(struct ListNode *head) { struct ListNode *slow = head; struct ListNode *fast = head; while (fast && fast->next) { slow = slow->next; fast = fast->next->next; if (slow == fast) return true; } return false; }另一个常见场景是有序数组里找两数之和等于目标值。左指针指向头部、右指针指向尾部,根据和与目标的大小关系移动某一边,复杂度从O(n^2)降到O(n)。双指针的智慧在于:把"两个变量间的某种关系"用两个游标来表达,谁该动、往哪动,由当前局面决定。
4. const一掺和,指针就变复杂:常量指针与指针常量
4.1 两种const修饰位置的语义
const的位置不同,锁定的对象不同。常见三种写法:
| 声明 | 名称 | 指针的指向可以改吗 | 指向的值可以改吗 |
|---|---|---|---|
const int *p; | 指向常量的指针(常量化所指对象) | 可以 | 不可以 |
int *const p; | 常量指针(指针本身就是常量) | 不可以 | 可以 |
const int *const p; | 二者都不可变 | 不可以 | 不可以 |
第一种写法也有人写成int const *p,和const int *p完全等价。第三种就是const int *const p或int const *const p,两头都锁死。
第二行的理解关键是:*const修饰的是p本身,"p是一个const变量",也就是指针变量一旦初始化就不能再指向别处,但可以通过它修改它指向的值。
4.2 实用记忆法和传参语义
网上流传过一句口诀:const在星号左边,锁的是"指针所指的值";const在星号右边,锁的是"指针本身"。我验证过无数次,简单好使。你甚至可以再直接一点:从左往右读,const后面跟的是int就是值不能动,跟的是p就是指向不能动。
把const指针用在函数签名里,是C/C++工程里的基本素养:
void printMsg(const char *msg);这个意思是:printMsg只能读取msg指向的字符串,不能修改它。既可以防止函数内部意外篡改调用者的数据,也是自文档化——调用者一看签名就知道"这个函数只读不改"。而如果哪天你传了一个char *给一个期望const char *的函数,编译器是允许的;反过来传const char *给char *参数,编译器会警告甚至报错,因为那等于放弃const承诺。
4.3 指针的指针:二级指针到底什么时候需要
热搜词里也有"指针的指针",这是让很多人头大的知识点。二级指针确实少见,但需要它的时候绕不开。
一个核心应用场景是:函数里要修改指针变量本身。看个经典例子——在链表头部插入节点:
void insertNode(struct Node **ppHead, struct Node *newNode) { newNode->next = *ppHead; *ppHead = newNode; }为什么非要struct Node **不可?因为你要修改head指针变量本身的值(指向新的头节点),而C语言形参是值拷贝,传struct Node *进来,你只能修改指针指向的节点内容,无法让调用者的head指向新节点。传二级指针,*ppHead解引用后就是调用者的head变量本身,改它就能影响外部。
判断"该不该用二级指针"有个简单标准:一级指针能改的是"它指向的内容",二级指针能改的是"一级指针本身"。凡是需要"把某个指针变量重新指向一个新的地方,并且这个改动要保留到函数外面",就得考虑二级指针。如果你只是修改已存在节点的字段,一级指针足够。
C++里很多人用引用来替代这种需求,因为Node* &head做空指针拉起更安全,后面第五节细说。
5. 引用、值传递、指针传递:C++传参的三选一
5.1 三种方式的底层对比
C++出现后,传参的问题多了一个选项:引用。这三者的关系如果只看表面会觉得很乱,但从内存视角看非常清晰:
| 传递方式 | 形参是什么 | 函数内改实参? | 形参能为空吗 | 典型场景 |
|---|---|---|---|---|
| 值传递 | 实参副本 | 不能 | 无所谓 | 读小对象 |
| 指针传递 | 实参地址的值拷贝 | 能(通过解引用) | 能(传nullptr) | 修改实参或允许空 |
| 引用传递 | 实参的别名 | 能 | 不能(必须绑定对象) | 修改实参、避免拷贝 |
值传递本质是"复制一份数据",修改形参不影响实参;指针传递是"复制一份地址",指针自己还是值拷贝,但地址指向的是同一个对象;引用传递没有拷贝地址这个动作,引用是实参的别名,编译器一般就把它当成实参本身来生成代码。
这有个经典问题可以给新手练手:void func(int *p)和void func(int *&p)有什么区别?答案是前者函数内给p重新赋值不影响调用者,后者可以。这正好接上4.3的二级指针——引用让"修改指针变量本身"变得赤裸裸地安全。
5.2 什么时候用引用,什么时候用指针
很多人问现代C++里到底该用哪个,我的建议很直接:
- 参数对象不能为空,传给函数后只需要读/写对象内容,就是
const T&或T&; - 参数可能是
nullptr,比如表示"可选"或"失败",那就用指针; - 需要重新绑定目标,比如循环遍历中让指针在容器元素间移动,用指针;
- 大对象避免值拷贝,用
const T&最经济实惠。
还有一个直接原因:引用语法没有*和->,代码更干净。但引用也有它的短板——必须初始化且不能为空,这就意味着你没法用引用表达"没有东西"这种语义。所以我的原则是:**优先引用,确实需要"空"语义时再退回指针。**这条对日常业务代码完全够用。
5.3 数组参数:隐式退化为指针
C/C++里函数形参如果写int arr[],编译器会把它当成int *arr处理,因为数组太大不可能整体拷贝。所以:
void f(int arr[]) { ... } // 本质是 void f(int *arr)这意味着函数内部没有数组的长度信息,你必须另外传一个长度参数。这也是C语言无数bug的来源之一:sizeof(arr)在函数内部拿到的是指针的大小,而不是数组的大小(C++可以考虑用std::array、std::vector或std::span来根治这个问题)。写代码时遇到"为什么我传了数组,函数里算出来的元素个数不对",十有八九就是这个退化机制在作祟。
6. 结构体指针与文件指针:C语言里绕不开的两种指针
6.1 结构体指针与->运算符
struct和指针结合,是链表、树、图等一切动态数据结构的基石。->运算符的历史地位在于:它把(*p).member这个又丑又容易漏括号的写法,压缩成了p->member。
struct Student { int id; char name[32]; int score; }; struct Student stu = {101, "Zhang", 88}; struct Student *ps = &stu; printf("%s\n", ps->name); // 等价于 (*ps).name ps->score = 95; // 等价于 (*ps).score = 95;结构体指针最喜欢的场景是动态分配内存创建节点:
struct Node *createNode(int val) { struct Node *n = (struct Node*)malloc(sizeof(struct Node)); if (n == NULL) { /* 处理内存分配失败 */ } n->val = val; n->next = NULL; return n; }注意malloc后一定要判断返回值是否为NULL。我见过不少新手直接不判断然后继续解引用,内存不足时程序就莫名其妙崩溃了。严谨的工程代码里,malloc后的判空是基本功,而不是可选项。
写链表类代码时还有个细节:为什么用struct LinkNode **而不是struct LinkNode *来插入头部节点,前面4.3讲过了——因为你要改head指针变量本身的指向,必须拿到它的地址。
6.2 文件指针的实操要点
FILE *fp大概是初学者接触到的第一个"状态型指针"。fopen成功返回一个指针,失败返回NULL。这个指针不只是"文件内容",它背后是操作系统管理的文件句柄、读写位置、错误标志等一整个状态。
基本套路很简单:
FILE *fp = fopen("data.txt", "r"); if (fp == NULL) { perror("fopen failed"); return -1; } char line[256]; while (fgets(line, sizeof(line), fp)) { // 处理一行 } fclose(fp);这里有几个经验级别的教训值得说:
fopen必须配fclose,漏掉它会导致文件句柄泄漏,在循环里反复打开文件迟早把句柄耗尽;feof(fp)不能用来判断"文件读完了",它只在读取操作尝试越过文件末尾后才置位,所以标准写法是"先读取,再判断返回值",而不是"先判断feof再读取";- 文件指针也遵循"要修改底层状态时传指针,只读取时传 const"的原则,例如
fprintf(FILE *stream, ...)把FILE *放在第一位,就是因为它要修改文件状态。
如果项目是C++,我推荐优先考虑std::ifstream/std::ofstream或fopen_s这类带安全边界的方式,手动管理FILE *始终有"忘关、双关、悬垂"三个风险,用RAII包装后楼下要讲的智能指针思路同样适用。
7. 从timer空指针报错看空指针排查套路
7.1 问题现场与根因分析
"timer执行查询时报告空指针"这类问题,在服务器端、GUI程序里都很常见。前两天我调一个定时任务时也踩过一模一样的坑:一个定时器每5秒触发一次,回调里会去访问一个全局指针指向的对象,然后某天运行到一半,崩溃日志里明明白白写着"null pointer dereference"。
排查的第一个动作不是猜,而是用调试器把崩溃点的所有变量值打出来。通常做法是先看堆栈:崩在哪个函数、哪一行、访问了哪个地址。如果是0x0或者接近0,基本就是空指针。
接下来要问自己三个问题:
- 这个指针变量在哪里初始化的?
- 在哪里可能被置成NULL?
- 有没有可能对象已经被释放,指针还没来得及置NULL?
定时器场景最容易出问题的点是生命周期不同步。对象是在主线程创建的,可能在某次关闭流程里被释放并置成NULL了,但定时器回调在另一个线程或者稍晚时刻还在引用它。此时你看到它"为NULL",实际是对象已经被销毁。
我那次排查的记录大致是这样的:
崩溃位置: TimerTask::execute() -> queryData() 指向对象: DataSource *ds_ 报错值: ds_ 为 0x0 线索: ds_ 在 StopDataService() 里被释放,但没有反注册定时器 结论: 关闭服务时先停止了数据源,却忘了停止定时器,导致一次“晚到的定时回调”访问了已释放对象解决方式也很简单:关闭数据源之前先停掉定时器,或者进入回调时先检查ds_是否为NULL并加锁。这个案例说明,空指针从来不只是"加个if判空"这么简单,它背后往往是对象生命周期管理的问题。
7.2 空指针防御的正确姿势
行业内对空指针有一系列成熟的防御措施,按优先级排序:
- 第一道防线:对象创建后立即初始化指针,杜绝"未初始化"的野指针;
- 第二道防线:所有能返回NULL的调用(malloc、fopen、new、查询接口)都要检查返回值;
- 第三道防线:析构或释放的时候把指针置NULL(release-and-null),这样别的模块访问时至少能得到明确的"空"而不是野地址;
- 第四道防线:使用智能指针或引用,让"空"这个状态从类型层面消失,能编译出错的就不留给运行时。
判空本身有个细节:最好把可能NULL的指针放在等式左边,比如if (NULL == p),防止手滑写成if (p = NULL)。这个经典错误在C语言里连编译都通过,坏在运行时。很多现代编译器会对if (p = NULL)报警告,但习惯写NULL == p可以从源头刹住这辆车。
还要提醒一句:在某处判空了,不代表另一处安全。多线程环境下,第一次判空通过、第二次访问前对象已经被别的线程释放,这叫TOCTOU(时间检查和使用的时间差)。完全靠判空是防不住的,要从生命周期管理层面解决。
8. 智能指针:现代C++给裸指针的"收尸服务"
8.1 裸指针的三个老大难
裸指针最大的问题不是"指针"这个概念,而是资源所有权不清晰。你new了一个对象,到底谁负责delete?函数A创建了对象传给函数B,B用完了要不要释放?如果B里某条路径抛了异常,delete还能执行到吗?
这三个问题在大型项目里会被放大成类别的bug:内存泄漏、双重释放、悬垂指针。C++社区为此搞出了RAII思想——资源管理绑定到对象生命周期。你创建一个对象,它构造时申请资源、析构时释放资源,使用者根本不用手动调delete。智能指针就是RAII思想最经典的代表。
8.2 unique_ptr、shared_ptr、weak_ptr怎么选
三种智能指针最常见的对比表我直接整理出来:
| 智能指针 | 所有权模型 | 引用计数 | 典型场景 |
|---|---|---|---|
std::unique_ptr | 独占所有权 | 无 | 工厂函数返回新对象、类持有成员资源 |
std::shared_ptr | 共享所有权 | 有 | 多个模块共同使用同一个对象、缓存 |
std::weak_ptr | 观察者/弱引用 | 不计数 | 打破循环引用、缓存会话、检测对象是否存活 |
选型时的判断顺序很简单:默认优先unique_ptr,它开销最小、语义最清晰,一个对象只能被一个人拥有;当确实有多个人要一起持有时,升级成shared_ptr;如果只是"想看它一眼,但不希望因此延长它的生命周期",用weak_ptr。
这里必须讲循环引用的坑。考虑两个对象互相持有shared_ptr,它们的引用计数永远降不到0,谁也释放不了,内存泄漏了但代码看起来毫无问题。解决办法是让其中一方持有weak_ptr,打破环:
struct Node { std::shared_ptr<Node> next; std::weak_ptr<Node> prev; // 这一侧用弱引用,避免循环引用 };weak_ptr还有一个用处:判断某个对象是否已经被释放。你lock()一下,能拿到shared_ptr就说明对象还活着,拿不到说明已经销毁。这是排查"timer回调访问已释放对象"的好帮手。
8.3 自己动手实现一个简化版shared_ptr
很多人面试被问"智能指针实现",我建议动手写一个简化版,理解就会非常深。shared_ptr的核心就一句话:维护一个引用计数,拷贝时计数+1,析构时计数-1,计数归零时释放资源。
class MySharedPtr { public: MySharedPtr(int* p) : ptr(p), count(new int(1)) {} MySharedPtr(const MySharedPtr& other) : ptr(other.ptr), count(other.count) { (*count)++; } ~MySharedPtr() { if (--(*count) == 0) { delete ptr; delete count; } } int& operator*() { return *ptr; } private: int* ptr; int* count; };这个示例简化了线程安全、自定义删除器、类型推导等,但核心逻辑就是"引用计数+RAII"。你拿着这个思路去看标准库源码,很多看不懂的地方会豁然开朗——无非是它把这种模式做成了通用的、线程安全的、支持各种类型的模板罢了。
顺带一提,我们平时说的"智能指针实现"还有另一层含义:利用栈对象的析构函数在作用域结束时自动执行,保证任何路径——包括异常路径——都能释放资源。理解这一点,比背十个API都值钱。
踩过几次坑之后的总结
写硬核知识点容易越写越长,最后说点掏心窝的经验。
指针这东西,恐惧感多半来自抽象层级跳跃:一会儿把它当值,一会儿把它当地址,一会儿又要解除引用。我的经验是把所有指针问题都降到"地址值"这个层面去想:**指针变量存的是数,一个地址数;*是根据地址去访问内存;&是获取某个变量的地址;[]就是基础地址加偏移量的语法糖。**所有复杂的指针数组、函数指针、多维数组、二级指针,本质都是这一条线的排列组合。
第二个经验是:永远不要忽略生命周期。几乎所有的"空指针报错"、"异常崩溃"、"内存越界",最后都指向同一个根源——对象生命周期和引用它的时机不匹配。裸指针时代靠脑子和规范硬撑,C++11之后就大胆用智能指针,把所有权关系写进代码里,让编译器替你看着这份契约。
最后一个建议比较实际:想系统攻克指针,手头放一本经典的《C和指针》或者《C++ Primer》,遇到绕不清的优先级、语义、陷阱,别用碎片化搜索去碰运气,认认真真翻到对应章节读一遍、敲一遍例子。我对指针的一次次通透,都不是记住了多少语法,而是靠"自己动手写代码、自己造bug、自己解bug"换来的。纸上谈兵解决不了指针问题,多写几次链表、多跑几次内存检测工具,比谁讲得天花乱坠都管用。