15、模块接口设计原则:高内聚低耦合、最小接口原则、接口稳定性

模块接口设计,说白了就是定义模块之间怎么「说话」。

我见过太多项目,前期接口设计随便搞搞,结果后期联调时鸡飞狗跳。一个接口改了,五个模块跟着崩。嗯,这种痛,经历过的人都懂。

今天咱们就聊聊接口设计的三个核心原则。这三个原则,是我这些年做嵌入式项目时反复验证过的。你如果能吃透它们,写出来的模块会非常「抗揍」。

15.1 高内聚与低耦合:模块的「内功」与「外功」

先问一个问题:你希望你的模块像什么?

我个人希望它像一把瑞士军刀——内部结构紧凑,功能完整;对外只露出一个刀柄,干净利落。这就是高内聚、低耦合的直观感受。

15.1.1 高内聚:模块内部的事,自己搞定

高内聚,指的是一个模块内部的所有元素(函数、变量、宏)都紧密围绕同一个职责。

我在项目中遇到过这样一个案例:有个同事写了一个「通信模块」,里面既有串口收发,又有数据校验,还顺带做了日志记录。结果后来想单独复用串口部分,发现根本拆不开——因为日志函数调用了校验函数,校验函数又依赖串口缓冲区。一团乱麻。

高内聚的判断标准:

  • 模块内的函数,80% 以上操作的是模块内部的私有数据
  • 模块对外暴露的接口,不超过 5~8 个
  • 修改一个功能时,不需要跑到模块外部去改代码

说白了,高内聚就是让模块「自治」。它自己管好自己的状态,自己处理自己的逻辑。外部只需要告诉它「做什么」,不需要关心「怎么做」。

15.1.2 低耦合:模块之间的「弱关系」

低耦合,就是模块之间的依赖要尽可能少、尽可能弱。

你想想看,如果模块 A 直接访问了模块 B 的内部结构体,那模块 B 一改结构体布局,模块 A 就得跟着改。这种「牵一发动全身」的设计,后期维护成本极高。

我常用的耦合度评估方法:

统计一下模块 A 的 .c 文件中,#include 了多少个其他模块的头文件。如果超过 5 个,就要警惕了。如果超过 10 个,基本可以断定耦合失控了。

我曾经接手过一个项目,一个 2000 行的 .c 文件,包含了 23 个头文件。改一个定时器参数,结果影响了 LCD 显示、按键扫描、甚至蜂鸣器。嗯,那段时间我每天都在「拆模块」中度过。

15.2 最小接口原则:少即是多

这个原则,我是在一次惨痛教训后才真正理解的。

当时我设计一个「温度传感器驱动模块」,一口气暴露了 15 个接口:初始化、复位、读ID、读温度、读湿度、设置精度、设置采样率、校准、自检…… 结果呢?

  • 使用者根本记不住这么多接口
  • 每次版本升级,接口签名都要调整
  • 文档写了 30 页,没人看

后来我痛定思痛,把接口砍到只剩 4 个:

// 温度传感器模块接口(最小化版本)
int  tsensor_init(void);                    // 初始化
int  tsensor_read_temperature(float *temp); // 读温度
int  tsensor_read_humidity(float *hum);     // 读湿度
void tsensor_deinit(void);                  // 反初始化

你看,够用了吧?那些校准、自检的功能,全部做成内部函数,或者通过编译开关控制。外部使用者根本不需要知道这些细节。

最小接口原则的检查清单:

  • 这个接口,真的有人用吗?—— 如果没人用,删掉
  • 这个接口,能不能合并到另一个接口里?—— 能合并就合并
  • 这个接口,使用者需要看文档才能用吗?—— 如果需要,说明设计得不够直观

我个人习惯是:一个模块对外暴露的接口,控制在 3~6 个。超过 8 个,我就会重新审视模块的职责划分。

15.3 接口稳定性:别让使用者「追着版本跑」

接口一旦发布,就要尽量保持稳定。为什么?

因为你的接口每改一次,所有调用方都要跟着改。在一个大项目中,调用方可能有几十个文件。改一个接口名,可能意味着几十个文件的修改、测试、回归。

