16、函数指针数组:转移表的实现、状态机的指针实现、命令模式与函数指针
好,咱们今天聊点硬核的。函数指针数组,这名字听着就有点劝退,对吧?
说实话,我早年刚接触C语言时,也觉得这东西就是炫技。直到我在一个嵌入式项目里,被一堆if-else和switch-case折磨得欲仙欲死……嗯,从那以后,我彻底服了。函数指针数组,说白了就是一张“跳转表”,让你用数组下标直接调用函数,省掉一堆条件判断。
16.1 转移表:用数组下标代替if-else
先看一个最经典的场景。假设你有一个计算器,支持加减乘除。常规写法是这样的:
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 div(int a, int b) { return a / b; }
int calc(int op, int a, int b) {
switch(op) {
case 0: return add(a, b);
case 1: return sub(a, b);
case 2: return mul(a, b);
case 3: return div(a, b);
default: return 0;
}
}
这段代码没问题,但如果你有几十个操作呢?switch-case会变得又臭又长。而且每次调用都要经过一次跳转判断,性能上也有损耗。
用函数指针数组改写一下:
int (*op_table[])(int, int) = {add, sub, mul, div};
int calc(int op, int a, int b) {
if (op < 0 || op >= sizeof(op_table)/sizeof(op_table[0]))
return 0;
return op_table[op](a, b);
}
你看,核心逻辑就一行:op_table[op](a, b)。数组下标直接定位到函数,没有分支预测失败,没有层层判断。我在一个通信协议解析项目里用过这个技巧,解析速度提升了将近30%。
核心思想:函数指针数组 = 一张跳转表。用数据驱动逻辑,而不是用逻辑驱动数据。
16.2 状态机的指针实现
状态机是嵌入式开发里的老朋友了。传统写法是用switch-case枚举状态,然后每个case里处理事件并跳转。但状态一多,代码就成了一团乱麻。
我个人习惯用函数指针数组来实现状态机。每个状态是一个函数,状态转移就是改变函数指针的值。来看一个简单的例子:一个两状态的LED控制器。
typedef void (*state_func)(void);
void state_on(void);
void state_off(void);
state_func current_state = state_off;
void state_on(void) {
// 点亮LED
LED_ON();
// 检测到关闭事件,切换到关状态
if (button_pressed()) {
current_state = state_off;
}
}
void state_off(void) {
LED_OFF();
if (button_pressed()) {
current_state = state_on;
}
}
void state_machine_run(void) {
current_state(); // 直接调用当前状态函数
}
这里没有switch,没有if-else链。状态切换就是改变一个函数指针的值。我曾经在一个工业控制项目里,用这种方式管理了20多个状态,代码清晰得像一张表格。
小技巧:可以把所有状态函数放在一个数组里,用枚举作为下标。这样状态切换就变成了 current_state = state_table[NEXT_STATE],可读性极佳。
16.3 命令模式与函数指针
命令模式是设计模式里的一种,说白了就是把“请求”封装成一个对象。在C语言里,没有对象怎么办?用函数指针数组模拟。
假设你有一个设备,支持多种命令:启动、停止、复位、配置。每个命令对应一个处理函数。你可以这样组织:
typedef void (*cmd_handler)(void *param);
typedef struct {
uint8_t cmd_id;
cmd_handler handler;
char *desc;
} command_t;
void cmd_start(void *param) { /* 启动逻辑 */ }
void cmd_stop(void *param) { /* 停止逻辑 */ }
void cmd_reset(void *param) { /* 复位逻辑 */ }
void cmd_config(void *param) { /* 配置逻辑 */ }
command_t cmd_table[] = {
{0x01, cmd_start, "启动设备"},
{0x02, cmd_stop, "停止设备"},
{0x03, cmd_reset, "复位设备"},
{0x04, cmd_config,"配置参数"},
};
void dispatch_command(uint8_t cmd_id, void *param) {
for (int i = 0; i < sizeof(cmd_table)/sizeof(cmd_table[0]); i++) {
if (cmd_table[i].cmd_id == cmd_id) {
cmd_table[i].handler(param);
return;
}
}
// 未找到命令,报错
}
你看,每个命令就是一个结构体,包含ID、处理函数和描述。新增一个命令,只需要在数组里加一项,不用改任何逻辑代码。这就是命令模式的精髓——把变化封装在数据里。
注意:函数指针数组虽然灵活,但调试时不太直观。你没法直接看到当前调用的是哪个函数。我建议在开发阶段,给每个函数指针加一个调试打印,或者用符号表辅助定位。
16.4 知识体系总览
下面这张图,把函数指针数组的三大应用场景串起来了。你可以看到,转移表、状态机、命令模式,本质上都是同一件事:用数组索引代替条件分支。
16.5 避坑指南
函数指针数组好用,但坑也不少。我把自己踩过的坑列出来,你注意避开:
- 数组越界:函数指针数组不检查下标合法性。我曾经因为一个下标越界,直接跳到了非法地址,系统当场崩溃。一定要加边界检查。
- 函数签名不一致:所有函数指针必须具有完全相同的签名(参数类型和返回值)。如果某个函数参数不同,编译器不会报错,但运行时必挂。
- 可读性下降:函数指针数组让代码更紧凑,但也更难跟踪。建议配合枚举和注释使用,让每个下标的意义一目了然。
- 调试困难:你没法在调试器里直接看到“当前调用的是哪个函数”。我习惯在关键位置加一个调试变量,记录当前执行的函数指针值。
我的习惯:每次定义函数指针数组时,我都会同时定义一个同名的字符串数组,存放每个函数的名称。这样调试时可以直接打印出来,方便定位问题。
好了,函数指针数组的内容就聊到这里。这东西看着简单,用好了能让你的代码脱胎换骨。下次写状态机或者命令解析器时,试试用函数指针数组,你会回来感谢我的。
公众号:蓝海资料掘金营,微信deep3321