接口隔离原则(ISP)下:实际案例——打印机系统的接口拆分,ISP与单一职责的区别
上一讲我们聊了ISP的理论基础。说白了就是一句话:不要强迫客户端依赖它不需要的接口。但理论归理论,落到代码里到底怎么拆?拆到什么程度算合适?今天我就拿一个打印机系统来当靶子,手把手带你走一遍。
一个糟糕的打印机接口
先看个反面教材。我在刚入行那会儿,接手过一个打印机驱动模块。当时的接口长这样:
class IMultiFunctionPrinter {
public:
virtual void print(const Document& doc) = 0;
virtual void scan(const Document& doc) = 0;
virtual void fax(const Document& doc) = 0;
virtual void staple(const Document& doc) = 0;
virtual void copy(const Document& doc) = 0;
};
看着挺全的是吧?打印、扫描、传真、装订、复印,一应俱全。但问题来了——不是所有打印机都支持这些功能。
比如我们公司采购了一批廉价打印机,只能打印。那实现类怎么写?
class SimplePrinter : public IMultiFunctionPrinter {
public:
void print(const Document& doc) override {
// 正常打印逻辑
}
void scan(const Document& doc) override {
throw std::runtime_error("不支持扫描");
}
void fax(const Document& doc) override {
throw std::runtime_error("不支持传真");
}
void staple(const Document& doc) override {
throw std::runtime_error("不支持装订");
}
void copy(const Document& doc) override {
throw std::runtime_error("不支持复印");
}
};
嗯,你看到了。四个方法直接抛异常。客户端调用时还得小心翼翼,先判断支不支持。这其实就是典型的接口臃肿——把一堆不相关的职责塞进同一个接口里。
按职责拆分接口
怎么拆?我个人的习惯是:先问客户端需要什么。打印机系统的客户端无非就几类:
- 普通用户:只需要打印
- 办公人员:需要打印+扫描+复印
- 行政人员:需要传真+装订
那我们就按这些职责拆成小接口:
class IPrinter {
public:
virtual void print(const Document& doc) = 0;
virtual ~IPrinter() = default;
};
class IScanner {
public:
virtual void scan(const Document& doc) = 0;
virtual ~IScanner() = default;
};
class IFax {
public:
virtual void fax(const Document& doc) = 0;
virtual ~IFax() = default;
};
class IStapler {
public:
virtual void staple(const Document& doc) = 0;
virtual ~IStapler() = default;
};
class ICopier {
public:
virtual void copy(const Document& doc) = 0;
virtual ~ICopier() = default;
};
这样一来,廉价打印机只需要实现IPrinter:
class SimplePrinter : public IPrinter {
public:
void print(const Document& doc) override {
// 正常打印
}
};
高端一体机可以同时实现多个接口:
class HighEndPrinter : public IPrinter, public IScanner, public ICopier {
public:
void print(const Document& doc) override { /* ... */ }
void scan(const Document& doc) override { /* ... */ }
void copy(const Document& doc) override { /* ... */ }
};
你看,客户端现在只依赖自己需要的接口。用打印功能的,只跟IPrinter打交道。用扫描的,只跟IScanner打交道。接口变小了,耦合也变低了。
ISP与单一职责的区别
很多初学者会问:ISP和单一职责原则(SRP)是不是一回事?
嗯,这个问题我当年也困惑过。其实它们关注的角度不同:
| 对比维度 | 单一职责(SRP) | 接口隔离(ISP) |
|---|---|---|
| 关注点 | 类的内部职责 | 接口的外部依赖 |
| 核心问题 | 一个类应该只有一个引起变化的原因 | 客户端不应该依赖它不需要的接口 |
| 作用对象 | 类(实现方) | 接口(调用方) |
| 典型场景 | 一个类既做业务逻辑又做数据持久化 | 一个接口包含打印、扫描、传真等不相关功能 |
| 拆分粒度 | 按职责拆分类的内部 | 按客户端需求拆分接口 |
说白了,SRP管的是类的内聚性,ISP管的是接口的隔离性。一个类可以只做一件事(符合SRP),但如果它的接口暴露了太多方法给不同客户端,那还是违反了ISP。
举个例子:一个DocumentManager类,内部只负责文档的增删改查(符合SRP)。但如果它的接口长这样:
class IDocumentManager {
public:
virtual void addDocument(const Document& doc) = 0;
virtual void removeDocument(int id) = 0;
virtual void printDocument(int id) = 0;
virtual void emailDocument(int id, const std::string& to) = 0;
};
虽然类内部职责单一,但接口却包含了打印和邮件功能。客户端如果只需要添加文档,却被迫依赖了printDocument和emailDocument——这就是ISP的问题。
ISP的落地技巧
在实际项目中,我总结了几条ISP的落地技巧:
- 接口尽量小,但别太小。 每个接口只包含一组高度相关的操作。比如打印相关的放一起,扫描相关的放一起。
- 用组合代替继承。 高端打印机通过多重继承组合多个小接口,而不是在一个大接口里塞满功能。
- 客户端按需依赖。 每个客户端只依赖它需要的接口,不要依赖整个大接口。
- 接口变化时,先问客户端。 如果接口需要新增方法,先看看会不会影响到现有客户端。会的话,考虑新增一个接口。
我曾经在一个项目中,因为没遵守ISP,导致每次新增打印机型号都要改一堆客户端代码。后来花了三天时间重构,把所有大接口拆成小接口。重构完,代码清爽多了,新增型号只需要实现对应的接口就行。
IPrinter又拆成IPrintBlack和IPrintColor。结果客户端要同时依赖好几个接口,反而更复杂。记住:接口拆分的粒度,以客户端的使用便利为准。
知识体系图
下面这张图总结了ISP在打印机系统中的核心逻辑:
从这张图可以看得很清楚:左边是违反ISP的“胖接口”,客户端被迫依赖不需要的方法;右边是符合ISP的“瘦接口”,客户端按需依赖,互不干扰。
好了,打印机系统的案例就讲到这里。ISP的核心其实就一句话:接口是给客户端用的,不是给实现类用的。设计接口时,多站在客户端角度想想,自然就能拆得合理。