2. 接口隔离原则(ISP)深度解析:定义、历史背景、与单一职责原则的区别
接口隔离原则,英文叫 Interface Segregation Principle,简称 ISP。它是 Robert C. Martin(我们圈内都叫他 Uncle Bob)在 2002 年提出的。
说白了就一句话:客户端不应该被迫依赖它用不到的接口。
你想想看,一个接口里塞了十几个方法,调用方其实只需要其中两个。剩下的那些怎么办?要么空实现,要么抛异常。这种代码我见过太多了。
2.1 历史背景:为什么会有 ISP?
90 年代末,面向对象设计开始流行。那时候大家喜欢把功能往一个接口里堆,觉得这样“高内聚”。
结果呢?
- 一个接口十几个方法,实现类里一堆空方法
- 改一个方法签名,所有实现类都要重新编译
- 调用方根本搞不清哪些方法能用
我记得有个项目,一个 IDevice 接口定义了 20 个方法。后来加了个简单的温度传感器,也得实现所有方法。代码里全是 return 0 和 return NULL。嗯,那场面,真叫一个壮观。
Uncle Bob 在给一个打印机系统做咨询时发现了这个问题。打印机接口里既有打印方法,又有扫描、传真、复印。但有些客户端只需要打印功能。于是 ISP 就诞生了。
核心洞察:接口的粒度应该由客户端决定,而不是由实现者决定。
2.2 ISP 与 SRP 的区别
很多初学者会把接口隔离原则和单一职责原则搞混。我刚开始也犯过这个错。
它们确实有联系,但关注点完全不同:
| 对比维度 | 单一职责原则(SRP) | 接口隔离原则(ISP) |
|---|---|---|
| 关注点 | 类的职责范围 | 接口的粒度 |
| 作用对象 | 类、模块 | 接口、抽象类 |
| 核心问题 | 一个类该不该做这么多事? | 一个接口该不该暴露这么多方法? |
| 违反后果 | 修改一个职责影响其他职责 | 客户端依赖不需要的方法 |
| 解决方向 | 拆分职责到不同类 | 拆分接口到更小粒度 |
举个例子你就明白了。
SRP 说的是:一个温度传感器类,不应该既管测温又管网络通信。这是职责拆分。
ISP 说的是:一个 ITemperatureSensor 接口,不应该包含 sendData() 和 reboot() 方法。这是接口拆分。
我的经验:先保证 SRP,再考虑 ISP。职责没理清之前,谈接口隔离是空中楼阁。
2.3 违反 ISP 的典型场景
我在嵌入式项目里见过最多的违反 ISP 的情况,就是“万能设备接口”。
// 违反 ISP:一个接口塞了所有功能
typedef struct {
int (*init)(void);
int (*read)(uint8_t *buf, uint32_t len);
int (*write)(uint8_t *buf, uint32_t len);
int (*ioctl)(uint32_t cmd, void *arg);
int (*reset)(void);
int (*self_test)(void);
int (*enter_sleep)(void);
int (*wakeup)(void);
int (*get_status)(uint8_t *status);
int (*set_config)(Config_t *cfg);
} DeviceInterface_t;
这个接口有 10 个方法。但一个简单的 LED 灯驱动,只需要 init 和 write。剩下的 8 个方法都得空实现。
我曾经在一个项目里看到,有人为了“通用性”,把所有外设都抽象成这个接口。结果呢?
- LED 驱动里 self_test 返回 0,reset 空转,enter_sleep 什么都不做
- 调用方根本不知道哪些方法能用,只能看注释
- 加一个新外设,得把 10 个方法都过一遍
避坑指南:我曾经在一个智能家居项目里,把温湿度传感器、PM2.5 传感器、光照传感器都塞进同一个 ISensor 接口。结果 PM2.5 传感器不支持 setThreshold(),光照传感器不支持 getHistory()。最后不得不加了一堆返回值判断。那代码,我自己都不想看。
2.4 如何正确应用 ISP
正确的做法是:按客户端需求拆分接口。
还是上面那个例子,拆成几个小接口:
// 遵循 ISP:按功能拆分成小接口
typedef struct {
int (*init)(void);
int (*read)(uint8_t *buf, uint32_t len);
int (*write)(uint8_t *buf, uint32_t len);
} IReadWrite_t;
typedef struct {
int (*reset)(void);
int (*self_test)(void);
} IMaintainable_t;
typedef struct {
int (*enter_sleep)(void);
int (*wakeup)(void);
} IPowerManage_t;
typedef struct {
int (*get_status)(uint8_t *status);
int (*set_config)(Config_t *cfg);
} IConfigurable_t;
这样,LED 驱动只需要实现 IReadWrite_t。电源管理模块只需要实现 IPowerManage_t。各取所需,互不干扰。
关键原则:接口的客户端有多少种不同的使用方式,就应该有多少个接口。
2.5 ISP 在嵌入式中的特殊考量
嵌入式开发和纯软件不一样。我们得考虑资源限制。
接口拆得太细,会带来两个问题:
- 函数指针表膨胀:每个接口都是一个结构体,里面全是函数指针。拆得越细,占的 RAM 越多
- 调用链变长:接口多了,调用关系复杂,可读性下降
我个人习惯的做法是:
- 先按功能域划分,每个域一个接口
- 接口方法控制在 3-5 个
- 如果某个接口超过 7 个方法,就考虑拆分
- 对于资源极度受限的 MCU,可以适当合并,但要做好文档说明
实用技巧:在 Cortex-M4 以上的平台,我一般用函数指针表实现接口。在 Cortex-M0 这种资源紧张的平台,我会用条件编译来控制接口的暴露范围。说白了,就是根据硬件资源做 trade-off。
2.6 知识结构图
下面这张图展示了 ISP 的核心逻辑和与 SRP 的关系:
2.7 本章小结
接口隔离原则不是什么高深的理论。它就是一个很朴素的道理:别把不相关的东西绑在一起。
记住几个要点:
- 接口是给客户端用的,不是给实现者用的
- 一个接口 3-5 个方法比较合适,超过 7 个就要警惕
- SRP 管的是类,ISP 管的是接口,别搞混
- 嵌入式环境要做资源权衡,但原则不能丢