15、模板方法模式:函数指针模板、算法骨架、钩子函数
模板方法模式,说白了就是「把不变的流程固定下来,把变化的部分留给别人去实现」。
我在嵌入式项目里用过很多次这个模式。比如一个数据采集流程:打开设备、配置参数、开始采集、处理数据、关闭设备。这个流程是固定的,但具体怎么打开设备、怎么处理数据,不同硬件差别很大。模板方法模式就是干这个的。
15.1 什么是模板方法模式
模板方法模式定义了一个算法的骨架,把一些步骤延迟到子类去实现。这样,算法的结构不变,但具体步骤可以灵活变化。
在C语言里,我们没有继承和多态,但可以用函数指针来实现类似的效果。说白了,就是定义一个函数,它接受几个函数指针作为参数,然后按照固定顺序调用它们。
核心思想:「固定流程 + 可变步骤」
- 算法骨架:不可变的主流程
- 抽象步骤:需要外部提供的函数
- 钩子函数:可选的自定义扩展点
15.2 函数指针模板:C语言的实现方式
先看一个简单的例子。假设我们要实现一个「初始化→运行→清理」的流程。
// 定义函数指针类型
typedef int (*init_func_t)(void);
typedef int (*run_func_t)(void);
typedef void (*cleanup_func_t)(void);
// 模板函数:固定流程
int template_process(init_func_t init,
run_func_t run,
cleanup_func_t cleanup)
{
int ret;
// 第一步:初始化
ret = init();
if (ret != 0) {
return ret;
}
// 第二步:运行
ret = run();
if (ret != 0) {
cleanup();
return ret;
}
// 第三步:清理
cleanup();
return 0;
}
你看,这个 template_process 就是算法骨架。它不管具体怎么初始化、怎么运行、怎么清理,只负责按顺序调用。
我在项目中遇到过一种情况:不同传感器模块的初始化流程完全不同。有的需要I2C配置,有的需要SPI设置,还有的需要等待硬件稳定。用模板方法模式,我把初始化函数作为参数传进去,主控流程完全不用改。
15.3 算法骨架:固定流程的设计
算法骨架是模板方法模式的核心。它定义了步骤的顺序、条件判断、循环等控制逻辑。
举个例子,一个通用的数据处理流程:
typedef struct {
int (*open)(void);
int (*read)(uint8_t *buf, uint32_t len);
int (*process)(uint8_t *buf, uint32_t len);
int (*write)(uint8_t *buf, uint32_t len);
void (*close)(void);
} data_pipeline_t;
int run_pipeline(data_pipeline_t *pipeline)
{
uint8_t buffer[1024];
uint32_t len;
int ret;
// 1. 打开资源
ret = pipeline->open();
if (ret != 0) return ret;
// 2. 循环读取和处理
while (1) {
ret = pipeline->read(buffer, sizeof(buffer));
if (ret < 0) {
pipeline->close();
return ret;
}
if (ret == 0) break; // 读取完毕
len = (uint32_t)ret;
// 3. 处理数据
ret = pipeline->process(buffer, len);
if (ret != 0) {
pipeline->close();
return ret;
}
// 4. 写入结果
ret = pipeline->write(buffer, len);
if (ret != 0) {
pipeline->close();
return ret;
}
}
// 5. 关闭资源
pipeline->close();
return 0;
}
这个 run_pipeline 就是算法骨架。它定义了「打开→循环读取→处理→写入→关闭」这个固定流程。具体每个步骤怎么实现,由调用者提供函数指针。
我的习惯:把算法骨架放在一个单独的文件里,比如 pipeline.c。这样整个团队都用同一个流程,不会出现「张三的采集流程先处理再读取,李四的先读取再处理」这种混乱。
15.4 钩子函数:灵活的自定义扩展点
钩子函数是模板方法模式里很灵活的一个设计。它允许你在算法骨架的特定位置插入自定义逻辑,而不影响主流程。
举个例子,在数据采集流程中,你可能想在每次采集完成后打印日志,或者在某些条件下提前终止采集。这些都可以用钩子函数实现。
typedef struct {
// 核心步骤
int (*init)(void);
int (*collect)(uint32_t *data);
int (*finish)(void);
// 钩子函数(可选)
void (*on_start)(void); // 开始采集时调用
void (*on_collect)(uint32_t *data); // 每次采集后调用
int (*should_stop)(void); // 是否提前停止
} collector_t;
int run_collector(collector_t *c, uint32_t *buffer, int count)
{
int i;
int ret;
// 钩子:开始
if (c->on_start) c->on_start();
// 初始化
ret = c->init();
if (ret != 0) return ret;
// 循环采集
for (i = 0; i < count; i++) {
ret = c->collect(&buffer[i]);
if (ret != 0) {
c->finish();
return ret;
}
// 钩子:每次采集后
if (c->on_collect) c->on_collect(&buffer[i]);
// 钩子:检查是否提前停止
if (c->should_stop && c->should_stop()) {
break;
}
}
// 结束
c->finish();
return i;
}
你看,on_start、on_collect、should_stop 都是钩子函数。调用者可以选择实现它们,也可以传 NULL 跳过。
注意:钩子函数不能破坏算法骨架的稳定性。我曾经见过一个项目,有人在钩子里直接修改了流程控制变量,导致整个采集流程乱套。钩子函数应该只做「观察」和「轻量级干预」,不要改变核心逻辑。
15.5 实战案例:通用通信协议栈
我在一个物联网项目里用过模板方法模式来实现通信协议栈。不同设备使用不同的物理层(UART、SPI、I2C),但上层协议是一样的。
// 通信协议栈的算法骨架
typedef struct {
// 物理层操作(必须实现)
int (*send_byte)(uint8_t byte);
int (*recv_byte)(uint8_t *byte);
// 协议层操作(可选钩子)
void (*on_packet_start)(void);
void (*on_packet_end)(uint32_t crc);
int (*on_error)(int error_code);
} comm_stack_t;
int send_packet(comm_stack_t *stack,
uint8_t *data, uint32_t len)
{
uint32_t i;
uint32_t crc = 0;
// 钩子:包开始
if (stack->on_packet_start) stack->on_packet_start();
// 发送包头
if (stack->send_byte(0xAA) != 0) goto error;
if (stack->send_byte(0x55) != 0) goto error;
// 发送长度
if (stack->send_byte((uint8_t)(len & 0xFF)) != 0) goto error;
// 发送数据
for (i = 0; i < len; i++) {
if (stack->send_byte(data[i]) != 0) goto error;
crc += data[i];
}
// 发送校验
if (stack->send_byte((uint8_t)(crc & 0xFF)) != 0) goto error;
// 钩子:包结束
if (stack->on_packet_end) stack->on_packet_end(crc);
return 0;
error:
if (stack->on_error) stack->on_error(-1);
return -1;
}
这个 send_packet 函数就是算法骨架。不管底层是UART还是SPI,发送流程都一样:包头→长度→数据→校验。具体怎么发一个字节,由 send_byte 函数指针决定。
避坑指南:我曾经在实现钩子函数时,不小心在 on_packet_end 里调用了 send_byte,导致递归调用。嗯,这个问题排查了很久。记住:钩子函数里不要调用算法骨架中的核心步骤,除非你明确知道自己在做什么。
15.6 模板方法模式的优缺点
| 优点 | 缺点 |
|---|---|
| 代码复用:算法骨架只写一次 | 函数指针调用有额外开销 |
| 流程统一:团队使用相同流程 | 调试时跳转较多,跟踪困难 |
| 扩展灵活:通过钩子函数定制 | 钩子函数过多会降低可读性 |
| 测试方便:可以mock各个步骤 | 需要管理多个函数指针 |
15.7 知识体系图
下面这张图展示了模板方法模式的核心逻辑和各个组成部分的关系:
15.8 总结
模板方法模式在C语言里非常实用。它用函数指针实现了「固定流程 + 可变步骤」的设计思想。
我个人习惯在以下场景使用它:
- 多个模块有相同的处理流程,但具体实现不同
- 需要统一团队的代码风格和流程规范
- 希望在不修改核心代码的情况下扩展功能
记住三个关键点:算法骨架要稳定,抽象步骤要清晰,钩子函数要克制。做到这三点,你的代码就会既灵活又可靠。
小技巧:在调试模板方法模式的代码时,可以在算法骨架里加一些调试打印,比如在调用每个步骤前后打印函数名。这样能快速定位问题出在哪个步骤。