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_starton_collectshould_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 知识体系图

下面这张图展示了模板方法模式的核心逻辑和各个组成部分的关系:

模板方法模式知识体系 算法骨架(固定流程) template_process / run_pipeline 抽象步骤1 init / open 抽象步骤2 run / process 抽象步骤3 cleanup / close 钩子函数(可选) on_start / on_collect 钩子函数(可选) should_stop / on_error 实现方式:函数指针 + 结构体

15.8 总结

模板方法模式在C语言里非常实用。它用函数指针实现了「固定流程 + 可变步骤」的设计思想。

我个人习惯在以下场景使用它:

  • 多个模块有相同的处理流程,但具体实现不同
  • 需要统一团队的代码风格和流程规范
  • 希望在不修改核心代码的情况下扩展功能

记住三个关键点:算法骨架要稳定,抽象步骤要清晰,钩子函数要克制。做到这三点,你的代码就会既灵活又可靠。

小技巧:在调试模板方法模式的代码时,可以在算法骨架里加一些调试打印,比如在调用每个步骤前后打印函数名。这样能快速定位问题出在哪个步骤。

公众号:蓝海资料掘金营,微信 deep3321