多级指针的恐惧,大多来自把它想象成了什么神秘结构。其实它平凡得让人失望:一级指针存变量的地址,二级指针存指针的地址,地址里存地址,如此而已。本章把它讲透,顺便还它一个公道:你在第 1 章就见过它(main 的 argv),在第 5 章也用过它(动态二维数组),本章补上最重要的一课:什么时候需要传二级指针(答案是「要改指针本身」),以及为什么三级以上就该怀疑设计。原文小节编号错成 6.x,一并修正。
int x = 42;
int *p = &x; // p:存 x 的地址
int **pp = &p; // pp:存 p 的地址
**pp; // 两次解引用:**pp = x = 42
**pp = 99; // 改 x
内存图(第 2 章的画风):
栈
┌─────┐ ┌─────┐ ┌─────┐
│ pp ●─┼───▶│ p ●──┼───▶│ 42 │ x
│8字节│ │8字节 │ │4字节 │
└─────┘ └─────┘ └─────┘
int** int* int
类型读法:*pp 是 int*(也就是 p 本身),**pp 才是 int。机器级行为是两次间接寻址(计组第 10 章:先 mov rax,[pp] 拿到 p 的值,再 mov rbx,[rax] 拿到 x 的值),每多一颗星多一跳,这是「指针税」(数据结构课第 12 章)的乘法版。
int main(int argc, char **argv) // 等价于 char *argv[]
argv 是「指针数组」(第 5 章的流派二):每个 argv[i] 是一个 char*,指向一个命令行参数字符串,末尾挂一个 NULL 哨兵:
argv ──▶ [●]──▶ "./prog" argv[0]
[●]──▶ "-v" argv[1]
[●]──▶ "file.txt" argv[2]
[∅] NULL argv[argc]
两种等价遍历,感受二级指针的两种口吻:
for (int i = 0; i < argc; i++) puts(argv[i]); // 下标版
for (char **p = argv; *p; p++) puts(*p); // 纯指针版:靠 NULL 终止
(纯指针版不需要 argc,哨兵本身就是终点信号,这是 C 处理「不知道多长」的经典套路,第 6 章的 '\0' 是单字符版,这里是字符串版。)
一级指针能让函数改外部 int(传 &x);二级指针能让函数改外部 指针。判据一句话:要改谁,就传谁的地址。
先看错误示范,这个 bug 我在 code review 里抓到过至少五次:
void bad_alloc(char *s) { // 收到的是 p 的副本!
s = malloc(64); // 只改了副本,函数一返回就蒸发
}
char *p = NULL;
bad_alloc(p);
free(p); // p 还是 NULL;malloc 那块再也找不到,泄漏
正确版:传 p 的地址:
void good_alloc(char **s) { // 收到 &p
*s = malloc(64); // *s 就是 p 本身,这一步真正改了调用者的 p
if (!*s) return;
}
char *p = NULL;
good_alloc(&p); // 传 &p,类型自动匹配 char**
free(p); // p 现在是合法指针
对照第 1 章「指针是存地址的变量」:函数想改 int,传 int*(改 p);想改 int,传 int**(改 pp)。把这条规则记牢,以后所有出参(T* 型函数)都不再神秘,glibc 的很多 API(pthread_create 的线程句柄、strtok 的 strtok_r 状态)都是这个形状。
第 5 章的动态二维、本章的字符串数组,凡是「先分配指针数组、再逐个分配行/元素」的结构,释放永远有固定顺序:
char **lines = malloc(n * sizeof(char *));
for (int i = 0; i < n; i++) lines[i] = strdup("...");
for (int i = 0; i < n; i++) free(lines[i]); // ① 先释放每个元素
free(lines); // ② 最后释放指针数组本身
顺序反了(先 free(lines))就再也摸不到 lines[i],整批泄漏。中间崩了呢(比如某个 strdup 失败)?成熟的写法是失败路径上把已分配的逐个回收,这段「错误处理回滚」代码能写对的人,基本就算出师了,习题里专门练。
多级指针不是等级游戏。合理清单:argv(char**)、出参(T**)、动态锯齿数组(T**)、个别句柄 API(void**)。出现 *** 时,九成该停下来用结构体或 typedef 包装:
char ***menu; // 危险信号
struct menu { char **sections; int n; }; // 有名字的结构,可读性回来一半
行数不变,可读性天差地别。C 指针的段位高低,从来不在「敢用几颗星」,而在「让每颗星都有存在的理由」。
char **s; s = malloc(64); 存错了层(把数据指针塞进了指针数组的格子);每写一步,数一数当前类型是几级。int a[3][4] 退化成 int (*)[4],和 int ** 完全不同(第 5 章讲过布局差异),参数类型写错时编译器会拦,但很多人这时选择强转,错上加错。int x = 1, *p = &x, **pp = &p; 求下列各式的类型和值(或意义):*pp、**pp、*pp = NULL 之后 p 是什么、pp 本身的 sizeof。int_array_create(int n, int ***out),堆上分配「指针数组 + n 个 int」,失败路径正确回收;配套写 int_array_destroy(int **a, int n)。用 valgrind(或 ASan)验证零泄漏。&p 版本修复,打印前后两个 p 的值。