模块化设计原则(下):开闭原则、里氏替换原则、迪米特法则

好,我们接着聊模块化设计。上一节讲了单一职责和接口隔离,这次要聊的三个原则,说实话,在实际项目中更容易被忽略,但踩坑的代价也更大。

我个人习惯把这三个原则放在一起讲,因为它们都指向同一个核心问题:模块之间的边界到底该怎么划?划得太死,改一个功能要动一片;划得太松,模块之间纠缠不清,谁也不敢动。

开闭原则(Open-Closed Principle)

定义很简单:对扩展开放,对修改关闭。说白了,就是加新功能的时候,尽量别去改已有的代码。

我在项目中遇到过最典型的反例:一个数据采集模块,原本只支持串口,后来要加网络接口。有人直接在采集函数里加了个 if (type == NET) 分支。嗯,当时是快了,但后面加蓝牙、加CAN总线的时候,那个函数膨胀到800多行,谁都不敢动。

核心思路:用抽象层隔离变化。底层实现可以换,但上层的调用接口不变。

看一个简单的C语言示例:

// 抽象接口
typedef struct {
    int (*init)(void);
    int (*send)(uint8_t *data, uint32_t len);
    int (*recv)(uint8_t *buf, uint32_t *len);
} comm_driver_t;

// 串口实现
static int uart_init(void) { /* ... */ }
static int uart_send(uint8_t *data, uint32_t len) { /* ... */ }
static int uart_recv(uint8_t *buf, uint32_t *len) { /* ... */ }

const comm_driver_t uart_driver = {
    .init = uart_init,
    .send = uart_send,
    .recv = uart_recv
};

// 上层业务只依赖 comm_driver_t,不关心具体实现
void app_send_data(comm_driver_t *drv, uint8_t *data, uint32_t len) {
    drv->send(data, len);
}

加新通信方式的时候,只需要新写一个结构体实例,已有的 app_send_data 一行都不用改。这就是开闭原则的落地方式。

小技巧:嵌入式里用函数指针表来实现多态,比C++的虚表更轻量,也更容易控制内存布局。

里氏替换原则(Liskov Substitution Principle)

这个原则的名字听着很学术,其实意思很直白:子类应该能完全替代父类,而且程序的行为不会出问题。

我曾经在一个电机控制项目里吃过亏。基类定义了一个 set_speed(int rpm) 方法,约定输入范围是0~10000。有个子类实现的是步进电机,它偷偷把范围限制到了0~2000,而且超出后直接返回错误码。上层调用者按照基类的约定传了5000,结果电机不动了,查了两天才找到原因。

教训:子类可以扩展功能,但不能削弱父类的约定。返回值范围、异常行为、前置条件,这些都要保持一致。

在嵌入式C里,虽然没有继承语法,但通过结构体嵌套和函数指针表,同样会碰到这个问题:

// 基类"接口"
typedef struct {
    int (*open)(void);
    int (*close)(void);
    int (*ioctl)(uint32_t cmd, void *arg);
} device_t;

// "子类"实现1:正常返回
int sensor_ioctl(uint32_t cmd, void *arg) {
    if (cmd > MAX_CMD) return -EINVAL;
    // 处理命令...
    return 0;
}

// "子类"实现2:违反约定
int fake_sensor_ioctl(uint32_t cmd, void *arg) {
    // 直接返回0,什么都不做 —— 这违反了"ioctl应该执行操作"的约定
    return 0;
}

你想想看,如果上层代码依赖 ioctl 去配置传感器,第二个实现虽然编译通过,但系统根本跑不对。这就是里氏替换原则要防止的问题。

迪米特法则(Law of Demeter)

这个法则有个更形象的名字:最少知识原则。意思是,一个模块只应该和它直接相关的模块通信,不要绕来绕去。

我见过最夸张的代码:一个温度采集模块,为了获取"当前是否需要加热"这个状态,居然调了 system_config->heater->control->mode->auto_enable 这种五层链式访问。后来结构体一改,十几个文件都要跟着改。

迪米特法则的落地规则:

  • 模块A只能调用模块B的公开接口
  • 不能通过模块B去调用模块C的接口
  • 模块内部的结构,对外部不可见

正确的做法是加一层封装:

// 错误:绕来绕去
if (sys_cfg->heater_ctrl.mode.auto_enable) {
    heater_on();
}

// 正确:直接问
if (is_heater_auto_mode()) {
    heater_on();
}

// 封装内部细节
bool is_heater_auto_mode(void) {
    return sys_cfg->heater_ctrl.mode.auto_enable;
}

这样做的好处很明显:哪天结构体改了,只需要改 is_heater_auto_mode 这一个函数,调用者完全不受影响。

模块划分实战案例

光讲理论没意思,我们拿一个真实的嵌入式项目来拆解:智能温控器。

这个系统需要:温度采集、加热控制、LCD显示、按键输入、Wi-Fi通信、OTA升级。如果全塞在一个 main.c 里,那就是灾难。

按照前面讲的原则,我建议这样划分:

模块 职责 依赖 对应原则
sensor 温度采集、滤波、单位转换 硬件I2C/SPI驱动 单一职责
heater PID控制、加热开关、过温保护 sensor(获取温度) 迪米特法则
display LCD驱动、界面渲染、菜单管理 sensor(显示温度) 接口隔离
keypad 按键扫描、去抖、事件分发 无 开闭原则
wifi 网络连接、MQTT通信、数据上报 sensor(读取数据) 里氏替换
ota 固件下载、校验、分区切换 wifi(获取数据流) 依赖倒置

每个模块对外暴露的接口,都通过头文件中的函数声明来约定。模块之间只通过接口通信,不直接访问对方的结构体内部。

我的习惯:每个模块的 .h 文件里只放公开接口,内部结构体和静态函数都藏在 .c 文件里。这样别人看头文件就知道怎么用,不用关心实现细节。

下面这张图展示了模块之间的依赖关系,注意看,都是单向的:

智能温控器模块划分与依赖关系 sensor heater display keypad wifi ota 硬件抽象层(HAL):I2C / SPI / GPIO / UART 说明: 实线箭头表示直接依赖,虚线表示通过HAL间接依赖。 所有模块只依赖下层接口,不依赖平级模块的内部实现。

这个划分方式,我用了好几年,在多个项目里验证过。核心思路就是:每个模块只做一件事,只和直接邻居说话,接口稳定后尽量不改。

注意:模块划分不是越细越好。我曾经把一个温控器拆成20多个模块,结果接口文件比实现文件还多,维护成本反而上升了。一般5~8个模块是比较合理的范围。

最后总结一下这三个原则的落地要点:

  • 开闭原则:加功能时先想能不能不改旧代码。用函数指针表、回调、抽象接口来实现。
  • 里氏替换:子类或替代实现必须遵守父类的约定。返回值、异常、行为语义都要一致。
  • 迪米特法则:只和直接朋友说话。别绕三层去拿数据,加一层封装不丢人。

这些原则不是教条,是前人踩坑踩出来的经验。你想想看,一个项目迭代三年,代码量从1万行涨到10万行,如果没有这些原则约束,那维护成本会指数级上升。嗯,我见过太多这样的项目了。