接口隔离原则(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("不支持复印");
    }
};

嗯,你看到了。四个方法直接抛异常。客户端调用时还得小心翼翼,先判断支不支持。这其实就是典型的接口臃肿——把一堆不相关的职责塞进同一个接口里。

⚠️ 注意: 接口里出现大量“不支持”或“空实现”,就是ISP被违反的典型信号。别等到代码跑起来才发现,设计阶段就该警惕。

按职责拆分接口

怎么拆?我个人的习惯是:先问客户端需要什么。打印机系统的客户端无非就几类:

  • 普通用户:只需要打印
  • 办公人员:需要打印+扫描+复印
  • 行政人员:需要传真+装订

那我们就按这些职责拆成小接口:

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;
};

虽然类内部职责单一,但接口却包含了打印和邮件功能。客户端如果只需要添加文档,却被迫依赖了printDocumentemailDocument——这就是ISP的问题。

💡 我的经验: 设计接口时,我习惯先列出所有可能的客户端,然后问自己:“这个客户端真的需要这个方法吗?”如果答案是否定的,那就拆出去。别怕接口多,怕的是接口大。

ISP的落地技巧

在实际项目中,我总结了几条ISP的落地技巧:

  1. 接口尽量小,但别太小。 每个接口只包含一组高度相关的操作。比如打印相关的放一起,扫描相关的放一起。
  2. 用组合代替继承。 高端打印机通过多重继承组合多个小接口,而不是在一个大接口里塞满功能。
  3. 客户端按需依赖。 每个客户端只依赖它需要的接口,不要依赖整个大接口。
  4. 接口变化时,先问客户端。 如果接口需要新增方法,先看看会不会影响到现有客户端。会的话,考虑新增一个接口。

我曾经在一个项目中,因为没遵守ISP,导致每次新增打印机型号都要改一堆客户端代码。后来花了三天时间重构,把所有大接口拆成小接口。重构完,代码清爽多了,新增型号只需要实现对应的接口就行。

⚠️ 避坑指南: 我曾经犯过一个错误——把接口拆得太碎。比如把IPrinter又拆成IPrintBlackIPrintColor。结果客户端要同时依赖好几个接口,反而更复杂。记住:接口拆分的粒度,以客户端的使用便利为准

知识体系图

下面这张图总结了ISP在打印机系统中的核心逻辑:

打印机系统接口拆分 — ISP核心逻辑 ❌ 违反ISP:大接口 IMultiFunctionPrinter ├─ print() ├─ scan() ├─ fax() └─ staple() 客户端被迫依赖 客户端(只需要打印) 却依赖了 scan/fax/staple ✅ 符合ISP:小接口 IPrinter └─ print() IScanner └─ scan() IFax / IStapler ... 客户端按需依赖 客户端(只需要打印) 只依赖 IPrinter ISP核心:按客户端需求拆分接口 每个接口只服务一类客户端,避免“胖接口”

从这张图可以看得很清楚:左边是违反ISP的“胖接口”,客户端被迫依赖不需要的方法;右边是符合ISP的“瘦接口”,客户端按需依赖,互不干扰。

好了,打印机系统的案例就讲到这里。ISP的核心其实就一句话:接口是给客户端用的,不是给实现类用的。设计接口时,多站在客户端角度想想,自然就能拆得合理。

专注资料整理