C语言指针进阶:数组指针、函数指针与二级指针实战详解
指针这种东西学C语言的人没有不骂的。前面第11、12讲我们已经聊过指针变量怎么定义、怎么跟普通变量和数组扯上关系也解释了为什么函数传参传指针能“改到外面的值”。这次是《C语言从入门到进阶》系列的第13讲也是指针专题的第三篇文章重点解决那些最容易把人绕晕的组合形态指针数组、数组指针、函数指针与指针函数、二级指针再加上结构体指针和const、void搭配的进阶规则。很多初学者学到这个阶段代码能看懂自己一写就报错问题基本都出在声明优先级、类型搭配和“到底传几级指针”上。这篇文章就把这些坎一个一个拆开讲适合已经理解指针基础、想往链表、回调函数、系统编程方向走的同学。1. 第13讲的定位这一阶段指针到底卡在哪1.1 指针系列脉络回顾整个指针专题我习惯分成三段第一段讲指针是什么、怎么用也就是“变量指针”那一层第二段讲指针和数组的纠缠包括传参、下标运算和字符串第三段也就是这一讲讲指针的各种组合形态。为什么要把组合形态单独拎出来因为大多数人的崩溃点不在“指针存的是地址”这句话上而在于声明一长串类型时根本不知道在声明什么。举个特别典型的例子int *p[3]和int (*p)[3]长得几乎一模一样意思却完全相反。一个是“指针数组”里面有3个指针一个是“数组指针”指向一个包含3个int的数组。很多人写代码时靠死记硬背背完就忘忘了就乱。实际上只需要抓住一个规则[]下标运算符的优先级高于*解引用运算符声明时谁先结合谁就是主体。1.2 知识地图指针的四大“组合形态”我把第13讲的内容收敛成四个维度后面所有小节都围绕它们展开。第一是指针数组与数组指针解决“指针怎么和数组嵌套”的问题第二是函数指针与指针函数解决“指针怎么指向函数、函数怎么返回指针”的问题第三是指针的指针解决“指针本身也是变量也要被修改”的问题第四是结构体指针和const、void的组合解决“指针在复杂类型和类型限定词下怎么规范使用”的问题。这四块内容不是平行的四条线它们是层层递进的。讲指针数组是因为以后要处理字符串列表讲函数指针是因为后面学回调、学qsort、学定时器都需要它讲二级指针是因为写链表、写树的时候你需要在函数里改掉实参指针本身讲结构体指针则是一切数据结构的起点。把这四条线拉通了指针就不再是记忆负担而是可以随时调用的工具箱。2. 指针数组与数组指针两个高相似度的魔鬼2.1 先看声明运算符优先级决定一切先说结论再讲原理。声明指针数组写成int *p[3]从右往左读p是数组数组里有3个元素每个元素是int *。声明数组指针写成int (*p)[3]小括号把*和p绑在一起所以p先是指针它指向的东西是“包含3个int的数组”。为什么有括号就不一样因为[]的优先级比*高。没有括号时p先和[]结合p就是数组加了括号p先和*结合p就是指针。这个规则不光用在指针上所有复杂声明都可以用“看优先级、先结合谁”来拆解。初学者最容易犯的一个错误是在数组参数那里混用。函数形参int a[3]实际上会被编译器调整为int *a这不是说数组和指针完全相等而是说函数传参时数组会退化成首元素指针。所以你在函数里写sizeof(a)得到的是8字节64位平台上指针的大小而不是12字节这个坑我在第12讲已经提醒过。2.2 指针数组实战字符串集合与排序指针数组最常见的用途是管理一批字符串。字符串本身是char[]一堆字符串就是一堆字符数组如果用二维数组char s[100][10]空间就浪费了因为每条字符串长短不一还得固定上限。用指针数组char *s[100]只存每个字符串的首地址字符串常量区或栈上有实际数据指针数组里的元素只是一个4字节或8字节的地址。看这个例子#include stdio.h #include string.h int main(void) { char *fruits[] {apple, banana, cherry, date}; int n sizeof(fruits) / sizeof(fruits[0]); for (int i 0; i n; i) { printf(%s\n, fruits[i]); } // 用标准库排序 // qsort 后面讲函数指针时会用到这里先演示思路 for (int i 0; i n - 1; i) { for (int j i 1; j n; j) { if (strcmp(fruits[i], fruits[j]) 0) { char *tmp fruits[i]; fruits[i] fruits[j]; fruits[j] tmp; } } } return 0; }排序时交换的是指针而不是整个字符串这个效率差距很大。字符串短的时候感觉不出来如果每个字符串有几KB直接复制字符数组会非常浪费。交换指针只是交换两个地址常数时间完成。这也是为什么数据库、表驱动这类代码里非常喜欢用指针数组来管理记录。2.3 数组指针实战操作二维数组的正确姿势数组指针int (*p)[4]指向的是一整行。二维数组在内存里是一段连续空间int matrix[3][4]可以理解为“3个长度为4的int数组组成的数组”。用数组指针指向它就能把“行”当作一个整体来处理。#include stdio.h int main(void) { int matrix[3][4] { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; int (*p)[4] matrix; // p 指向第一行即包含4个int的数组 for (int i 0; i 3; i) { for (int j 0; j 4; j) { printf(%4d, p[i][j]); } putchar(\n); } printf(p 1 跳过的字节数: %ld\n, (long)((char *)(p 1) - (char *)p)); return 0; }p 1会向后跳一整个“包含4个int的数组”也就是16字节。如果你把(*p)[4]写成*p[4]编译立刻报警告因为类型不匹配。函数形参如果接收二维数组也应该写void func(int (*arr)[4])或void func(int arr[][4])这两种写法等价。2.4 一张表格对照记忆对比项指针数组数组指针声明写法int *p[3]int (*p)[3]本质p是数组元素是int指针p是指针指向int[3]数组读取方向从右往左读括号内先读p再读类型常见用途字符串集合、表驱动二维数组行遍历、函数形参指针算术意义p1跳到下一个指针元素p1跳一行3*4字节混淆后果地址被当数组下标类型不匹配编译警告这张表不用背关键是理解优先级。遇到复杂声明把“主体是这个变量还是这个数组”先问清楚就基本不会错。3. 函数指针与指针函数从“数据指针”过渡到“代码指针”3.1 指针函数返回指针不难难的是别返回栈地址指针函数本质是一个函数它的返回值类型是指针。比如char *get_name(void)这个函数返回一个char *。它本身是个函数只是返回类型特殊一点。学到这里不会出大问题出大问题的是返回了局部变量的地址。#include stdio.h char *bad_func(void) { char buf[64]; sprintf(buf, hello); return buf; // 危险返回的是栈上局部数组的首地址 }一旦这个函数返回栈空间就被回收你再访问那块地址就是悬空指针。哪怕打印出来短时间是对的那也是“运气好”说不定下一个函数调用就把栈覆盖了。正确处理有三条路一是用static修饰局部数组让它的生命周期延长到整个程序二是在堆上malloc但调用方要用完free三是把缓冲区由调用方传入函数只负责往里面写。3.2 函数指针把函数当成一种数据函数名本身就是函数入口地址所以可以赋值给一个函数指针变量。声明方式是返回类型 (*指针名)(参数列表)。这里括号也是必须的写成int *fp(int)就变成了“返回int指针的函数”而不是函数指针。#include stdio.h int add(int a, int b) { return a b; } int sub(int a, int b) { return a - b; } int main(void) { int (*fp)(int, int) add; printf(%d\n, fp(10, 5)); fp sub; printf(%d\n, fp(10, 5)); return 0; }函数指针的意义在于调用哪个函数这件事可以运行到一半再决定。这就像你把“操作”本身当作参数传来传去。C语言没有Java那种函数对象、没有Python的lambda但函数指针在C语言里就是最朴素的“把行为参数化”的手段。3.3 回调函数实战通用排序框架提到行为参数化最经典的就是qsort。它的第四个参数就是一个函数指针用来告诉排序算法“你怎么比较两个元素”。#include stdio.h #include stdlib.h int cmp_int(const void *a, const void *b) { int x *(const int *)a; int y *(const int *)b; return (x y) - (x y); } int main(void) { int arr[] {5, 2, 9, 1, 7}; int n sizeof(arr) / sizeof(arr[0]); qsort(arr, n, sizeof(int), cmp_int); for (int i 0; i n; i) { printf(%d , arr[i]); } putchar(\n); return 0; }qsort在报错时要求比较函数返回负数、0、正数分别表示小于、等于、大于。我上面写成(x y) - (x y)是为了避免直接x - y可能遇到的整数溢出风险虽然这里数字小看不出问题但这个习惯在工程里值得养成。回调函数是很多框架的核心思想你不需要修改库代码只需要把你的函数传进去库在合适的时机回调你。3.4 typedef 封装让函数指针不再烫手函数指针声明太丑一个复杂点的回调签名能写半行。工程里几乎都会用typedef给它起个别名。typedef int (*compare_func)(const void *, const void *); compare_func comparator cmp_int; qsort(arr, n, sizeof(int), comparator);定义好别名后写起来清爽很多。还有更花哨的用法比如函数指针数组用来实现菜单分发、状态机。第6节的综合Demo会用到这里先记住函数指针数组声明是int (*ops[])(int, int)它结合了第2节的指针数组语法和第3节的函数指针语法原理一样就是优先级规则一层层套。4. 指针的指针二级指针、双指针法一字之差天壤之别4.1 二级指针的物理模型指向指针的指针指针变量本身也有一块内存里面存的是目标变量的地址。那谁存指针变量的地址呢就是二级指针。声明写int **pp读法是pp是一个指针它指向一个int *。这个模型用画图的方式理解最舒服pp - p - x修改*pp等于修改p的值修改**pp等于修改x的值。很多同学卡在“为什么函数里传一级指针改不了外部的指针传二级指针就能改”其实就是C语言传参默认按值传递把实参的值拷贝一份给形参。你想在函数里改外部变量的值就要传外部变量的地址。现在你想改的变量本身是一个指针那就要传那个指针变量的地址也就是二级指针。4.2 典型场景1在函数里修改实参指针#include stdio.h #include stdlib.h void init_pointer(int **pp) { *pp (int *)malloc(sizeof(int)); if (*pp ! NULL) { **pp 42; } } int main(void) { int *p NULL; init_pointer(p); // 传的是 p 的地址 if (p ! NULL) { printf(%d\n, *p); free(p); } return 0; }如果写成void init_pointer(int *p)函数里只能改变p指向的内存里的内容却无法让外面的p指向一块新的内存。因为形参p只是实参指针的副本在函数里修改p本身外面的p不会变。同理写free(p); p NULL;这样的清理函数也必须用二级指针否则调用方拿到的还是一块悬空地址。4.3 典型场景2链表头插法的两种实现二级指针用得最多的地方就是链表。头插法要在头部插入新节点头指针本身要更新。用返回值更新struct node { int data; struct node *next; }; struct node *push_front(struct node *head, int val) { struct node *new_node (struct node *)malloc(sizeof(struct node)); new_node-data val; new_node-next head; return new_node; }用二级指针更新void push_front(struct node **head_ref, int val) { struct node *new_node (struct node *)malloc(sizeof(struct node)); new_node-data val; new_node-next *head_ref; *head_ref new_node; }两种写法都能用但第二种更适合“一个函数里要同时改多个头指针”的场景。比如创建一个新链表又想修改旧链表的头返回值只能管一个二级指针可以改多个。数据结构课里写二叉树删除、红黑树旋转大量使用struct node **原理都相通。4.4 澄清算法题里的“双指针法”别混淆热词里“双指针法”是算法题里的高频词但要先说明它跟“二级指针”是完全不同的两个概念。算法题中的双指针通常指用两个指针变量一左一右对撞指针或者一快一慢快慢指针去扫描数组或链表解决有序数组两数之和、链表找环这类问题。举个例子反转数组void reverse_array(int *arr, int n) { int *left arr; int *right arr n - 1; while (left right) { int tmp *left; *left *right; *right tmp; left; right--; } }这里left和right都是“一级指针”只是同时用两个指针来遍历所以叫双指针法。有些教程口头上把“指针的指针”也叫“双指针”这就造成了理解上的混乱。你在看资料前先分辨话题是内存模型还是算法技巧。我建议内存里嵌套多级的叫“二级指针”算法里同时移动两个游标扫描的叫“双指针法”写代码时注意区分类型。5. 结构体指针与const、void的高级组合5.1 结构体指针-的由来与传参策略结构体一旦大起来直接传值拷贝很浪费而且函数内修改不影响外面所以结构体指针是数据结构的根基。访问结构体指针内部成员用-运算符它等价于(*指针).成员只是写得短。你看到p-next心里要知道它就是(*p).next。#include stdio.h #include string.h typedef struct { char name[32]; int age; } Student; void print_student(const Student *s) { if (s NULL) { return; } printf(%s, %d\n, s-name, s-age); } int main(void) { Student s; strcpy(s.name, Alice); s.age 20; print_student(s); return 0; }第一秀if (s NULL)判空对吗非常对。任何解引用前先判断指针不是空指针这是写入代码里的习惯。第二形参加const说明这个函数只读不改调用方能立刻明白函数意图编译器也能帮你拦截误修改。5.2 const 限定指针的三种写法const出现位置不同含义完全不同这是高阶面试常考点。我把三种写法列一张表。写法含义能改指针本身吗能改指向的值吗const int *p指向const int的指针能不能int *const p常量指针不能能const int *const p双重限定不能不能怎么记看const修饰的是谁。const int *p因为int前面有const所以指针指向的“int值”被限定那*p 10就编译报错但p可以。int *const pconst跟着p走p本身不能改所以p报错但*p 10可以。工程上最常见的建议是函数形参用const SomeStruct *p来传入只读数据既避免拷贝又保证不被误改。5.3 void*泛型指针的限制与强转void *是一个“不知道指向什么类型”的指针。qsort的形参为什么都用void *因为排序不知道你要排int、double还是结构体只能接收一个通用地址。但是C语言的void *不能直接解引用因为编译器不知道那块内存占几个字节也不能直接做指针算术因为它不知道步长。使用前必须强制转成具体类型的指针int x *(int *)void_ptr;这里括号内的(int *)是把void *强转为int *然后取*。void *存在的价值是写出通用代码缺点是没有类型检查所以工程里要谨慎使用尽量在外层把类型定清楚。6. 一个综合Demo用函数指针数组做菜单计算器6.1 程序设计思路把第2讲的指针数组、第3讲的函数指针数组、第5讲的const和函数封装放一起做一个最简单的菜单计算器。它不用一堆if-else而是把运算函数地址放进数组用户选择菜单编号时直接按下标调用。这样以后加一个“求余”只需要新增一个函数、数组里多一项不用改调用逻辑。6.2 代码实现与关键点注释#include stdio.h typedef int (*operation)(int, int); int add(int a, int b) { return a b; } int sub(int a, int b) { return a - b; } int mul(int a, int b) { return a * b; } int divide(int a, int b) { return b ? a / b : 0; } int main(void) { operation ops[] {add, sub, mul, divide}; const char *names[] {加法, 减法, 乘法, 除法}; int n sizeof(ops) / sizeof(ops[0]); int a 15, b 4; for (int i 0; i n; i) { printf(%s: %d\n, names[i], ops[i](a, b)); } return 0; }这段代码其实蕴含了三层指针语法第一层operations ops[]是“函数指针数组”是第2节和第3节的知识点叠加第二层names是const char *数组用来保存字符串常量所以用const修饰第三层调用ops[i](a, b)等价于先取出函数地址再调用。程序没有一堆switch将来扩展新运算只要改数组和names就行这就是数据驱动设计思想的雏形。如果你想让这段代码更贴近真实工程可以把ops也传给一个函数参数类型写成operation ops[]函数内循环调用业务逻辑完全隔离外部用户输入。毕业设计、课设里用这种写法评委通常会高看一眼。7. 踩坑实录指针最容易犯的错7.1 最容易犯的5个运行期错误第一个未初始化的野指针。声明int *p;就直接*p 10;p的初始值不确定大概率导致段错误。解决铁律定义指针时养成 NULL的习惯使用前判空。第二个free之后继续使用。释放后立即把指针置NULL否则它就是悬空指针再次访问可能读出脏数据。第三个返回局部变量地址第3节专门讲过要用static、堆内存或调用方缓冲区。第四个数组指针和指针数组写反导致类型不兼容编译器报incompatible pointer types。第五个函数里只传一级指针就想修改外部指针本身像链表头插那样需要二级指针传错级数看不出编译错误但运行结果就是链表没更新。7.2 编译期报错排查速查表报错信息常见原因处理expected int ** but argument is of type int *形参要二级指针实参传了一级实参前加assignment to int (*)[4] from incompatible pointer type把二维数组名赋给错的行指针确认行元素个数一致invalid use of void expression试图解引用void *先强转成具体类型called object type int is not a function把变量名和函数指针冲突检查声明优先级确认()位置returning char * from a function with incompatible return type指针函数返回类型错确认函数声明头排查思路不要乱。第一步看警告别忽略第二步看实参和形参的类型是否完全匹配包括const匹配第三步看优先级用()明确意图。C语言的报错信息经常不直接指向真正原因尤其是宏和复杂声明把代码块拆成最小可编译单元逐层测试。7.3 自查清单写指针代码前我建议你过一遍这个清单每个指针有没有初始化或赋NULL解引用前有没有判空函数传参时想改“指针指向的值”传一级指针想改“指针本身的值”传二级指针吗返回的指针指向的内存生命周期有多长栈、静态区还是堆用了malloc对应位置有没有free数组参数传进去后是否误用sizeof这一遍走完90%以上的低级错误都能拦住。写给自己也写给回头看的人带学生做了这么多年我最感慨的是指针这关早晚要过早过比晚过舒服。很多人一开始死背语法背完就忘其实关键不是背是画内存图。每个指针变量画成一个方框里面写地址箭头指到目标内存涉及二级指针就画两个方框涉及结构体指针就在结构体上标出成员偏移。画完图代码自然写得对。这一讲内容以后会反复出现数据结构课里链表、二叉树、哈希表全是结构体指针的天下系统编程里文件指针、缓冲区管理离不开void *和二级指针框架代码里回调机制全靠函数指针。第13讲只是个转折点后面再聊队列、栈和算法时你会感谢现在愿意把指针组合形态逐个啃通的自己。真要是卡住了就用第7节那个自查清单一句一句问自己比对着报错瞎试快得多。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →