接口分离原则(Interface Segregation Principle)

时间:2022-02-11 08:41:39

接口分离原则(Interface Segregation Principle)用于处理胖接口(fat interface)所带来的问题。如果类的接口定义暴露了过多的行为,则说明这个类的接口定义内聚程度不够好。换句话说,类的接口可以被分解为多组功能函数的组合,每一组都服务于不同的客户类,而不同的客户类可以选择使用不同的功能分组。

ISP 原则承认了对象设计中非内聚接口的存在。但它建议客户类不应该只通过一个单独的类来使用这些接口。取而代之的是,客户类应该通过不同的抽象基类来使用那些内聚的接口。在不同的编程语言中,这里所指的抽象基类可以指 "接口(interface)"、"协议(protocol)"、"签名(signature)" 等。

本篇文章我们将探讨 "fat" 胖接口或称为 "polluted" 被污染的接口在类设计中所带来的问题。将涉及描述这些接口是如何被创建的,如何改进类的设计以便隐藏它们。最后,我们将通过一个案例来学习 "fat" 接口的产生过程,以及我们如何通过使用 ISP 原则来校正它。

接口污染(Interface Pollution)

假设我们在设计一个安保系统。在这个系统中,Door 对象包含锁定(Lock)和解锁(Unlock)行为,同时也了解门的开关状态(IsDoorOpen)。

   class Door
{
public:
virtual void Lock() = ;
virtual void Unlock() = ;
virtual bool IsDoorOpen() = ;
};

类 Door 被设计为抽象类,这样客户类可以通过实现它的接口(virtual)来使用,而无需依赖于特定的 Door 实现。

现在假设有一种实现称为 TimedDoor。TimedDoor 需要在门开了一段时间之后发出一个声音报警(Sound Alarm)。为了实现这个功能,TimedDoor 需要与另一个叫做 Timer 的对象进行通信。

   class Timer
{
public:
void Regsiter(int timeout, TimerClient* client);
};
class TimerClient
{
public:
virtual void TimeOut() = ;
};

当 Timer 对象检测计时时间已经超时时,它就调用 Register 函数。Register 函数的参数包括超时的时长和一个 TimerClient 对象的指针。TimerClient 类拥有一个 TimeOut 方法,用以当超时发生时被调用。

那么如何让 TimerClient 与 TimedDoor 进行通信,以便当超时发生时 TimedDoor 能够被通知到呢?理论上讲有很多种选择。图 1 中展示了一个常见的方案。

接口分离原则(Interface Segregation Principle)

图 1

我们强制让 Door 继承自 TimerClient,然后 TimedDoor 自然也继承自 TimerClient。这让 TimerClient 可以将自己注册到 Timer 对象中以提供 TimeOut 接口。

这个方案很好理解,但是有很多问题。其中最主要的问题是,Door 类直接依赖了 TimerClient。然而,并不是所有的 Door 的衍生类都需要考虑时间和超时问题。而实际上,在我们这么设计之前, Door 类根本不关心任何有关时间的问题。然后,那些不需要使用时间功能的 Door 的衍生类也将不得不为 TimeOut 提供一个实现,即使是空实现。此外,使用这些衍生类的客户类也将不得不引用 TimerClient 的定义,即使它们并没有使用这些功能。

图 1 展示了在面向对象设计中的一个常见的问题,尤其是在类似于 C++ 这样的静态类型语言中。确切的说,这个问题属于接口污染(interface pollution)问题,Door 的接口已经被一个并不需要的接口所污染。而在实际使用中,新增的接口仅是 Door 的一个子类所需要的功能。如果这样演进下去,每当一个衍生类需要使用一个新的接口时,我们就将这个接口添加到它的 Door 基类中,持续的污染使得基类的接口变得越来越臃肿。

而且,每当将一个新的接口添加到基类定义中时,基类的所有其他衍生类也相应实现了该接口。一般来说,新增的接口在基类中已经有了默认实现或空实现,而子类如果不需要使用该功能可以不提供实现。我们知道,这样的设计已经违背了里氏替换原则(Liskov Substitution Principle),降低了代码的可维护性和可复用性。

隔离客户类意味着隔离接口

Door 和 TimerClient 所表示的接口其实是被不同的客户类所使用。Timer 使用 TimerClient,而负责控制门的那些类会使用 Door 。因此它们服务的对象是不同的,所以接口也应当保持隔离。为什么这么说呢?在下面的描述中我们会知道,客户类所以依赖的接口之间是可以相互影响的。

