指针这个词,几乎是每个学 C 和 C++ 的人绕不过去的一道坎。我见过太多人,语法书翻了两三遍,*和&也认得,可一到自己动手写代码就出问题:要么段错误崩得莫名其妙,要么函数里的修改传不出去,要么面试被问一句"函数指针和指针函数有什么区别"当场卡壳。问题不在于他们不努力,而在于大多数教材把指针当成一个孤立的语法点来讲,没有先告诉你它背后那张"内存地图"长什么样。我写这篇的初衷,就是想把指针从内存模型开始,一层一层拆到底,让刚入门的人能建立直觉,让已经写过一阵子代码的人能补上那些一直含糊的角落。文中涉及指针变量、指针用法、指针数组、函数指针、智能指针、结构体指针这些高频概念,我会尽量用可复现的代码配上真实运行结果来讲,而不是停留在定义层面。
1. 先建立一张内存地图:指针到底在指什么
很多人学指针的第一个坑,是把它当成一种"特殊的数据类型"来死记硬背,结果越记越乱。真正省力的做法是先接受一个画面:内存就是一条很长很长的走廊,走廊两侧排列着一间间小房间,每个房间有唯一的门牌号,房间里能放一个字节的数据。这个门牌号就是地址,房间里的内容就是值。变量在你眼里可能是int a = 10;,但在内存看来,它不过是某几间连着的房间,门口挂着一个地址牌,里面存着10。指针这个概念,本质上就是一个专门用来记录"门牌号"的变量。
1.1 变量、地址与取址运算符
先看一段最短的代码,把"地址"这个东西变得可见。
#include <stdio.h> int main(void) { int a = 10; printf("a 的值: %d\n", a); printf("a 的地址: %p\n", (void*)&a); printf("a 占用的字节数: %zu\n", sizeof(a)); return 0; }在 64 位机器上跑,输出大概是这样:
a 的值: 10 a 的地址: 0x7ffd4a3c9b4c a 占用的字节数: 4这里有两件事值得说清楚。第一,&a取的是a的起始地址,%p是专门打印地址的格式符;第二,int占 4 个字节,也就是说a在内存里实际占用了从0x...b4c开始的连续 4 间房间。为什么强调"起始地址"?因为后面讲指针类型的时候,正是这个"起点加长度"决定了指针加减运算走多远。
初学者最容易忽略的点是:地址是运行时才确定的。同样的代码,你今天跑出来的地址和明天跑出来的地址大概率不一样,因为操作系统每次加载程序分配的位置不同。这也是为什么你不能把某个打印出来的地址硬编码进程序里——它只在当次运行有效。理解了这一点,你就明白了为什么指针的一个核心用途是"动态地找到目标",而不是"固定地记录目标"。
提示:
%p期望的参数类型是void*,打印非void*指针时最好显式转换一下,否则在某些编译器上会有警告。
1.2 指针变量的声明、取址与解引用
知道了地址,指针就好懂了:指针变量就是一个存放地址的变量。声明时用类型加星号,比如int *p;表示p是一个指向int的指针。这里有个经典的困惑——int *p到底是int*是类型,还是*p是变量?我一般跟新人说:看成int (*p);,星号跟着变量走,是"解引用运算符",只是声明时借用了它的形状。这样你再看到int *p, q;就不会误会了——这里只有p是指针,q是普通的int。
#include <stdio.h> int main(void) { int a = 10; int *p = &a; // p 保存 a 的地址 printf("p 指向的值: %d\n", *p); // 解引用,拿到 10 *p = 20; // 通过指针修改 a printf("a 现在的值: %d\n", a); // 20 return 0; }*p出现在等号右边,是"取值";出现在等号左边,是"改值"。同一个符号,两种身份,取决于它在表达式里的位置。这是指针语法里最需要形成肌肉记忆的一点。我建议新手把这段代码改着玩:把p换成别的变量,把*p换成a,观察每次输出怎么变,几轮下来直觉就建立起来了。
1.3 指针类型究竟有什么用
既然地址就是一个数字,那为什么还要区分int*、char*、double*?干脆全部用同一种"地址类型"不就行了?这是很多人心里的疑问。答案在于两件事:解引用时读几个字节,以及加减运算时跳几步。
int*解引用会一次取出 4 个字节并当成整数解释,char*只取 1 个字节,double*取 8 个字节。如果类型搞错,读出来的就是错乱的位模式。加减运算更直观:
int arr[3] = {1, 2, 3}; int *p = arr; printf("%p\n", (void*)p); printf("%p\n", (void*)(p + 1)); // 地址增加了 sizeof(int),通常是 4p + 1不是地址加 1,而是加了一个sizeof(int)。指针的类型决定了它的"步长"。这在遍历数组、操作结构体时非常关键。我见过有人用char*去遍历int数组,结果只走到了第一个字节,后面全乱套,就是没理解步长这回事。
| 指针类型 | 解引用读取字节 | 加一的地址偏移(64位常见值) |
|---|---|---|
char* | 1 | +1 |
short* | 2 | +2 |
int* | 4 | +4 |
double* | 8 | +8 |
记住这张表,指针运算里 90% 的"越界"和"跳错位置"问题都能自己推出来。
2. 指针赋值、空指针与野指针的家族关系
写完第一段,很多人已经能看懂指针的基本操作了,但一放到真实项目里,崩溃反而变多了。原因多半出在指针的"生命周期"和"有效性"上。一个指针变量在声明的那一刻,里面是什么值?如果你没初始化,它可能是一个彻底随机的数字,指向一片你根本没权限访问的内存。这时候解引用,程序立刻崩。搞清楚空指针、野指针、悬空指针的区别,是写 C 代码的基本安全线。
2.1 指针赋值与指向关系
指针赋值有两种含义,初学者特别容易混。第一种是改指向,让指针去指别的地方;第二种是改内容,通过指针改它指向的那块内存。
int a = 1, b = 2; int *p = &a; // p 指向 a int *q = &b; // q 指向 b *p = 100; // 改内容:a 变成 100,q 不受影响 p = q; // 改指向:p 现在指向 b printf("%d, %d\n", a, *p); // 100, 2p = q之后,p和q指同一块内存,通过任意一个改,另一个都能看到。这种"别名"现象是双向链表、图结构里指针操作的基础,也是很多诡异 bug 的源头——你以为动的是 A,其实改到了 B 头顶上。我的经验是,只要一段代码里出现了两个以上指向同一对象的指针,就要格外小心,最好用注释标出谁是谁的别名。
2.2 空指针、野指针、悬空指针的区别
这三个词经常被混用,但它们描述的是不同状态:
- 空指针:值为
NULL或nullptr的指针,明确表示"我不指向任何有效对象"。解引用空指针必然崩溃,但至少它是有明确语义的。 - 野指针:没有被初始化的指针,值随机,指向哪里完全靠运气。这是最危险的一种,因为它可能"看起来能用",跑十次崩一次,极难排查。
- 悬空指针:曾经指向有效内存,但那块内存已经被释放或回收,指针却还留着旧地址。再解引用就是访问已释放内存,行为不可预测。
int *wild; // 野指针,千万别解引用 int *empty = NULL; // 空指针 if (empty != NULL) { // 安全使用 } int *dangling = (int*)malloc(sizeof(int)); free(dangling); // dangling 现在是悬空指针,不能再 *dangling dangling = NULL; // 释放后立刻置空,是好习惯我个人的习惯是两条铁律:声明指针时立刻初始化为NULL,释放内存后立刻把指针置空。这两条能消灭我见过的大半指针事故。尤其是第三条——释放后置空,代价只是一行赋值,收益是避免任何一次"用已释放内存"的崩溃。
注意:
free之后那块内存可能马上被系统复用,悬空指针读到的值可能"看起来正常",这让问题更加隐蔽。不要依赖运气。
2.3 指针的指针与多级间接寻址
既然指针是变量,那它自己也有地址,于是就有"指向指针的指针",也就是二级指针int **pp。多级间接寻址听起来吓人,其实就一层一层剥洋葱。
int a = 5; int *p = &a; int **pp = &p; printf("%d\n", **pp); // 5 **pp = 50; // 等价于 a = 50 printf("%d\n", a); // 50二级指针最常见的真实用途,是让函数能修改调用方的一个指针变量。比如动态分配一个数组,如果函数只接收一级指针,它改的是副本,外面的指针不动;必须传指针的地址,也就是二级指针:
void alloc_array(int **arr, int n) { *arr = (int*)malloc(n * sizeof(int)); } // 调用:alloc_array(&myArr, 10);再往上,三级指针int ***在实际业务里很少出现,除非你在处理多维动态数组或者某些库的回调签名。我看到三级以上指针的第一反应是"这里能不能用结构体包一下",往往可读性会好很多。指针的指针、多级指针这些词在面试里出现频率不低,但工作中真正需要层层套的场景其实有限,理解原理比死记用法更重要。
内存扫描类工具在查找动态数据时,依赖的正是"静态基址 + 多级偏移"这种链式寻址思路:每一级指针指向下一级,最后落到目标数据。程序重启后,动态分配的内存位置变了,如果某一级基址不稳定,整条链就断了,表现就是"扫描得到、重开就失效"。理解多级指针的本质,你就能明白这类现象背后的原因,而不是把它当成玄学。
3. 数组、字符串和指针之间那点事
如果说前面的内容是打地基,那数组与指针就是最容易塌方的一层。几乎所有 C 语言的"迷惑行为"都集中在这里:数组名到底是不是指针?sizeof(arr)为什么在函数里变了?char *s和char s[]有什么区别?把这几件事讲透,你对指针的理解会上一个大台阶。
3.1 数组名到底是什么
数组名在大多数表达式里会"退化"成指向首元素的指针,这是关键。arr等价于&arr[0],所以*(arr + i)和arr[i]完全等价——方括号本质上是语法糖,编译后就是地址加法加解引用。
int arr[4] = {10, 20, 30, 40}; printf("%d\n", *(arr + 2)); // 30 printf("%d\n", arr[2]); // 30但数组名不是指针,这一点在sizeof面前暴露无遗:
printf("%zu\n", sizeof(arr)); // 16,整个数组大小 printf("%zu\n", sizeof(int*)); // 8,一个指针的大小数组名是"数组类型",有确定的长度信息;指针只是一个地址。当数组作为函数参数传递时,它会退化成指针,长度信息丢失,这就是为什么必须额外传一个n:
void print_arr(int arr[], int n) { printf("%zu\n", sizeof(arr)); // 8,已经退化成指针了 for (int i = 0; i < n; i++) printf("%d ", arr[i]); }初学者常犯的错就是在函数里用sizeof(arr) / sizeof(arr[0])算长度,结果永远是 1 或 2,因为此时arr是指针。这个坑我踩过,也见无数人踩过,记住它值得。
3.2 指针数组与数组指针别搞混
这两个名字就差一个字,含义完全不同,靠"哪个词在后面"来判断:指针数组是数组,数组指针是指针。
int *p[5];是指针数组:一个包含 5 个元素的数组,每个元素都是int*。int (*p)[5];是数组指针:一个指针,它指向"含 5 个 int 的数组"。
读法上,从变量名出发,先看右边再看左边:p[5]说明先和[]结合,是指针数组;(*p)说明先和*结合,是指针。这个"由内向外"的读法是破解所有复杂声明(包括函数指针)的通用方法。
int *parr[3]; // 指针数组:3 个 int* int (*prow)[3]; // 数组指针:指向含 3 个 int 的数组 int matrix[2][3] = {{1,2,3},{4,5,6}}; prow = matrix; // 数组指针可以指向二维数组的行 printf("%d\n", prow[1][2]); // 6我在实际项目里,指针数组用得比数组指针多得多,尤其是处理一组不定长字符串的时候。
3.3 用指针数组存放字符串
指针数组最经典的场景就是保存一批字符串:
const char *names[] = {"Alice", "Bob", "Charlie", "Dave"}; for (int i = 0; i < 4; i++) { printf("%s\n", names[i]); }这里每个元素是一个指向字符串字面量的指针,字符串本身存在只读区,指针数组只存地址。好处是灵活:数组长度固定,但每个字符串长度可以任意。对比char names[4][20]这种二维字符数组,后者会浪费不少空间,因为它给每个字符串都预留了 20 字节。
用指针数组存字符串时有两个注意点。第一,字符串字面量是只读的,names[0][0] = 'a'会崩,想要可修改的字符串得用char buf[] = "..."。第二,指针数组的元素可以被重新赋值,指向别的字符串,这一点比二维数组灵活得多。
| 存法 | 内存占用 | 字符串可变 | 元素可否重定向 |
|---|---|---|---|
char names[4][20] | 固定 80 字节 | 可以 | 不行 |
const char *names[4] | 指针 32 字节 + 字符串本体 | 不行(只读区) | 可以 |
选哪种,取决于你的字符串是否需要修改、长度是否差异大。
3.4 快慢指针解决有序数组原地去重
快慢指针是双指针技巧里最实用的一类,用在有序数组原地去重上特别典型。题目大意是:给一个有序数组,原地删除重复元素,返回新长度,不能开额外数组。解法就是让slow指向已处理部分的末尾,fast在前面探路,遇到和slow不同的值就把slow往前挪一格并覆盖。
int removeDuplicates(int *nums, int n) { if (n == 0) return 0; int slow = 0; for (int fast = 1; fast < n; fast++) { if (nums[fast] != nums[slow]) { slow++; nums[slow] = nums[fast]; } } return slow + 1; }这个写法的时间复杂度是 O(n),空间复杂度 O(1),一次遍历搞定。为什么它能工作?因为数组有序,重复元素一定相邻,slow维护的区间永远是不含重复的"已确定区",fast找到新的不同值就往里塞。我自己写这类题时,习惯先在纸上画两个格子,标出slow和fast的位置,模拟几步再下笔,比空想稳得多。这种指针技巧在很多语言里都通用,原理都是"一个指针读、一个指针写",读的跑得快,写的跟着走。
4. 函数指针和指针函数,别被名字骗了
这一对是面试里的高频陷阱题,也是很多人在项目里想用却不敢用的东西。名字看着像,实际差得远,判断方法和前面一样,看变量名先和谁结合。
4.1 函数指针:指向函数的指针
函数在内存里也有一段代码,也有入口地址,所以可以有指针指向它。声明一个函数指针:
int add(int a, int b) { return a + b; } int sub(int a, int b) { return a - b; } // 声明一个指向"接收两个int返回int"的函数指针 int (*op)(int, int) = add; printf("%d\n", op(3, 4)); // 7 op = sub; printf("%d\n", op(3, 4)); // -1函数指针最大的价值在于回调和解耦。比如你要写一个通用计算函数,让调用方决定具体用哪种运算:
typedef int (*BinaryOp)(int, int); int compute(BinaryOp fn, int a, int b) { return fn(a, b); } printf("%d\n", compute(add, 3, 4)); // 7 printf("%d\n", compute(sub, 3, 4)); // -1typedef会让函数指针类型好写很多,我强烈建议用typedef把函数指针类型单独定义出来,否则像int (*)(int, int)这种写在一长串参数里,读代码的人会很痛苦。在 C 里,qsort的最后一个参数就是函数指针,让你自定义比较规则,这是标准库里最经典的用法。C++ 里还可以用std::function和 lambda,写起来更自然,但底层原理依然是函数指针那一套。
注意:函数指针和普通指针不同,对它做算术运算(比如
op + 1)没有意义,也不要试图解引用出一段代码数据。它的唯一用途是调用。
4.2 指针函数:返回指针的函数
指针函数其实不是"指针",而是一个函数,只是它的返回值类型是某种指针。判断方法还是老一套:int *f(int x);里f先和(int x)结合,说明它是函数,返回值是int*。
int* findMax(int *arr, int n) { int *max = &arr[0]; for (int i = 1; i < n; i++) { if (arr[i] > *max) max = &arr[i]; } return max; } int arr[] = {3, 7, 2, 9, 5}; int *m = findMax(arr, 5); printf("%d\n", *m); // 9写指针函数最需要注意的就是:不要返回局部变量的地址。局部变量在函数返回后就被回收了,返回它的地址得到的是悬空指针,外面用一下就可能崩。
int* bad(void) { int x = 10; return &x; // 危险!x 已经随函数结束而销毁 }要返回指针,要么返回传入参数的地址,要么返回动态分配的内存(并约定好由谁释放),要么返回静态变量或全局变量的地址。
| 对比项 | 函数指针 | 指针函数 |
|---|---|---|
| 本质 | 指针 | 函数 |
| 声明示例 | int (*p)(int,int) | int *p(int,int) |
| 判断方法 | 变量先和*结合 | 变量先和()结合 |
| 常见用途 | 回调、策略切换 | 返回数组/结构体/字符串 |
4.3 实际项目里怎么选
函数指针适合"行为要变"的场景,比如一个排序库,允许用户自定义比较方式;指针函数适合"结果是一块数据"的场景,比如解析函数返回一个动态构造的结构体,或者查找函数返回匹配元素的位置。二者也可以组合,函数指针可以当参数,指针函数可以当回调的返回值。我自己的经验是,只要你觉得"这里未来可能换一种算法",就用函数指针把变化点抽出来;只要你觉得"返回一个对象太大、拷贝太贵",就考虑返回指针。判断标准始终是需求,不是炫技。
5. 结构体指针与 const 修饰的细节
前面几节主要围绕基础类型和函数,真实项目里面向对象风格的数据组织大量依赖结构体指针。而const和指针搅在一起,又衍生出一堆让人头晕的组合。这一节把这两个高频痛点收掉。
5.1 结构体指针的访问方式
结构体指针通过->访问成员,p->x等价于(*p).x。->之所以被造出来,就是因为在指针很多的情况下,(*p).x写起来太啰嗦、括号又容易漏。
typedef struct { int id; char name[32]; double score; } Student; Student s = {1, "Alice", 92.5}; Student *p = &s; printf("%d, %s, %.1f\n", p->id, p->name, p->score); p->score = 95.0; // 通过指针修改结构体指针在链表、树里是命脉。以单链表节点为例:
typedef struct Node { int data; struct Node *next; } Node; void push_front(Node **head, int val) { Node *node = (Node*)malloc(sizeof(Node)); node->data = val; node->next = *head; *head = node; }这里用二级指针Node **head,是因为头指针本身需要在函数里被修改。如果不加二级,函数里改的只是副本,外面的head不变,新节点就"丢了"。这个坑极其常见,我的建议是:只要函数涉及"增删头节点",一律传二级指针,养成习惯。
5.2 顶层 const 和底层 const
const和指针的组合有四种常见写法,很多人分不清。判断技巧:从右往左读,const修饰它左边的东西;如果左边没有,就修饰右边。
const int *p;等价于int const *p;——底层 const,*p不可改,p可改指向。int * const p;——顶层 const,p本身不可改指向,*p可改。const int * const p;——两者都不可改。
int a = 1, b = 2; const int *p1 = &a; // *p1 = 5 非法;p1 = &b 合法 int * const p2 = &a; // *p2 = 5 合法;p2 = &b 非法那顶层和底层 const 可以相互赋值吗?这是热词里出现过的问题。答案是有条件:顶层 const 在拷贝时会被忽略,所以int*可以赋给const int*(增加限制是安全的,这叫 qualify conversion),反过来const int*给int*则不允许,因为那等于丢掉限制,可能让你通过新指针改掉本该只读的数据。简单记:加 const 容易,去 const 要显式转换。
int a = 10; const int *cp = &a; // 合法,加上只读限制 // int *p = cp; // 非法,去掉限制编译器不允许 int *p = (int*)cp; // 强制转换能过,但你要自己保证不写坏这套规则的意义在于,const是给编译器和读代码的人看的"契约"。函数参数写const char*,就是在声明"我不会改你传进来的字符串",调用方看到就能放心传字面量,不用拷一份。
5.3 void 万能指针的边界
void*是一种"无类型指针",可以接收任意类型的指针,也可以转成任意类型。它最典型的用途是通用内存操作,比如手写一个memcpy风格的函数:
void* my_copy(void *dst, const void *src, size_t n) { char *d = (char*)dst; const char *s = (const char*)src; while (n--) *d++ = *s++; return dst; }void*不能直接解引用,也不能做加减运算,因为编译器不知道步长。必须先转成具体类型再用。它给了 C 语言一种"弱泛型"的能力,标准库的malloc返回的就是void*,C 里不用强转,C++ 里则需要转成具体类型。用void*的代价是类型信息丢失,容易在转换时出错,所以在能确定类型的地方,我一般不用它。
6. 智能指针:现代 C++ 的资源管理思路
如果你在写 C++,那手动new/delete的时代其实可以翻篇了。智能指针的核心思想是把"释放资源"这件事交给对象的生命周期去管:对象出作用域自动析构,析构里顺便把内存还了。这解决了手动管理最容易出问题的两件事——忘记释放(内存泄漏)和提前释放(悬空指针)。
6.1 三种智能指针的分工
C++11 之后主要有三种:
std::unique_ptr:独占所有权,不能拷贝,只能移动。开销接近裸指针,是默认首选。std::shared_ptr:共享所有权,内部有引用计数,最后一个持有者析构时才释放。std::weak_ptr:配合shared_ptr使用,不增加引用计数,用来打破循环引用。
#include <memory> #include <iostream> int main() { std::unique_ptr<int> up = std::make_unique<int>(42); std::cout << *up << "\n"; // 42 std::shared_ptr<int> sp1 = std::make_shared<int>(7); std::shared_ptr<int> sp2 = sp1; // 引用计数变 2 std::cout << sp1.use_count() << "\n"; // 2 return 0; // 自动释放,不需要 delete }我选型的原则很简单:默认用unique_ptr,确实需要共享才升级到shared_ptr。因为shared_ptr的引用计数是有成本的(原子操作),而且循环引用会直接导致泄漏,滥用它反而更危险。
6.2 手写一个简易智能指针
想真正理解智能指针,最好的办法是自己写一个。核心就三点:构造时接管指针,析构时释放,禁止拷贝(或实现引用计数)。下面是简化版:
template <typename T> class SimplePtr { public: explicit SimplePtr(T *p = nullptr) : ptr_(p) {} ~SimplePtr() { delete ptr_; } SimplePtr(const SimplePtr&) = delete; SimplePtr& operator=(const SimplePtr&) = delete; SimplePtr(SimplePtr&& o) noexcept : ptr_(o.ptr_) { o.ptr_ = nullptr; } SimplePtr& operator=(SimplePtr&& o) noexcept { if (this != &o) { delete ptr_; ptr_ = o.ptr_; o.ptr_ = nullptr; } return *this; } T& operator*() const { return *ptr_; } T* operator->() const { return ptr_; } T* get() const { return ptr_; } private: T *ptr_; };写完这段,你就明白为什么unique_ptr禁止拷贝、只能移动了:如果允许拷贝,两个对象析构时会delete同一块内存,直接二次释放崩溃。移动语义把所有权"交出去",原对象置空,避免重复释放。这个思路就是智能指针的灵魂。
6.3 循环引用与 weak_ptr 的用武之地
shared_ptr最典型的坑是循环引用:A 持有 B 的shared_ptr,B 又持有 A 的shared_ptr,两者引用计数永远不为 0,谁都不析构,内存就泄漏了。解决办法是把其中一方的持有改成weak_ptr。
struct B; struct A { std::shared_ptr<B> b; }; struct B { std::weak_ptr<A> a; // 用 weak 打破循环 };weak_ptr不增加引用计数,使用前要用lock()提升为shared_ptr,判断对象是否还活着。这是设计上"谁拥有谁、谁只观察谁"的表达。我在项目里遇到对象之间互相引用时,会先问一句"这个引用需不需要延长对方生命",不需要的一律用weak_ptr。
注意:不要用
shared_ptr管理数组,除非显式指定删除器;也不要对同一个裸指针构造两个独立的shared_ptr,那会让两个控制块都以为自己是唯一主人,最后双重释放。
7. 常见问题排查:指针崩溃到底怎么找
指针知识学得再多,最后都要落到"代码崩了怎么办"。这一节我整理了自己和同事实际遇到过的典型问题,配上排查思路,做成一份可以直接对照的清单。
7.1 段错误、空指针、悬空指针的排查顺序
遇到段错误(Segmentation fault),我的排查顺序基本固定:
第一步,确认崩溃位置。用gdb跑一遍,bt看调用栈,print看当前指针的值。九成的空指针问题,打印一下指针就能看出来。
gcc -g main.c -o main gdb ./main # 在 gdb 里:run,崩溃后 bt,然后 print 变量名第二步,判断是哪种指针问题。指针值是0x0,就是空指针解引用;指针值很怪(比如0x1、0xffffffff),多半是野指针或已越界;指针值看着正常但一访问就崩,可能是悬空指针。
第三步,定位来源。空指针回溯哪里没赋值,悬空指针回溯哪里free或delete过,野指针回溯哪里声明时没初始化。用valgrind可以自动帮你发现大部分内存错误:
valgrind --leak-check=full ./main它会把"使用未初始化内存""访问已释放内存""内存泄漏"直接报出来,是排查内存问题的利器。
还有一种迷惑情况:程序里报"空指针",但错误信息看起来跟 C 代码没关系。比如某些 EDA 工具报error (orcap-5004): error initializing com property pages: 无效指针,这里的"无效指针"其实是软件内部 COM 组件初始化失败,跟你在写的 C 指针没半毛钱关系,只是恰好用了同一个词。遇到这类报错,先确认它属于哪个软件的哪一层,别往自己代码上套。
再比如定时器执行查询时报空指针,常见原因是查询依赖的对象还没初始化就触发了定时器回调,或者对象已经被释放但定时器还在跑。排查点是:回调触发时那个对象是否还存活。这类问题和 C 语言里的悬空指针本质相同,都是"生命周期没对齐"。
7.2 常见问题速查表
我把高频问题整理成一张表,遇到时对照着看:
| 现象 | 可能原因 | 排查/解决 |
|---|---|---|
| 解引用直接崩 | 空指针或野指针 | 打印指针值,声明时初始化 |
| 释放后再访问崩 | 悬空指针 | 释放后置 NULL,用 valgrind |
| 函数里改的值外面没变 | 传的是值不是地址 | 传指针或二级指针 |
函数里sizeof(arr)不对 | 数组退化成指针 | 额外传长度参数 |
| 返回局部变量地址 | 悬空指针 | 改用参数传入或动态分配 |
| 循环引用内存不释放 | shared_ptr互相持有 | 一方改weak_ptr |
| 二次释放崩溃 | 两个指针指向同一内存 | 明确所有权,用智能指针 |
| 指针加一位置跳错 | 类型不匹配 | 检查指针类型的步长 |
提示:把编译器警告开到最大(
-Wall -Wextra),很多指针问题在编译期就能被提醒。别嫌吵,警告基本没有冤枉的。
我在实际使用中的体会是,指针相关的 bug 有一个共同特征:它们在"看起来能跑"和"崩溃"之间随机切换,因为内存布局、释放时机、运行环境都会影响结果。所以与其事后排查,不如把几条预防规则刻进肌肉记忆:初始化、释放后置空、明确所有权、边界检查、开高警告级别。这几条看起来朴素,但能挡掉绝大多数麻烦。
最后再分享一个小技巧。每次遇到看不懂的复杂指针声明,别硬猜,用"由内向外、从变量名出发先看右边再看左边"的方法一步步拆,或者在编辑器里用typedef把它一层层剥开,你会发现再吓人的声明都能拆成几条简单规则。指针本身不复杂,复杂的是它和数组、函数、const、生命周期纠缠在一起时的组合。把每个概念单独吃透,再组合起来,就没有绕不过去的坎。