接口与实现分离:抽象接口设计、回调函数机制、函数指针作为接口、模块间解耦

说实话,模块化编程这件事,很多人一开始都理解偏了。

他们以为把代码拆成几个文件,每个文件里放几个函数,就叫模块化了。其实不是的。真正的模块化,核心在于接口与实现的分离。说白了,就是让别人用你的代码时,不需要知道里面是怎么写的。你改内部逻辑,别人不用改调用方式。

我早年做嵌入式项目时,就吃过这个亏。一个传感器驱动模块,里面直接调用了I2C的底层函数。后来换了MCU,I2C库变了,结果所有用到传感器的地方全得改。那叫一个惨。从那以后,我养成了一个习惯:先定义接口,再写实现

抽象接口设计:先画好边界

接口是什么?不是头文件里那些函数声明那么简单。接口是模块之间的契约。你承诺提供什么功能,调用方承诺怎么使用。

我习惯把接口设计分成三步:

  1. 梳理功能清单:这个模块到底要对外暴露什么能力?
  2. 定义数据结构:输入输出用什么类型?结构体还是基本类型?
  3. 确定调用方式:同步还是异步?阻塞还是非阻塞?

举个例子,一个简单的温度传感器模块,接口可以这样设计:

// sensor_interface.h
#ifndef SENSOR_INTERFACE_H
#define SENSOR_INTERFACE_H

#include <stdint.h>

typedef struct {
    int32_t temperature;  // 温度值,单位0.1°C
    uint8_t humidity;     // 湿度值,单位%
    uint8_t crc;          // 校验值
} sensor_data_t;

// 初始化传感器
int sensor_init(void);

// 读取一次数据
int sensor_read(sensor_data_t *data);

// 进入低功耗模式
void sensor_sleep(void);

// 唤醒传感器
void sensor_wakeup(void);

#endif

你看,这个接口里没有任何底层硬件的影子。调用方只需要知道:初始化、读取、休眠、唤醒。至于背后是I2C还是SPI,是模拟传感器还是数字传感器,调用方完全不用关心。

核心原则:接口只描述“做什么”,不描述“怎么做”。

函数指针作为接口:让行为可替换

有时候,光靠函数声明还不够。比如你写了一个数据采集模块,需要把采集到的数据存到不同的地方——有人想存SD卡,有人想存Flash,还有人想通过串口发出去。你总不能为每种存储方式都写一个采集模块吧?

这时候,函数指针就派上用场了。

我习惯把函数指针当作可插拔的行为单元。你想想看,如果采集模块里直接调用一个写函数,那这个写函数是谁?不知道。它只是一个指针,指向一个具体的实现。

// data_acquisition.h
#ifndef DATA_ACQUISITION_H
#define DATA_ACQUISITION_H

#include "sensor_interface.h"

// 定义写入函数的类型
typedef int (*write_func_t)(const uint8_t *data, uint32_t len);

// 注册写入回调
void daq_register_write_callback(write_func_t func);

// 启动一次采集
int daq_start_acquisition(void);

#endif

实现起来也很简单:

// data_acquisition.c
#include "data_acquisition.h"

static write_func_t s_write_func = NULL;

void daq_register_write_callback(write_func_t func) {
    s_write_func = func;
}

int daq_start_acquisition(void) {
    sensor_data_t data;
    int ret = sensor_read(&data);
    if (ret != 0) return -1;

    if (s_write_func != NULL) {
        return s_write_func((const uint8_t *)&data, sizeof(data));
    }
    return -2;
}

调用方只需要注册自己的写入函数:

// main.c
#include "data_acquisition.h"
#include "sd_card_driver.h"

int main(void) {
    sensor_init();
    daq_register_write_callback(sd_card_write);
    daq_start_acquisition();
    return 0;
}

你看,采集模块和存储模块之间,完全通过一个函数指针解耦了。想换存储方式?换个回调函数就行,采集模块一行代码都不用改。

我的习惯:回调函数的注册函数,命名统一用 xxx_register_yyy_callback。这样团队里其他人一看就知道是干什么的。

回调函数机制:异步通知的优雅方式