当我们面对新的需求引起软件的变化时,我们通常会考虑这些对接口的更改是否将影响到它们的客户类。例如,我们可能会关心对 TimerClient 的更改是否会影响到它所有的客户类,因为有时就是它的客户类强制让接口发生变化的。

例如,Timer 的客户类可能会调用多次 Register 函数。假设 TimedDoor 侦测到了门已经被打开,则它会调用 Timer 的 Register 方法来注册 TimeOut 回调。如果在超时之前门关上了,关了一会后又重新打开了。这将导致我们又 Register 了一个新的 TimeOut 回调,而之前的那个还没有被触发。然后当超时发生时, TimedDoor 的 TimeOut 函数被调用,使得 Door 发出了一个错误的报警消息。

我们可以通过下述代码中的约定机制来处理上面描述的情况。为 Register 添加一个 timeOutId 参数作为识别 TimerClient 的标识。当回调 TimeOut 时携带这个注册时设置的 timeOutId,使得 TimerClient 的子类知道具体调用的是谁的 TimeOut 函数。

   class Timer
{
public:
void Regsiter(int timeout,
int timeOutId,
TimerClient* client);
};
class TimerClient
{
public:
virtual void TimeOut(int timeOutId) = ;
};

显然,这个改动会影响所有 TimerClient 的用户,而实际上这个问题是一个设计疏漏。然而,如果保持图 1 中的设计,就是为了添加一个 timeOutId,将有可能导致 Door 和它所有的客户类都会被影响到(至少要重新编译吧!)。那为什么为了解决 TimerClient 中的一个 Bug,而将影响到那些不需要使用时间超时机制的 Door 的衍生类呢?当对程序的一个改动触发了完全不相关的模块的改动时,这种代价和变化所带来的后果就变得无法预测,而且这种变化显然也会引入更大的风险。

接口分离原则(The Interface Segregation Principle)

Clients should not be forced to depend upon interfaces that they do not use.

接口分离原则描述为 "客户类不应被强迫依赖那些它们不需要的接口"。

接口分离原则(Interface Segregation Principle)

当客户类被强迫依赖那些它们不需要的接口时,则这些客户类不得不受制于这些接口。这无意间就导致了所有客户类之间的耦合。换句话说,如果一个客户类依赖了一个类,这个类包含了客户类不需要的接口,但这些接口是其他客户类所需要的,那么当其他客户类要求修改这个类时,这个修改也将影响这个客户类。通常我们都是在尽可能的避免这种耦合,所以我们需要竭尽全力地分离这些接口。

类接口和对象接口

我们再来看下 TimedDoor 类。这个类的对象包含两个独立的接口,但同时被两个不同的客户类所使用,也就是 Timer 和使用 Door 的用户。这两个接口必须在同一个对象上实现,因为这两个接口的实现需要处理同样的数据。那么我们该如何使其符合 ISP 原则呢?如何将那些必须保持在一起的接口进行分离呢?

解决这个问题的基本方式就是让对象的客户类不通过对象的接口来访问,而是通过委托(delegation)或者基类对象来访问。

通过委托进行分离(Separation through Delegation)

我们可以采用 Adapter 设计模式来解决 TimedDoor 的问题。具体方式是通过从 TimerClient 衍生一个 Adapter 对象,其将操作转递至 TimedDoor 对象。如图 2 所示。

接口分离原则(Interface Segregation Principle)

图 2

当 TimedDoor 想要 Register 一个 TimeOut 回调至 Timer 时,先创建一个 DoorTimerAdapter,然后将其Register 给 Timer。当 Timer 调用 DoorTimerAdapter 的 TimeOut 方法时,DoorTimerAdapter 将这个调用转递给 TimedDoor。

这个方案满足了 ISP 原则,并且避免了 Door 的使用者与 Timer 之间的耦合。而且当 Timer 被修改时,Door 的用户将不再会被影响到。此外,TimedDoor 也无须一定非要与 TimerClient 保持相同的接口。DoorTimerAdapter 能够将 TimerClient 接口翻译成 TimedDoor 接口。因此,这是一个非常通用的解决方案。

   class TimedDoor : public Door
{
public:
virtual void DoorTimeOut(int timeOutId);
};
class DoorTimerAdapter : public TimerClient
{
public:
DoorTimerAdapter(TimedDoor& theDoor)
: itsTimedDoor(theDoor)
{}
virtual void TimeOut(int timeOutId)
{itsTimedDoor.DoorTimeOut(timeOutId);}
private:
TimedDoor& itsTimedDoor;
};

