1. 拿到练习73之后,我建议你先别急着敲代码
如果你也是照着网上流传的“C语言经典100例”在刷题,大概刷到七十几题会有一个很明显的感觉:前面那些题目(水仙花数、九九乘法表、冒泡排序)闭着眼都能写,到七十多题开始突然“变味”了——题面看起来还是在练习控制台菜单、练习分支循环,但真上手写,却总差那么一口气。
先说一个很多人没意识到的点:经典100例因为被反复转载、整理、改版,“第73题”在不同版本里对应的内容并不一样。我手里这版练习73,核心是“用函数指针数组实现一个带菜单的计算器”,本质上考的是三件事:函数指针怎么声明、函数指针数组怎么组织、怎么通过索引直接调用不同函数。而另一个流传较广的版本,第73题是“反向输出一个链表”,两者完全不同。
所以拿到题目第一步不是打开编辑器,而是先确认你的题目描述到底长什么样。如果题面出现了“函数指针”“菜单选择”“加减乘除”这类关键词,那就和我这版对上了。这篇就围绕这一版展开,把函数指针这个知识点从原理到代码再到编译报错,完整盘一遍。
明确目标之后,我们拆一下这道题的隐藏考点:表面上是个计算器,实际考察的是“函数的地址”这个抽象概念。很多人在前面章节学过“数组名是数组首元素的地址”,却很少意识到“函数名本身也是一个地址”。练习73就是逼你把函数名当成一个可存放、可索引、可调用的对象去理解。学完这道题,你顺手就掌握了回调、命令分发、菜单系统这些后面真正有用的工程技巧,所以它值得好好写。
2. 函数指针为什么会劝退一堆人:三个类比帮你建立直觉
2.1 变量存数据,指针存地址,函数指针存“函数这栋楼的楼门号”
很多教程一上来就丢语法:int (*func)(int, int);然后告诉你“声明了一个函数指针”。对新手来说,这完全不能建立画面感,只会背格式,换个场景就懵。
我的解释方式是这样的:普通变量好比一栋单人公寓,里面住着一个整数,你通过门牌号(变量名)找到它;普通指针变量,就像手里攥着一张写着地址的纸条,它不是数据本身,但它能告诉你“你要找的人住在哪”;而函数是什么呢?函数是一栋楼,里面住着一段完整的逻辑:进门要带两个整数做凭证,出来时给你一个整数当结论。
函数指针,就是把“这栋楼的楼门号”写在一张纸条上。调用的时候,你不必直接叫出这栋楼的名字(函数名),你只要把纸条上的门牌号交给CPU,CPU自己找上门去执行整段逻辑。
有了这个画面,很多问题迎刃而解:
add和&add,在绝大多数编译器里结果一模一样,都是那栋楼的楼门号。add()才是“直接进去执行”,而add只是“告诉你楼门号”。写代码时括号加错位置,等于把“地址纸条”和“执行结果”混淆了。
2.2 语法三件套:声明、赋值、调用,拆开记就不难
函数指针的语法确实反直觉,我建议拆成三块记忆:
声明:int (*calc)(int, int);
读法是:先看*calc,说明 calc 是一个指针;再看外面的int (int, int),说明它指向的函数接收两个 int 参数、返回 int。连起来就是“calc 是一个指向‘接收两个 int 并返回 int 的函数’的指针”。注意那个括号,int *calc(int, int)是完全不同的东西,那是在声明一个“返回 int 指针的普通函数”。
赋值:calc = add;
等号右边写函数名,不写括号。函数名本身就是地址,这和“数组名就是首地址”是同一种思维。此时 calc 和 add 指向同一个逻辑块。
调用:calc(3, 5);
声明、赋值、调用,三件事做完,你会发现和普通函数唯一的区别就是“函数名前面加了一个星号声明”。实际工程里,用函数指针实现回调、命令表,核心就是这套三件套,没有任何多余的花样。
2.3 数组来了:把函数指针放进一个“登记表”
练习73真正的精髓,是把函数指针再套一层数组。所谓函数指针数组,就是一张登记表:表上第 0 行写着“加法这栋楼的楼门号”,第 1 行写着“减法这栋楼的楼门号”,依次排开。
声明方式为:int (*funcs[4])(int, int);
读法:funcs 先和[4]结合,说明 funcs 是一个含有 4 个元素的数组;元素类型是int (*)(int, int),也就是指向“接收两个 int 返回 int 的函数”的指针。所以翻译成大白话:funcs 是一张能登记 4 个函数地址的表。
初始化更直白:int (*funcs[4])(int, int) = {add, sub, mul, divide};
这里每个元素直接就是一个函数名,编译器会把这些函数的入口地址依次放进数组。
调用时:funcs[choice](x, y);
choice 是用户输入的数字。传 0 就执行加法,传 1 就执行减法。这就是计算器菜单的核心机制——用一个整数当索引,代替一堆 if-else 或 switch-case。把代码从“每来一个操作写一个分支”变成“查表后直接跳转”,程序结构瞬间清爽。
3. 手写一版可直接运行的完整答案:带菜单的整数计算器
3.1 核心设计思路
这一版完整代码,我用四个函数分别实现加减乘除,再用一个函数指针数组去登记它们。主循环负责打印菜单、读用户输入、根据编号调用对应函数。这样设计天然贴练习题要求,也方便你后面改成浮点数版本或扩展开方、取余等操作。
3.2 上代码,完整可编译
#include <stdio.h> 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) { if (b == 0) { printf("除数不能为0,本次调用返回0。\n"); return 0; } return a / b; } int main(void) { int (*funcs[4])(int, int) = {add, sub, mul, divide}; char ops[4] = {'+', '-', '*', '/'}; int x, y, choice; while (1) { printf("\n======== 计算器菜单 ========\n"); printf("[0] 加法 [1] 减法 [2] 乘法 [3] 除法 [-1] 退出\n"); printf("请选择功能编号: "); scanf("%d", &choice); if (choice == -1) { break; } if (choice < 0 || choice > 3) { printf("无效选项,请输入0到3之间的数字。\n"); continue; } printf("请输入两个整数: "); scanf("%d %d", &x, &y); printf("%d %c %d = %d\n", x, ops[choice], y, funcs[choice](x, y)); } printf("程序结束。\n"); return 0; }3.3 逐步说明,每一段都不白写
函数定义部分,加、减、乘很简单。除法的判断值得注意:如果你不在 divide 内部做除零保护,用户在菜单里选 3 再输入 5 0,程序会直接崩溃。练习题往往不要求这么细,但真正拿得出手的代码必须考虑运行时的异常输入。
main 函数里的int (*funcs[4])(int, int)是整道题的眼睛,前面已经拆过,它就是一张登记了 4 个函数地址的表。char ops[4]存运算符符号,只是为了输出结果时显示成 5 + 3 这种自然格式,和函数调用本身无关。
while 循环里先读 choice,判断退出条件再判断范围。这里的顺序有讲究:先判断是否等于 -1,再判断是否越界,避免输入 -1 时先掉进“无效选项”的分支导致无法退出。实际写的时候我确实见过很多同学把这个顺序写反,结果想退出却一直提示无效选项。
funcs[choice](x, y)是核心调用。这一行等价于:
switch (choice) { case 0: result = add(x, y); break; case 1: result = sub(x, y); break; case 2: result = mul(x, y); break; case 3: result = divide(x, y); break; }区别在哪?每增加一个功能,switch 方案要加 case 分支、修逻辑;函数指针数组方案只需要加一个函数、把它的名字填进数组。代码的扩展点变少、更集中,这就是为什么命令分发、插件系统、菜单系统这些实际场景偏爱函数指针数组。
3.4 你可能遇到的第一个意外:函数指针数组越界
运行上面代码,输入 0 到 3 都没问题。但如果把越界判断注释掉,输入 5 会怎样?funcs[5]会去内存里某个随机地址“取函数入口”,然后跳过去执行。轻则跑出一堆乱码结果,重则直接段错误。我在排查类似问题时的明显感受是:越界访问指针数组比越界访问普通 int 数组更难定位,因为问题爆发点未必在数组访问那一行,而是在“跳到了错误地址之后”,所以练习时务必保留边界判断。
4. 编译期三个最常踩的坑,我挨个给你列出来
练习 73 本身不难,但我在给初学同学看代码的时候,见到的报错类型高度集中。这站点个名,省得大家反复踩。
4.1 赋值时函数名后面加了括号
int (*funcs[4])(int, int) = {add(), sub(), mul(), divide()};这是新手最容易写错的一行。问题是:add()是“立刻执行 add 函数并拿返回值”,而add才是“拿函数的地址”。加法函数返回整数,于是这行代码试图把 4 个整数塞进一个函数指针数组,编译器直接报类型不兼容。理解上可以记一句话:函数指针数组初始化时,右边只写函数名,永远不要跟括号。
4.2 声明时括号位置不对
int *funcs[4](int, int); // 错误 int (*funcs[4])(int, int); // 正确直接把声明读出来:第一种是“funcs 是一个数组,元素是返回 int 指针的函数”?不对,元素如果是函数,那它必须有函数名位置,这行代码编译器甚至不知道怎么解析。第二种是“funcs 是一个 4 元素数组,每个元素是指向函数的指针”。两者差距毫厘,意义天壤之别。我建议判断规则:先用星号加括号把“指针”身份锁死,再写数组维度,顺序不要反。
4.3 函数参数类型对不上
如果你把 add 声明成int add(double a, double b),前面初始化时 funcs 数组的元素类型却是int (*)(int, int),编译器一样报错。概念上很好理解:两个楼门牌号虽然都是“地址”,但一栋楼进门需要两个 int,另一栋楼需要两个 double,你不能把 double 楼的地址登记进 int 楼的通讯录里。练习73里统一参数类型为 int 是最省事的,如果后面想升级成 double 版本,记得把所有函数签名和函数指针类型全部同步替换,代码量不大,最怕漏改其中一两个。
4.4 我的排查思路,供你参考
遇到编译报错,先看冒号前的行号,再看待处理类型。这类报错几乎都会提到 incompatible types、expected ... but got ... 之类的关键字。定位到具体行之后,对照上面三件事逐一检查:赋值是否加了括号、声明括号是否错位、参数类型是否一致。
如果代码能编译但运行结果诡异,比如输入 0 却不执行加法,先打印函数地址验证登记表是否初始化正确:
printf("add 的地址: %p\n", (void *)add); printf("funcs[0] 的内容: %p\n", (void *)funcs[0]);如果两个值一致,说明登记正确,问题在别处;如果 funcs[0] 是未初始化垃圾值,说明数组初始化语句有误。这种“先打印地址”的验证思路,也是以后做指针相关项目时最常用的排错手法。
5. 同样一套思路,还能用在哪些场景
练习73写完之后别急着翻下一题,函数指针数组这个模式能迁移到很多地方,往下延展的价值比题目本身大得多。
5.1 成绩统计:按不同字段排序
假设你写一个学生成绩程序,要求支持按学号排序、按姓名排序、按总分排序。最笨的做法是写三个排序函数,然后复制粘贴三份排序逻辑,只在比较那一行做改动。函数指针数组的做法是:写一个通用的排序函数,接收一个“比较器”函数指针参数,再写三个比较函数,分别比较学号、姓名、总分。调用时把对应比较器的地址传进去。
实际做法如下:参与比较的两个学生结构体指针分别为 p1、p2,比较学号时写成return p1->id - p2->id;;比较总分时写成return p1->total - p2->total;。排序算法本身完全不动。这比直接复制排序代码少维护至少 70% 的重复逻辑,出 bug 的概率也大幅下降。练习73教你的是“怎么把函数地址存进数组”,这个场景教你的是“怎么把函数地址作为参数传递”,本质是同一条知识线的两级台阶。
5.2 命令分发:文本菜单系统
写一个文本界面的小工具,用户输入 help、quit、list 等命令,程序分别执行对应处理函数。工程上常用的做法是维护一张结构体表:
struct Command { char *name; void (*handler)(void); };每个命令对应一个结构体元素,比如{"help", showHelp}、{"quit", quitProgram}。程序收到字符串命令后,遍历这张表,匹配到 name 就调用 handler,匹配失败输出未知命令。
这和练习73简直一模一样,只是把“整数索引查表”换成了“字符串匹配查表”。你甚至可以把表中的 handler 定义成“接收字符串参数并返回整数”的函数指针,用于支持带参数的命令,比如move 100、count 3 5。凡是要做命令解析的地方,几乎都逃不出这个模式。
5.3 回调机制:库函数 qsort 的比较器
很多人用 qsort 时只记住了格式,不知道那个比较器参数到底是什么。实际上qsort(数组, 元素个数, 元素大小, 比较器)的核心就是把一个函数指针交给库函数,让库函数在排序过程中调用它。你提供一个“如何在两个元素之间比较大小”的函数,排序算法就完成了泛型化。
标准库头文件 stdlib.h 里的 qsort 函数,其最后一个参数的类型就是函数指针。写比较器时,示例写法为:
int compareInts(const void *a, const void *b) { int ia = *(const int *)a; int ib = *(const int *)b; return (ia > ib) - (ia < ib); }然后调用qsort(arr, n, sizeof(int), compareInts);即可。这里的 compareInts 就是一个回调函数。理解了练习73的函数指针赋值与调用,再看这个参数就没什么神秘感了。往后接触 gui 按钮事件回调、网络请求完成回调、定时器回调,底层都是同一件事。
5.4 最后分享一个练习建议
刷这种经典题目,我比较推荐的节奏是:第一遍看着答案把代码敲通,重点标注自己卡住的地方;第二天合上答案默写一遍,你会发现函数指针声明那一行大概率还是错,这恰恰说明知识还没真正长在你身上;过一周再把这题改造成浮点数计算器,顺便增加一个“取余”功能。三遍走完,函数指针数组基本就熟得和 for 循环一样自然了。
经典100例的价值从来不在“刷完”,而在“每个例子背后的那个模式,你能不能在下个项目里想起来用”。练习73这种题,就是给函数指针这个重要模式留的一个锚点,想清楚这层,你就没白做这道题。