3、接口设计原则(上):高内聚低耦合、最小接口原则、接口稳定性
接口设计这件事,说白了就是定规矩。规矩定得好,团队协作顺畅,代码维护轻松。规矩定得烂,改一个功能要牵连十几个模块,加班到天亮也未必能搞定。
我做了十几年嵌入式,见过太多因为接口设计不合理导致的惨案。今天咱们就聊聊接口设计的三个核心原则:高内聚低耦合、最小接口原则、接口稳定性。这三个原则是接口设计的基石,理解透了,你的代码质量会上一个台阶。
3.1 高内聚低耦合:模块的自我修养
先问个问题:你写一个驱动模块,里面该放什么?不该放什么?
高内聚的意思是:一个模块内部的东西,应该紧密相关。比如一个UART驱动,它只负责UART的初始化、发送、接收、中断处理。别把I2C的代码塞进去,也别把日志打印的逻辑混进来。
低耦合的意思是:模块之间的依赖关系要尽量少。驱动层改了,应用层最好不用动。反过来也一样。
核心指标:
- 内聚度:模块内部元素之间的关联程度。越高越好。
- 耦合度:模块之间的依赖程度。越低越好。
我在项目中遇到过这样一个案例:一个同事把温度传感器的校准算法直接写在了驱动层的读取函数里。结果换了传感器型号,不仅要改驱动,还要改所有调用这个读取函数的上层代码。这就是典型的低内聚、高耦合。
正确的做法是:驱动层只负责硬件操作,校准算法放在中间层或者应用层。驱动层提供原始数据,上层自己处理。
3.1.1 内聚的几种类型
| 类型 | 描述 | 评价 |
|---|---|---|
| 功能内聚 | 模块内所有元素共同完成一个单一功能 | 最佳 |
| 顺序内聚 | 模块内元素按顺序执行,前一个输出是后一个输入 | 良好 |
| 通信内聚 | 模块内元素操作同一数据 | 一般 |
| 过程内聚 | 模块内元素按特定顺序执行,但数据不共享 | 较差 |
| 时间内聚 | 模块内元素因执行时间相近而组合 | 差 |
| 逻辑内聚 | 模块内元素因逻辑相似而组合 | 很差 |
| 偶然内聚 | 模块内元素毫无关联 | 最差 |
你想想看,你的驱动模块属于哪种内聚?我建议尽量做到功能内聚。一个驱动文件只负责一种外设,一个函数只做一件事。
3.1.2 耦合的几种类型
| 类型 | 描述 | 评价 |
|---|---|---|
| 数据耦合 | 模块间只通过参数传递数据 | 最佳 |
| 标记耦合 | 模块间传递复杂数据结构,但只使用部分字段 | 良好 |
| 控制耦合 | 一个模块传递控制信息(如标志位)给另一个模块 | 一般 |
| 外部耦合 | 模块间共享外部数据(如全局变量) | 较差 |
| 公共耦合 | 多个模块共享同一全局数据区 | 差 |
| 内容耦合 | 一个模块直接访问另一个模块的内部数据 | 最差 |
避坑指南:我曾经在一个项目中看到有人直接在应用层代码里操作寄存器地址。这就是内容耦合,最糟糕的那种。驱动层封装了硬件细节,应用层就不该知道寄存器长什么样。
3.2 最小接口原则:少即是多
这个原则其实很简单:接口只暴露必要的内容,隐藏所有实现细节。
我见过很多新手喜欢把驱动层的所有函数都暴露给应用层。初始化、配置、读写、状态查询、调试接口……一股脑全放在头文件里。结果呢?应用层开发者面对几十个函数,根本不知道该用哪个。
正确的做法是:只暴露应用层真正需要的接口。比如一个LED驱动,只需要三个函数:led_init()、led_on()、led_off()。至于底层用的是GPIO还是PWM,应用层不需要知道。
我的习惯:写驱动时,我会先问自己一个问题——如果我是应用层开发者,我希望看到什么样的接口?然后只保留那些必要的,其他的全部用 static 隐藏起来。
3.2.1 接口设计清单
设计一个驱动接口时,我通常会按照这个清单来检查:
- 初始化/去初始化:必须提供,且成对出现
- 核心操作:应用层真正需要的读写/控制函数
- 配置接口:如果应用层需要调整参数,提供配置函数
- 状态查询:如果应用层需要知道设备状态,提供查询函数
- 回调注册:如果驱动需要通知应用层事件,提供回调注册接口
除此之外的东西,能省则省。你想想看,接口多了,维护成本就高了,出bug的概率也大了。
3.2.2 代码示例:好的接口 vs 坏的接口
先看一个反面教材:
/* 坏的接口:暴露了太多细节 */
void uart_init(uint32_t baud, uint8_t data_bits, uint8_t stop_bits, uint8_t parity);
void uart_set_baud(uint32_t baud);
void uart_set_data_bits(uint8_t bits);
void uart_set_stop_bits(uint8_t bits);
void uart_set_parity(uint8_t parity);
void uart_enable_tx(void);
void uart_enable_rx(void);
void uart_disable_tx(void);
void uart_disable_rx(void);
void uart_set_tx_pin(uint8_t pin);
void uart_set_rx_pin(uint8_t pin);
void uart_send_byte(uint8_t data);
uint8_t uart_recv_byte(void);
void uart_send_buffer(uint8_t *buf, uint32_t len);
uint32_t uart_recv_buffer(uint8_t *buf, uint32_t max_len);
void uart_register_callback(uart_event_t event, void (*cb)(void));
void uart_debug_dump_regs(void);
再看一个好的接口:
/* 好的接口:只暴露必要内容 */
typedef struct {
uint32_t baud;
uint8_t data_bits;
uint8_t stop_bits;
uint8_t parity;
} uart_config_t;
int uart_init(const uart_config_t *config);
int uart_send(const uint8_t *data, uint32_t len);
int uart_recv(uint8_t *buf, uint32_t max_len, uint32_t timeout_ms);
void uart_register_rx_callback(void (*cb)(uint8_t data));
void uart_deinit(void);
看到了吗?好的接口只有5个函数,而且参数更简洁。应用层开发者一眼就能看懂怎么用。
3.3 接口稳定性:一旦发布,绝不轻易改动
这个原则我吃过不少亏。以前年轻气盛,觉得接口设计得不完美就改,结果每次改接口,所有调用方都要跟着改。改到最后,大家怨声载道。
接口稳定性的意思是:接口一旦发布给其他模块使用,就要保持稳定。如果非要改,必须走变更流程,通知所有受影响方。
核心思想:接口是契约。契约签了,就不能随便撕毁。
3.3.1 如何保证接口稳定性
我总结了几个实用方法:
- 充分测试后再发布:接口设计完成后,先在内部做充分测试,确认没问题再发布给其他团队。
- 预留扩展点:设计接口时,预留一些扩展字段或回调,避免未来需求变化导致接口改动。
- 版本化管理:如果必须改,使用版本号区分新旧接口。旧接口保留一段时间,给调用方迁移时间。
- 使用抽象层:在驱动层和应用层之间加一层抽象,即使驱动层接口变了,抽象层可以适配,应用层不受影响。
3.3.2 接口变更策略
| 变更类型 | 处理方式 | 示例 |
|---|---|---|
| 新增接口 | 直接添加,不影响旧接口 | 增加 uart_set_flow_ctrl() |
| 修改参数 | 新增重载函数,旧函数标记为 deprecated | uart_init_v2() 替代 uart_init() |
| 删除接口 | 先标记 deprecated,至少保留一个版本周期 | 在头文件中加 __attribute__((deprecated)) |
| 行为变更 | 必须通知所有调用方,同步修改 | 修改超时策略,需要应用层配合调整 |
我曾经踩过的坑:有一次我改了一个I2C驱动的接口,把参数顺序调换了。我以为自己记得所有调用方,结果漏了一个模块。那个模块在特定条件下会写入错误的寄存器地址,导致设备死机。从那以后,我再也不敢随便改接口参数顺序了。
3.4 三个原则的关系
这三个原则不是孤立的,它们相互支撑:
- 高内聚低耦合是基础,让模块职责清晰、依赖可控。
- 最小接口原则是方法,让接口简洁、易用、不易出错。
- 接口稳定性是保障,让接口可靠、可维护、可演进。
下面这张图展示了这三个原则在接口设计中的位置和作用:
嗯,这里要注意:这三个原则不是一次性就能做到完美的。接口设计是一个迭代的过程。先设计一个满足当前需求的接口,然后在实践中不断优化。但一旦发布给其他团队使用,就要严格遵守接口稳定性原则。
我个人习惯是在项目初期花足够多的时间来设计接口。因为接口设计阶段的错误,到了后期修复成本会成倍增加。你想想看,改一行驱动代码可能只需要十分钟,但如果这个接口被十个模块调用,改完还要通知所有人、做回归测试……嗯,那可能就是好几天的工作量了。
一个小技巧:设计接口时,先写使用示例代码,再写接口定义。这样能站在调用方的角度思考,更容易设计出简洁易用的接口。