我曾经踩过的坑:

有一个项目,我在 v1.0 版本把初始化接口命名为 module_init(),v1.1 版本觉得 module_setup() 更好听,就改了。结果下游三个团队同时报错,联调推迟了两周。从那以后,我给自己定了一条铁律:接口命名,一次想好,绝不轻易改

15.3.1 如何保证接口稳定性?

我总结了三条实操经验:

  1. 接口参数用「结构体指针」代替「多个独立参数」

    比如:

    // 不稳定的设计
    void uart_config(int baud, int data_bits, int stop_bits, int parity);
    
    // 稳定的设计
    typedef struct {
        int baud;
        int data_bits;
        int stop_bits;
        int parity;
    } uart_config_t;
    
    void uart_config(const uart_config_t *cfg);

    为什么这样好?因为以后想加参数(比如加一个 flow_control 字段),只需要在结构体里加,接口签名不用变。所有旧代码不需要修改。

  2. 接口返回值用「枚举」代替「魔数」

    魔数(比如返回 0 表示成功,-1 表示失败)容易让人困惑。用枚举更清晰:

    typedef enum {
        ERR_OK       = 0,
        ERR_TIMEOUT  = -1,
        ERR_INVALID  = -2,
        ERR_BUSY     = -3
    } err_t;
  3. 预留扩展位

    在结构体末尾留一个 reserved 字段,或者用位域留几个保留位。这样以后扩展时,不会破坏二进制兼容性。

15.4 三个原则的关系与权衡

这三个原则不是孤立的,它们互相影响:

  • 高内聚 是基础——模块内部不乱,外部接口才能简洁
  • 最小接口 是手段——接口少了,耦合自然就低了
  • 接口稳定性 是目标——最终目的是让使用者安心

但有时候它们也会冲突。比如:为了接口稳定,你可能想多暴露几个「备用接口」;但为了最小接口,你又想砍掉它们。怎么办?

我的做法是:优先保证稳定性。如果一个接口未来大概率会被用到,那就先留着,但标记为 @deprecated 或者用条件编译控制。等确定不需要了,再在下一个大版本中移除。

一个小技巧:

在头文件里用 #ifdef __DEPRECATED__ 包裹那些「可能废弃」的接口。这样既保留了接口,又给了使用者明确的信号。

15.5 知识体系总览

下面这张图,把本章的核心逻辑串起来了。你可以把它当作接口设计的「检查地图」:

模块接口设计三原则 高内聚低耦合 模块内部职责单一 模块间依赖最少 修改不波及外部 最小接口原则 接口数量 3~6 个 每个接口职责清晰 使用者无需看文档 接口稳定性 签名一次确定不改 用结构体传参 预留扩展位 基础 手段 共同目标 实践建议 1. 每个模块的 .h 文件只暴露必要的接口,内部函数用 static 隐藏 2. 接口参数优先使用结构体指针,方便未来扩展 3. 接口命名统一前缀(如模块名_动作),避免命名冲突 4. 接口变更时,旧接口保留一个版本周期,用 @deprecated 标记

15.6 避坑指南

最后,分享几个我亲身踩过的坑,希望能帮你少走弯路:

我曾经犯过的错:

  • 过度设计接口:一开始就想把所有功能都暴露出去,结果接口数量爆炸。后来学会「按需暴露」,先只暴露最常用的,等使用者提需求再加。
  • 忽略接口的「隐式依赖」:比如模块 A 的初始化函数必须在模块 B 之后调用,这种依赖没写在文档里,结果别人调用时顺序搞反,查了半天 bug。
  • 接口参数类型用 int 代替枚举:比如用 0 表示串口1,1 表示串口2。后来有人传了个 3 进来,程序直接跑飞。改成枚举后,编译器就能帮你检查了。

嗯,接口设计这件事,说白了就是「替别人着想」。你设计接口时多花点心思,别人用起来就少掉点头发。反过来,你调用别人接口时,也会感激那个设计得清爽的模块。

记住:好的接口设计,是让使用者「不需要思考」就能用对。


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