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 在嵌入式中的特殊考量

嵌入式开发和纯软件不一样。我们得考虑资源限制。

接口拆得太细,会带来两个问题:

  1. 函数指针表膨胀:每个接口都是一个结构体,里面全是函数指针。拆得越细,占的 RAM 越多
  2. 调用链变长:接口多了,调用关系复杂,可读性下降

我个人习惯的做法是:

  • 先按功能域划分,每个域一个接口
  • 接口方法控制在 3-5 个
  • 如果某个接口超过 7 个方法,就考虑拆分
  • 对于资源极度受限的 MCU,可以适当合并,但要做好文档说明

实用技巧:在 Cortex-M4 以上的平台,我一般用函数指针表实现接口。在 Cortex-M0 这种资源紧张的平台,我会用条件编译来控制接口的暴露范围。说白了,就是根据硬件资源做 trade-off。

2.6 知识结构图

下面这张图展示了 ISP 的核心逻辑和与 SRP 的关系:

接口隔离原则(ISP)知识结构 ISP 核心定义 历史背景 与 SRP 的区别 应用方法 90年代接口膨胀问题 Uncle Bob 打印机案例 SRP:类的职责范围 ISP:接口的粒度 按客户端需求拆分 嵌入式资源权衡 核心:客户端不应该依赖它用不到的接口 接口粒度由客户端决定,不是由实现者决定

2.7 本章小结

接口隔离原则不是什么高深的理论。它就是一个很朴素的道理:别把不相关的东西绑在一起。

记住几个要点:

  • 接口是给客户端用的,不是给实现者用的
  • 一个接口 3-5 个方法比较合适,超过 7 个就要警惕
  • SRP 管的是类,ISP 管的是接口,别搞混
  • 嵌入式环境要做资源权衡,但原则不能丢