回调函数不只是用来解耦的,它还是实现异步通知的好帮手。

我做过一个项目,需要定时采集传感器数据,采集完成后通知主控做处理。如果采用轮询方式,主控就得一直等着,啥也干不了。用回调就清爽多了:

// timer_callback.h
#ifndef TIMER_CALLBACK_H
#define TIMER_CALLBACK_H

typedef void (*timer_callback_t)(void *context);

// 注册定时回调
int timer_register_callback(timer_callback_t cb, void *context, uint32_t interval_ms);

// 启动定时器
void timer_start(void);

#endif

实现里,定时器中断触发时,直接调用注册进来的回调函数:

// timer_callback.c
static timer_callback_t s_cb = NULL;
static void *s_context = NULL;

void timer_isr_handler(void) {
    if (s_cb != NULL) {
        s_cb(s_context);
    }
}

调用方只需要:

void on_data_ready(void *ctx) {
    // 数据准备好了,开始处理
    sensor_data_t *data = (sensor_data_t *)ctx;
    process_data(data);
}

int main(void) {
    sensor_data_t buffer;
    timer_register_callback(on_data_ready, &buffer, 1000);
    timer_start();
    // 主循环可以干别的事
    while(1) {
        handle_other_tasks();
    }
}

嗯,这里要注意一点:回调函数里不要做耗时操作。中断上下文里跑太久,系统就崩了。我一般只在回调里设置一个标志位,或者把数据扔到队列里,真正的处理放到主循环或者任务里去。

我曾经踩过的坑:回调函数里直接调用了printf,结果中断里跑打印,耗时太长,导致定时器丢中断。后来改成只设置标志位,主循环里再打印,问题就解决了。

模块间解耦:从依赖到协作

接口与实现分离的最终目的,就是解耦。两个模块之间,不应该有直接的依赖关系。它们应该通过接口来协作。

我画了一张图,展示这种解耦的结构:

采集模块 (调用方) 调用回调函数 存储模块 (实现方) 提供写入函数 接口层 函数指针 + 注册机制 (解耦的桥梁) 注册回调 调用实现 模块A不直接依赖模块B,只依赖接口层

你看,采集模块和存储模块之间没有直接连线。它们都只跟接口层打交道。采集模块注册一个回调,存储模块提供一个函数。接口层负责把它们连起来。

这样做的好处是什么?

  • 可替换性:想换存储方式?换个回调函数就行。
  • 可测试性:测试采集模块时,可以注册一个假的回调函数,不用真的去读写SD卡。
  • 可复用性:采集模块可以复用到不同项目里,只要新项目提供对应的回调函数。

一句话总结:接口与实现分离,就是用函数指针做桥梁,让模块之间只认接口,不认具体实现。

实际项目中的避坑指南

说了这么多好处,我也得说说实际中容易踩的坑。

第一个坑:回调函数上下文丢失

我早期写代码时,回调函数里需要访问一些全局变量。后来代码重构,全局变量挪到了结构体里,结果回调函数拿不到数据了。解决办法就是给回调函数传一个 void *context 参数,把需要的上下文传进去。上面代码里我已经这么做了。

第二个坑:函数指针类型不匹配

函数指针的类型定义一定要严格。参数个数、参数类型、返回值类型,一个都不能错。我见过有人把 int (*)(int) 强转成 void (*)(void),结果调用时栈乱了,程序跑飞。嗯,这种bug特别难查。

第三个坑:回调注册顺序

如果模块A在初始化时就会触发回调,但模块B还没注册回调函数,那就出问题了。我一般会在模块初始化函数里检查回调函数是否已经注册,如果没有,就返回错误码。

int daq_start_acquisition(void) {
    if (s_write_func == NULL) {
        return -3;  // 回调未注册
    }
    // ... 正常采集流程
}

我的建议:在模块的初始化文档里,明确写出回调函数必须在什么时候注册。团队协作时,这能省去很多沟通成本。

接口与实现分离,说白了就是面向接口编程。你写代码时,多想想:这个模块的接口够不够干净?调用方需要知道多少内部细节?如果换一个实现,调用方需要改多少代码?

想清楚这些问题,你的代码自然就模块化了。


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