C语言指针进阶:五大指针类型与工程实战
指针这东西学一次懵一次很正常。你要是已经跟着这个系列学到第13讲说明指针的基础概念、指针与数组的关系、指针运算这些坑你已经踩得差不多了。这一讲我们干点更进阶的事指针数组、数组指针、函数指针、二级指针、结构体指针这些东西在链表、排序、命令行参数解析、回调机制里全是高频角色。我尽量把每个知识点的“为什么”讲透代码给全踩过的坑也一并交代争取你看完能直接用而不是停留在“好像明白了”的阶段。1. 指针进阶的核心指针数组与数组指针1.1 数组和指针的“历史遗留问题”C语言的数组和指针暧昧程度堪比一对互相试探的初恋。之前我已经反复强调过数组名在绝大多数表达式中会隐式转换为指向首元素的指针这个转换规则带来了极大的便利也制造了极大的混乱。很多初学者背下了“数组名就是指针”然后就开始在sizeof上翻车——sizeof(arr)返回整个数组的字节数sizeof(ptr)却只返回一个指针变量的大小。这种混乱在指针数组和数组指针这两个概念上会进一步放大。先说结论。指针数组是“装着指针的数组”本质是数组每个元素是个指针变量。数组指针是“指向数组的指针”本质是指针只不过它指向的目标是一个数组整体。一个是数组套指针一个是指针套数组。主次关系一旦搞反代码编译警告能吵到你耳鸣。这两个概念在工程里的应用场景差异也很明显。指针数组最经典的使用场景是字符串表、命令行参数argv、状态码描述表数组指针则常配合二维数组使用用来指明“一行有多少个元素”在函数传参的时候尤其有用。下面我一个个拆开讲。1.2 指针数组一段连续内存里的指针们先看声明语法int *pa[5]; char *strs[3] {hello, world, C};pa是一个数组有5个元素每个元素都是int *类型。注意*和[]的优先级问题[]的下标运算符优先级高于*的解引用运算符所以int *pa[5]先被理解成int *(pa[5])——先是一个数组然后数组的元素是指针。这种声明不需要额外加括号。指针数组最爽的地方在于它天然适合存放字符串数组。C语言里没有真正的字符串类型字符串本质是字符数组字符串常量的值是一个指向首字符的地址。所以char *strs[3] {hello, world, C}的含义是我定义一个3元素的指针数组每个元素存一个char *地址分别指向三个字符串常量所在的内存区域。这里有个底层细节值得展开。字符串常量通常存放在只读数据区所以如果你写成char *strs[3] {...}严格意义上你是在让指针指向只读内存后续对strs[0][0]赋值就是未定义行为。更安全的写法是const char *strs[3] {...}不过很多老代码对此并不讲究。反过来如果你希望字符串可以修改就得用二维字符数组char strs[3][32]显式分配可写的堆栈内存。这两种写法的内存布局差别很大前者指针数组常量区字符序列后者一整块连续的字符矩阵。我自己在写命令行解析器的时候特别喜欢用指针数组const char *help_text[] { usage: app [options] input, -h, --help show this help, -v, --version print version, NULL }; for (int i 0; help_text[i] ! NULL; i) { printf(%s\n, help_text[i]); }利用数组元素是连续存放的特性用NULL当作哨兵值而不是硬编码数组长度这样后续增删条目时不需要同步改循环次数避免了好几种离奇越界bug。这种“以哨兵结尾”的写法在C库和系统编程里到处都是argv的最后一个元素就是NULL本质是同一个套路。1.3 数组指针指民警的“行指针”数组指针的声明长这样int (*p)[5];括号绝对不能少因为[]优先级高。去掉括号就变成了int *p[5]即指针数组。声明int (*p)[5]的含义是p是一个指针指向一个长度为5的int数组。为啥需要这玩意儿最典型的场景是在函数里接二维数组的“行地址”。二维数组int matrix[3][5]在内存里是连续平铺的15个int当你把它传给函数时直接void f(int matrix[3][5])看着很正常但编译器会把它调整成void f(int (*matrix)[5])——因为数组参数会退化为指针二维数组退化成“指向行的指针”列数5必须保留否则被调函数无法计算内存布局。void print_matrix(int (*m)[5], int rows) { for (int i 0; i rows; i) { for (int j 0; j 5; j) { printf(%4d , m[i][j]); } putchar(\n); } } int main(void) { int matrix[3][5] { {1, 2, 3, 4, 5}, {6, 7, 8, 9, 10}, {11, 12, 13, 14, 15} }; print_matrix(matrix, 3); return 0; }这里matrix作为实参传过去后函数内部m[i][j]的寻址过程是先取m i得到第i行的行指针再解引用成该行的首地址然后按元素大小偏移j个int。也就是*(*(m i) j)。很多初学者想不明白为什么二维数组传参非要保留列数就是因为函数的指针算术依赖“每行有几个元素”这个关键参数来确定行差。数组指针的另一种常见应用是动态分配二维数组int (*p)[5] malloc(sizeof(int[5]) * rows);这行代码一次性分配了rows * 5个int的空间然后把这个连续内存块当作“每行5列”的二维数组来使用。比起用二级指针模拟二维数组这种方法内存完全连续、缓存友好、释放也只要一次free(p)在需要规则矩形矩阵的场景下性能要好得多。2. 函数指针把函数当作一种数据2.1 函数指针的声明与调用C语言的函数虽然没有变量那样的“值”概念但它有一等公民的地址——每个函数在链接后都有一个入口地址。所以我们可以把这个地址存进指针变量将来通过指针来间接调用它。函数指针的声明有着本系列到目前为止最反直觉的语法int (*fp)(int, int);这里fp是一个指针指向一个“返回int、接收两个int参数”的函数。如果少了那对括号int *fp(int, int)就成了“声明了一个名为fp、接收两个int、返回int*的函数”——又是一对死在优先级上的难兄难弟。我建议用“右左法则”去解析这种声明先从标识符开始向右看遇到(说明是函数再向左看遇到*说明返回值是指针……反复直到整个声明读完。多读几个复杂声明你的C语言诅咒抗性会大涨。赋值和调用就自然得多int add(int a, int b) { return a b; } fp add; // 函数名退化为函数指针跟数组名退化为首元素指针一个逻辑 int result fp(3, 5); // 直接通过函数指针调用add和add在这里是等价的因为C语言规定函数名在表达式中会转换为函数指针除非前面取地址。绝大多数人习惯直接写fp add我觉得没问题但如果你想把意图表达更严谨写fp add也完全正确。2.2 回调机制qsort里的函数指针函数指针最大的价值不是让你花里胡哨地间接调用而是实现回调机制——把某个函数的行为作为参数传给另一个函数让后者在合适时机调用这个函数。标准库qsort是教科书级的实例。qsort的声明是void qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *));最后这个参数就是函数指针。你传入的自定义比较函数返回负数、零、正数分别表示第一个参数小于、等于、大于第二个参数。如果你想对结构体排序只需要写一个专属的比较函数剩下的一切交给qsort。typedef struct { char name[32]; int score; } Student; int cmp_student_by_score(const void *a, const void *b) { const Student *sa (const Student *)a; const Student *sb (const Student *)b; return sa-score - sb-score; } Student class_room[] { {Alice, 88}, {Bob, 76}, {Cindy, 95} }; size_t n sizeof(class_room) / sizeof(class_room[0]); qsort(class_room, n, sizeof(Student), cmp_student_by_score);小而美。关键点有三处。第一void *是通用指针比较函数内部必须自己完成类型转换因为qsort根本不知道你排序的是什么类型。第二比较函数的返回语义要尽量规范a b返回负数a b返回零a b返回正数千万不要写成return a - b这种有溢出风险的写法我后面会专门说。第三sizeof(Student)告诉qsort每个元素占多少字节它才能做指针跳转和元素交换。函数指针的另一个密集场景是信号处理、事件驱动、状态机、插件化架构。你写一个回调注册表运行时根据用户输入切换处理函数逻辑代码和业务逻辑解耦得很干净。我实习时写过一个小型网络框架每个协议族的处理函数都注册在一个函数指针数组里来一个包就查一下协议号对应下标然后直接handlers[idx](packet)。那个设计到现在看仍然很舒服。2.3 函数指针的坑类型系统和可读性函数指针虽然好用但踩坑速度也快。第一个坑是类型严格性。int (*)(int, int)和int (*)(double, int)是两个不同类型不能相互赋值编译器会警告。第二个坑是空指针调用函数指针没有初始化就调用程序秒崩调试器报的地址又难定位。第三个坑是函数指针数组的声明可读性急剧下降int (*handler_table[4])(int, int);这份声明读起来是handler_table是一个数组有4个元素每个元素是一个函数指针指向返回int、接收两个int的函数。一堆符号挤在一起我介意就加一个typedeftypedef int (*OpFunc)(int, int); OpFunc handler_table[4];可读性立刻拉满。typedef本质上是给一个复杂的类型起别名它不会引入新类型只是让写法变得人模人样。在结构体排序、命令解析、状态机这些会大量出现函数指针的代码里强烈建议用typedef兜底。3. 二级指针为什么你需要指针的指针3.1 二级指针的内存模型二级指针声明是int **pp意思是pp指向一个int *类型的变量。一级指针存普通变量的地址二级指针存一级指针变量的地址。关系链是pp - p - x想访问最底层的x要写**pp。为什么要这么绕两个理由。一是你想在函数里修改外部指针变量的值只传一级指针做不到二是你要动态创建“指针的数组”时比如二维数组的指针式模拟就需要二级指针。先看第一个理由。C语言所有实参都是传值拷贝你以为传了个指针进去就能修改它但函数内部修改的只是指针的拷贝外部指针纹丝不动。看看这个错误示范void create_buffer(int *p) { p malloc(sizeof(int) * 100); // 只改了形参外部p还是NULL } int main(void) { int *buf NULL; create_buffer(buf); if (buf NULL) { printf(still NULL!\n); // 这里打印 } }问题就出在create_buffer接收到的那个指针是buf的拷贝你在函数内部让这个拷贝指向新内存外部buf没有任何改变。要想在函数里改外部指针的值就必须传外部指针的地址——也就是二级指针void create_buffer(int **p) { *p malloc(sizeof(int) * 100); // 修改外部指针变量本身 } int main(void) { int *buf NULL; create_buffer(buf); if (buf NULL) { printf(allocation failed\n); } else { printf(buffer created, first element %d\n, buf[0]); } free(buf); }这个例子能想明白二级指针你就已经掌握了60%。另外40%在链表、二叉树的插入操作里等你。3.2 链表中“改头”的艺术链表头插法的经典实现是二级指针的完美验金石typedef struct Node { int data; struct Node *next; } Node; void push_front(Node **head, int value) { Node *new_node malloc(sizeof(Node)); if (new_node NULL) return; new_node-data value; new_node-next *head; // 新节点指向原来的头 *head new_node; // 头指针更新为新的节点 }为什么这里必须用Node **head因为头插法要修改调用者手里那个head指针的指向。如果在函数里只传Node *head你只能修改head-next这些指针指向的内存内容却无法让外部head变量指向新节点。传Node **head之后*head就能读写调用侧的head变量了。对比一下删除链表节点也是同理删除头节点时同样要更新外部头指针。学会了二级指针你在操作链表时就不会再写出“函数跑完链表原封不动”的诡异bug。我曾经见过一个同学用全局head绕过这个问题短期能跑但紧接着他写了两个链表、需要各自独立头指针的时候代码就彻底失控了。3.3 二级指针和动态二维数组二级指针的另一个高频用途是指针式二维数组。和前面数组指针方案的连续矩阵不同二级指针方案是“指针数组独立行内存”int **matrix malloc(sizeof(int *) * rows); for (int i 0; i rows; i) { matrix[i] malloc(sizeof(int) * cols); }这样每行的内存独立分配行与行之间在地址空间里不一定连续。好处是每行长度可以不同适合“锯齿数组”坏处是内存碎片多、分配释放都要按行循环操作性能上不如连续的矩阵。这里要特别提醒一个易错点malloc(sizeof(int *) * rows)分配的空间用于存放int *类型的指针变量每个元素大小是sizeof(int *)而不是sizeof(int)。很多初学者搞混这两个大小导致后续matrix[i]越界或者错位访问。64位平台上sizeof(int *)通常是8sizeof(int)通常是4交叉用就会把内存布局全部打乱。3.4 什么时候不需要二级指针二级指针虽好但也不是万能。如果函数只是要读指针指向的数据、或者修改指针指向的数据内容而不需要改变指针本身的指向那一级指针就够了。我见过为了“显得高级”硬写二级指针的代码纯粹增加阅读理解负担。判断标准只有一句话要不要修改外部指针变量本身的指向要就传地址二级指针不要传一级指针。比如一个函数只输出字符串并统计长度那void print_string(const char *s)完全正确整成const char **s就是给自己挖坑。4. 结构体指针C语言里朴素的面向对象味道4.1 结构体指针与箭头运算符结构体指针和结构体变量在内存访问方式上有本质差别。struct Student st;直接在栈上分配一块结构体大小的内存访问字段用st.score。struct Student *p st;则持有一个地址访问字段时先解引用整个结构体再取字段语法上是(*p).score。这个写法万无一失但有更简洁的等价写法p-score箭头运算符就是“解引用取成员”的语法糖。结构体指针在C语言里的地位相当高尤其是配合动态内存分配和链表这些数据结构时。比如你定义了一个Student想把它放进二叉搜索树节点里就得存Student *data还是直接存Student data两种方案各有取舍。存指针省内存拷贝、节点大小固定但你要小心指针的生命周期直接存值更直观、无悬挂引用风险但结构体大会导致整棵树节点膨胀。我的建议是结构体较小几个字段、几十字节以内直接存值结构体较大包含大数组、长字符串缓冲区存指针。这个建议适用于绝大多数数据处理场景。4.2 结构体指针的实战学生成绩单排序把结构体指针和函数指针组合起来就能写出很实用的排序程序。我就以学生成绩管理为例完整实现一遍#include stdio.h #include stdlib.h #include string.h typedef struct { char name[32]; int score; } Student; int by_score_desc(const void *a, const void *b) { const Student *sa a; const Student *sb b; // 注意直接返回差值的风险在分数差很大的时候会溢出 return (sa-score sb-score) ? -1 : (sa-score sb-score) ? 1 : 0; } int by_name_asc(const void *a, const void *b) { const Student *sa a; const Student *sb b; return strcmp(sa-name, sb-name); } int main(void) { Student class_room[] { {Alice, 88}, {Bob, 76}, {Cindy, 95} }; size_t n sizeof(class_room) / sizeof(class_room[0]); qsort(class_room, n, sizeof(Student), by_score_desc); printf(sorted by score (desc):\n); for (size_t i 0; i n; i) { printf(%s: %d\n, class_room[i].name, class_room[i].score); } qsort(class_room, n, sizeof(Student), by_name_asc); printf(sorted by name (asc):\n); for (size_t i 0; i n; i) { printf(%s: %d\n, class_room[i].name, class_room[i].score); } return 0; }这段代码里的(const Student *)a强制类型转换是必要的因为qsort的回调只给你const void *不转换成具体结构体指针你没法读取字段。by_score_desc里的比较逻辑我用的是三元表达式做三态判断刻意避开return sa-score - sb-score。这里多说一句分数差不超过INT_MAX时减法没问题但如果你排序的是大整数、日期差、仓位价格差减法就可能溢出导致排序结果诡异。养成写三态比较的习惯可以省掉好几个深夜debug。运行结果一目了然先是成绩降序再是姓名升序。同一个排序函数换一个比较函数就能实现完全不同的排序规则这就是函数指针回调机制的威力。你把“怎么比较”和“怎么排序”解耦了代码复用率瞬间提升。4.3 结构体指针里的生命周期问题结构体指针最容易踩的坑是返回局部结构体的地址Student *create_student_bad() { Student s {Alice, 88}; return s; // 悬垂指针s在函数返回时已经销毁 }函数内部定义的局部变量存在栈上函数返回后这块内存是“失效”的虽然地址上的旧数据往往还在但行为属于未定义。正确做法是动态分配Student *create_student_good(const char *name, int score) { Student *s malloc(sizeof(Student)); if (s NULL) return NULL; strcpy(s-name, name); s-score score; return s; }用指针管理结构体数据时我总是坚持一条铁律谁分配谁释放。create_student_good里malloc出来的内存必须由调用者负责free。如果分配函数和释放函数散落在不同模块时间一长就会出现内存泄漏或者重复释放。最好成对提供接口比如student_new()和student_destroy(Student *s)或者在文档里明确标注内存归属。5. 实战项目用指针数组实现多字符串字典序排序前面几章拆了单个知识点这一节把它们捏成一个完整的可运行项目接收命令行输入的一组字符串存进指针数组然后用qsort按字典序排序并输出。这个项目会用到指针数组、函数指针、字符串函数、内存管理是第13讲内容的综合练习。5.1 项目需求与设计程序运行时从标准输入读取若干行直到读到EOF为止。每一行最多255个字符不限制总行数。读完以后按字典序升序输出原始字符串。这里我不采用固定大小的二维数组因为那样需要预先猜测最大行数容易造成浪费或者不够用。更灵活的方案是用char **lines作为动态指针数组逐行读入字符串动态分配内存存放每一行的内容指针数组按需扩容。整体设计分三步读取阶段用fgets逐行读入为每一行分配足够的内存保存字符串把指针追加到动态数组里。排序阶段调用qsort比较函数用strcmp比较两个字符串。输出与清理逐行打印然后free每一行内存最后free指针数组本身。5.2 完整代码实现#include stdio.h #include stdlib.h #include string.h #define MAX_LINE_LEN 256 int compare_strings(const void *a, const void *b) { const char *const *sa a; const char *const *sb b; return strcmp(*sa, *sb); } int main(void) { char **lines NULL; size_t capacity 0; size_t count 0; char buffer[MAX_LINE_LEN]; while (fgets(buffer, sizeof(buffer), stdin) ! NULL) { /* 去掉换行符 */ buffer[strcspn(buffer, \n)] \0; if (buffer[0] \0) { continue; } /* 扩容指针数组 */ if (count capacity) { size_t new_capacity (capacity 0) ? 8 : capacity * 2; char **new_lines realloc(lines, new_capacity * sizeof(char *)); if (new_lines NULL) { fprintf(stderr, out of memory\n); goto cleanup; } lines new_lines; capacity new_capacity; } /* 为字符串分配内存 */ size_t len strlen(buffer) 1; char *copy malloc(len); if (copy NULL) { fprintf(stderr, out of memory\n); goto cleanup; } strcpy(copy, buffer); lines[count] copy; } qsort(lines, count, sizeof(char *), compare_strings); for (size_t i 0; i count; i) { printf(%s\n, lines[i]); } cleanup: for (size_t i 0; i count; i) { free(lines[i]); } free(lines); return 0; }5.3 代码逐段解读读入部分用的是fgets它最多读MAX_LINE_LEN - 1个字符留一个位置给\0。如果一行超过255个字符fgets会截断剩余内容还留在输入流里。严格起见你还应该判断是否读到换行符来决定是否继续读剩余字符但作为练习这个处理是够用的。strcspn(buffer, \n)返回字符串中第一个\n的位置我用它精准定位换行符并替换为\0。这个方法比手动循环找换行符更稳也顺手处理了读取到EOF但最后一行没有换行符的情况。扩容逻辑用的是常见的倍增策略容量不够就翻倍初始容量8。realloc如果失败会返回NULL并且原内存块不受影响所以我先把它存到new_lines确认非空再赋值给lines避免直接把lines搞丢。动态扩容的核心思想就是“按需增长均摊O(1)”这个思路在写哈希表、动态数组时都能复用。比较函数compare_strings里有个隐藏细节。qsort传给回调的是指向数组元素的指针数组元素本身是char *所以回调拿到的是char **也就是指向指针的指针。我声明成const char *const *sa两个const分别限制sa所指向的char *是只读的而sa这个变量本身在本次比较中也只读。外面加一层保护能防止不小心写出修改数组元素的代码编译器可以提前帮你抓出逻辑错误。5.4 测试结果我准备了一个输入文件names.txtbanana apple cherry date elderberry编译运行gcc -Wall -Wextra -stdc11 sort_lines.c -o sort_lines ./sort_lines names.txt输出apple banana cherry date elderberry排序无误。如果把compare_strings里的strcmp改成倒序比较就能秒变降序排序这就是回调机制的好处——排序框架不变只换比较逻辑。6. 常见问题与排查技巧实录学指针进阶这一章很多报错是有限的、有规律的。我把这些年和学生、同事一起踩过的坑整理成一张速查表希望能帮你快速定位问题。症状大概率原因解决办法程序编译时提示incompatible pointer types函数指针类型不匹配比如返回int (*)(int)却赋给了int (*)(int, int)检查函数签名是否有返回值、参数个数/类型差异必要时用typedef统一类型Segmentation faultgdb里看不到源码行号野指针或者越界访问常见于未初始化指针就解引用指针初始化一律置NULL动态分配后检查返回值数组遍历时以哨兵或长度为准调用函数后外部指针还是NULL函数里试图用一级指针修改外部指针本身改成二级指针在函数内通过*p修改外部指针的值realloc后旧指针还在用realloc失败返回NULL导致原指针丢失或成功后混淆新旧地址先存新指针成功再赋值后续一律用新指针访问内存对字符串执行free报invalid pointer字符串常量是不可以free的指针指向的地址不是malloc分配的只释放malloc/realloc/calloc返回的地址字符串常量区不要碰二维数组传参后数据错乱函数形参退化成数组指针但列数不一致导致行偏移算错函数参数一定带列数如int (*m)[5]越界访问时要警惕这里我重点挑两个最常见的展开说。第一个是指针未初始化。C语言里栈上的局部指针变量不会自动清零它的初值是按住栈内存里残存的垃圾数据。新手写int *p; *p 10;运气好可能没崩但属于纯粹的未定义行为。我见过不少同学在结构体里声明了char *name却忘了分配空间直接strcpy(s-name, alice)程序时不时崩一次调试两小时最后发现是这种情况。我的习惯是能初始化的一定初始化不确定初始值就置NULL或者指向一个合法的零长度字符串绝不留着垃圾值等着爆雷。第二个是混淆sizeof和指针。sizeof(array)返回整个数组大小sizeof(pointer)只返回指针自身大小。很多人以为它们相同在写memcpy(dst, src, sizeof(src))时把指针当数组用就会复制得多或者复制得少。一个稳妥的写法是数组长度总是用一个宏或变量显式保存比如size_t n sizeof(arr) / sizeof(arr[0])然后在所有需要长度的位置都引用n不要到处用sizeof。7. 指针进阶的学习路径建议来到这个系列的第三讲你的进度其实已经跑赢很多CC学习者了。但指针的进阶内容不是看完就能融会贯通的我建议按下面的节奏继续推进把指针数组、数组指针、函数指针、二级指针、结构体指针这五块知识各写一个小程序跑通不能只看不练。优先完成字符串排序这个实战项目它把五块知识全串起来了做完以后再用同样的思路改造一下改成按字符串长度排序、按最后字符排序、按数字解析排序每种改法都能加深你对回调机制的理解。接下来再往深走可以碰链表、二叉树、栈、队列这些数据结构它们几乎全建立在结构体指针和二级指针之上。如果方便用调试器跟一遍指针的地址变化比如打印p、p、*p、**pp各自的值这一步能扫清你最后10%的模糊感。学指针没有捷径但也没那么可怕。本质上它就是一个“间接寻址”思维——你去图书馆找书先查目录拿到书架号再根据书架号找到具体位置指针就是那个写着书架号的小纸条。钥匙在你手里多开门自然就熟练了。后面遇到更复杂的内存模型、链表操作、函数回调你会发现第13讲这些看似绕的知识点全都在默默起作用。继续往下写下一讲我准备聊指针与字符串的深度结合到时候会用实际案例把字符串逆序、分词、动态拼接这些场景全部串一遍咱们下次见。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →