读源码学架构系列:接口隔离原则

接口隔离原则(ISP): 不应该强迫客户依赖于它们不用的方法。

这个原则用来拆分庞大臃肿的接口成为更小的和更具体的接口,这样使用者将会只需要知道他们感兴趣的方法。这种缩小的接口也被称为角色接口ISP承认存在有一些对象,它们确实不需要内聚的接口,但是ISP建议不应该把它们作为一个单一的类存在,相反,使用者看到的应该是多个具有内聚接口的抽象基类。

接口隔离原则的目的是让系统解耦,从而更容易重构、更改和重新部署。

考虑一个安全系统,在这个系统中,有一些Door对象,可以被加锁和解锁,并且Door对象知道自己是开着还是关着。现在,考虑一个这样的实现,TimedDoor,如果门开着的时间过长,它就会发出警报声。

public interface Door {

    void lock();

    void unlock();

    boolean isDoorOpen();

}

如果一个对象希望得到超时通知,它可以调用Timerregister函数。该函数有两个参数,一个是超时时间,另一个是指向TimerClient对象的引用,该对象的timeout函数会在超时到达时被调用。

public class Timer {

    public void register(int timeout, TimerClient client) {

    }

}
public interface TimerClient {

    void timeout();

}

如何将TimerClient类和TimedDoor类联系起来,才能在超时时通知到TimedDoor中相应的处理代码呢?下面是一个容易想到的解决方案:

其中,Door继承了TimerClient,因此TimedDoor也就继承了TimerClient。这就保证了TimerClient可以把自己注册到Timer中,并且可以接收timeout消息。

这个方案也不是没有问题,主要的问题是,现在Door类依赖于TimerClient了。可并不是所有种类的Door都需要定时功能。实际上,最初的Door抽象类和定时功能没有任何关系。如果创建了无需定时功能的Door的派生类,那么在这些派生类中就必须要提供timeout方法的退化实现(这就有可能违反了LSP)。此外,使用这些派生类的应用程序即使不使用TimerClient类的定义,也必须要引入它,这样就有了不必要的复杂性和不必要的耦合。

上面的设计方案是一个典型的接口污染的例子,Door的接口被一个它不需要的方法污染了。在Door的接口中加入这个方法只是为了能给它的一个子类带来好处。如果每次子类需要一个新方法时,这个方法就加到基类中去,这会进一步污染基类的接口。

而且,每次基类中加入一个方法时,派生类中就必须要实现这个方法(或者定义一个缺省实现)。事实上,有一种特定的实践让我们可以使派生类无需实现这些方法,该实践的做法是把这些接口合并为一个基类,并在这个基类中提供接口中方法的退化方法。但是,这种实践违反了LSP,带来了维护和重用方面的问题。

Door接口和TimerClient接口是被完全不同的客户程序使用的。Timer使用TimerClient,而操作门的类使用Door。既然客户程序是分离的,所以接口也应该保持分离

为什么呢?

我们在考虑软件中引起变化的原因时,通常考虑的都是接口的变化会怎样影响它们的使用者。例如,如果TimerClient的接口改变了,我们会去关心TimerClient的所有使用者要做什么样的改变。

然而,实际上也存在着反向的影响,有时,迫使接口改变的,正是它们的使用者。例如,有些Timer的使用者会注册多个超时通知请求。比如对于TimerDoor来说,当它检测到门被打开时,会向Timer发送一个register消息,请求一个超时通知。可是,在超时到达前,门关上了,关闭一会儿后又被再次打开。这就导致在原先的超时到达前又注册了一个新的超时请求。最后,最初的超时到达,TimedDoortimeout方法被调用,Door错误地发出了警报。

我们可以通过在注册时增加一个timeOutID的唯一标识来改正上面情形中的错误。

public class Timer {

    public void register(int timeout, int timeOutID, TimerClient client) {

    }

}

public interface TimerClient {

    void timeout(int timeOutID);

}

显然,这个改变会影响到TimerClient的所有使用者。对于上图中的设计,这个改变还会影响到Door以及Door的所有使用者,这不是我们所期望的。如果程序中一部分的更改会影响到程序中完全和它无关的其他部分,那么更改的代价和影响就变得不可预测,并且更改所附带的风险也会急剧增加

如果强迫客户程序依赖于那些它们不使用的方法,那么这些客户程序就面临着由于这些未使用方法的改变所带来的变更。因此,我们希望分离接口来尽可能的避免这种耦合。

再考虑TimedDoor的问题,这里有两个独立的接口,有两个独立的客户--Timer以及Door所使用的对象。因为实现这两个接口需要操作同样的数据,所以这两个接口必须在同一个对象中实现。那么怎样才能遵循ISP呢?如何才能分离必须在一起实现的接口呢?

该问题的答案是:一个对象的客户(使用者)不是必须通过该对象的接口去访问它,也可以通过委托或者通过该对象的基类去访问它

一个解决方案就是创建一个派生自TimerClient的对象,并把对该对象的请求委托给TimedDoor。如下图所示:

TimedDoor想要向Timer对象注册一个超时请求时,它就创建一个DoorTimerAdapter并且把它注册给Timer。当Timer对象发送timeout消息给DoorTimerAdapter时,DoorTimerAdapter把这个消息委托给TimedDoor

这个方案遵循ISP原则,并且避免了Door的客户程序和Timer之间的耦合。同时,Timer的变化不会影响到任何Door的使用者。此外,TimedDoor也不必具有和TimerClient一样的接口。DoorTimerAdapter会将TimerClient接口转换成TimedDoor接口。

这个解决方案还是有些不够优雅,每次想去注册一个超时请求时,都要去创建一个新的对象。此外,委托处理会导致一些很小但仍然存在的运行时间和内存的开销,在某些应用领域,内存和运行时间都非常宝贵,以至于这种开销成了一个值得关注的问题。

还有一种解决方案是通过分离接口来实现。如下图所示:

在这个模型中,TimedDoor同时继承了DoorTimerClient。尽管这两个基类的使用者都可以使用TimedDoor,但是实际上却都不再依赖于TimedDoor类。这样,它们就通过分离的接口使用同一个对象。

通常我们会优先选择这个解决方案。

再看一个ATM用户界面的例子:ATM需要灵活的用户界面,它需要根据不同的操作显示不同的界面,比如输出信息需要能显示为多种语言等。显然,通过创建一个抽象基类把一些公共的逻辑封装起来,然后把每个ATM能执行的不同操作封装为类Transaction的派生类,这样我们可以得到类DepositTransactionWithdrawalTransaction以及TransferTransaction。每个类都调用UI的方法,设计如下图示:

现在我们用OOD的设计原则来检验上面的设计,显然这个设计不符合LSP原则。每个操作所使用的UI的方法,其他的操作类都不会使用,因此,对于任何一个Transaction的派生类的改动都会迫使对UI的相应改动,同时也影响到了其他所有Transaction的派生类及其他所有依赖于UI接口的类,引入了依赖的传递性和不必要的耦合。

例如,如果要增加一种操作PayGasBillTransaction,为了处理该操作想要显示的特定消息,就必须要在UI中加入新的方法。但由于DepositTransactionWithdrawalTransaction以及TransferTransaction全都依赖于UI接口,所以它们都需要重新编译。更糟糕的是,如果这些操作是作为组件供其他组件使用的话,那么这些组件必须全部重新编译部署,但事实上它们的逻辑没有做过任何的改动。

通过将UI接口分解成DepositUiWithdrawUI以及TransferUI这样的单独的接口,可以避免这种不合适的耦合,最终的UI接口可以去实现这些单独的接口:

每次创建一个Transaction类的新派生类时,抽象接口UI就需要增加一个相应的基类,并且UI接口以及所有它的派生类都必须改变。不过,这些类并没有被很多的组件使用,事实上,它们可能仅被main程序或那些启动系统并创建具体UI实例所使用,因此,增加新的UI基类所带来的影响是可控的。

臃肿的类(胖类)会导致它们的使用者之间产生不必要的耦合关系,当一个使用者要求胖类进行一个改动时,会影响到所有其他的使用者。因此,使用者应该仅仅依赖于它们实际调用的方法。通过把胖类的接口分解为多个特定于使用者的接口,可以实现这个目标。每个特定于使用者的接口仅仅声明它的特定客户或者客户组调用的那些函数。接着,该胖类就可以继承所有特定于使用者的接口,并实现它们,这样就解除了使用者和它们没有调用的方法之间的依赖关系,并使使用者之间互不依赖。

OOD的每个原则在应用时都必须小心,不能过度使用它们。尤其是在一些复杂的场景中,需要结合实际的需求做出一些平衡。

References:
  1. 敏捷软件开发
  2. Interface Segregation Principle