尽管如此,上述方案还是不够优雅。它要求每当我们想注册一个 TimeOut 时都需要创建一个新的对象。创建对象显然多了些开销。

通过多继承进行分离(Separation through Multiple Inheritance)

图 3 中展示了多继承的使用方式。在这种模型下,TimedDoor 同时继承了 Door 和 TimerClient。尽管两个基类的客户类都能够使用 TimedDoor,但它们都没有直接依赖 TimedDoor 类,而是通过独立的接口来使用相同的对象的。

接口分离原则(Interface Segregation Principle)

图 3

   class TimedDoor : public Door, public TimerClient
{
public:
virtual void TimeOut(int timeOutId);
};

我个人是比较推荐这个方案的。多继承没有想象的那么恐怖。事实上,我发现它在这种条件下还特别有用。而且,对于上面图 2 中的方案,我只在当 DoorTimerAdapter 中所执行的转换是必要的情况下才会推荐使用。

ATM 用户接口案例

现在我们来看一个较有实际意义的案例,古老的 ATM(Automated Teller Machine)问题。ATM 机的用户接口需要非常灵活的设计,因其界面输出可能需要被翻译成多种不同的语言和呈现方式。比如,它有可能使用显示屏进行展现,也有可能使用盲人点字面板,或者通过语音合成技术进行输出。为了支持这些功能,我们可以通过创建一个抽象基类,包含所有支持的功能接口,并且接口函数设置的 virtual 以供子类扩展。

接口分离原则(Interface Segregation Principle)

图 4

ATM 还支持不同的交易(transaction)类型,每种交易类型都衍生自基类 Transaction。这样就可以得到 DepositTransaction、WithdrawTransaction 和 TransferTransaction 等。每种交易对象都会调用 UI 模块进行操作。例如,DepositeTransaction 对象调用 UI 的 RequestDepositAmount 成员函数,而 TransferTransaction 对象则调用 UI 的 RequestTransferAmount 成员函数。如图 5 所示。

接口分离原则(Interface Segregation Principle)

图 5

注意到,这种情形恰好是 ISP 原则告诉我们一定要避免的。每种 Transaction 类型都使用了 UI 的一部分功能。这使得对 Transaction 的某一个衍生类的更改将导致相应的 UI 跟着更改。

这种耦合可以通过分离 UI 的接口来避免。我们可以将 UI 的接口分成多个独立的抽象基类接口,例如 DepositUI、WithdrawUI 和 TransferUI 等。然后 UI 类则通过多继承来实现这些抽象基类。如图 6 所示。

接口分离原则(Interface Segregation Principle)

图 6

诚然,无论何时创建 Transaction 的一个新的衍生类,都需要相应创建一个抽象的 UI 基类。从而使 UI 及其所有衍生类也必须跟着修改。尽管如此,这些类其实并没有广泛的分布在应用程序中。实际上,它们可能仅在 main 函数或者系统的启动 bootstrap 中才会被实例化。所以,添加新的 UI 基类是可控的。

总结

本篇文章中,我们探讨了关于胖接口 "fat interface" 所带来的问题,也就是接口不是为特定的客户类服务,而服务了多个不同的客户类。胖接口使本应该被隔离的客户类之间产生了耦合。通过应用 Adapter 设计模式,采用委托(delegation)或多继承方式,胖接口可以被分离成多个抽象的基类接口,从而打破客户类之间的不必要的耦合。

面向对象设计的原则

 SRP

单一职责原则

Single Responsibility Principle

 OCP

开放封闭原则

Open Closed Principle

 LSP

里氏替换原则

Liskov Substitution Principle

ISP

接口分离原则

Interface Segregation Principle

 DIP

依赖倒置原则

Dependency Inversion Principle

 LKP

最少知识原则

Least Knowledge Principle

参考资料

本文《接口分离原则(Interface Segregation Principle)》由 Dennis Gao 翻译改编自 Robert Martin 的文章《ISP: The Interface Segregation Principle》,未经作者本人同意禁止任何形式的转载,任何自动或人为的爬虫行为均为耍流氓。