【设计原则篇】接口隔离原则(ISP)


  这个原则用来处理“胖(fat)”接口所具有的缺点。如果类的接口不是内聚的(cohesive),就表示该类具有“胖”的接口。换句话说,类的“胖”接口可以分解成多组方法。每一组方法都服务于一组不同的客户程序,如果你是使用C#/Java这种面向对象编程语言的话,可以理解每一组方法都服务于不同的对象。这样一些客户程序可以使用一组成员函数,而其他客户程序可以使用其他组的成员函数。

  ISP(interface isolation principle)承认存在有一些对象,它们确实不需要内聚的接口;但是ISP建议客户程序不应该看到它们作为单一的类存在。相反,客户程序看到的应该是多个具有内聚接口的抽象基类。

1、接口污染

  现有一个安全系统,在这个系统中,有一个IDoor的接口,可以被加锁和解锁,并且IDoor接口知道自己是开着还是关着。参考代码片段1

代码片段1 IDoor.cs

public interface IDoor
{
  public void Lock();
  public void UnLock();
  public bool IsDoorOpen();
}

  将IDoor设置成接口,这样客户程序就可以使用那些实现IDoor接口的对象,而不需要依赖于IDoor的特定实现。

  现在,考虑一个这样的实现,TimedDoor,如果门开着的时间过长,它就会发出警报声。为了做到这一点,TimedDoor对象需要和另一个名为ITimer的对象交互。参考代码片段2

代码片段2 ITimer.cs

public interface ITimer
{
  public void Register(int timeout,ITimerClient client);
}

public interface ITimerClient
{
  public void TimeOut();
}

  如果一个对象希望得到超时通知,它可以调用ITimer的Register函数。该函数有两个参数,一个是超时时间,单位是秒,另一个是实现了ITimerClient接口的对象的引用,该对象的TimeOut函数会在超时到达时被调用。

  我们怎样将ITimerClient接口和TimedDoor类联系起来,才能在超时的时候通知到TimedDoor中相应的处理代码呢?有几个方案可供选择。图1中展示了一个易想到的解决方案。其中IDoor继承了ITimerClient,因此TimeDoor也就必须实现ITimeClient接口中的TimeOut方法。这就保证了ITimerClient的实现类可以注册到ITimer中,并且可以接受TimeOut消息。


【设计原则篇】接口隔离原则(ISP)

图 1 位于层次结构顶部的Timer Client

  虽然这个解决方案很常见,但是他也不是没有问题。最主要的问题是,现在IDoor接口依赖于ITimerClient了。可是并不是所有种类的Door都需要定时功能。事实上,最初的Door接口和定时功能没有任何关系。如果创建了无需定时功能的Door的实现类,那么这些实现类中就必须要提供TimeOut方法的退化(degenerate)的实现——这就有可能违反LSP(里氏替换原则)。此外,使用这些实现类的应用程序即使不使用ITimerClient类中的方法,也必须要实现它。这样就具有了不必要的复杂性以及不必要的重复的僵化的味道。

  这是一个接口污染的例子,这种情况在像Java、C++、C#这样的静态类型语言中是很常见的。Door的接口被一个它不需要的方法污染了。在Door的接口中加入这个方法只是为了能给它的一个子类带来好处。如果持续这样做的话,那么每次子类需要一个新方法时,这个方法就会被加到基类中去。这会进一步污染基类的接口,使它变“胖”。

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

2、分离客户就是分离接口

  IDoor接口和ITimerClient接口是被完全不同的客户程序使用的。ITimer依赖于ITimerClient,而Door的类需要实现IDoor接口。既然客户程序是分离的,所以接口也应该保持分离。为什么呢?如果要遵循接口隔离原则的话,客户程序对它们使用的接口同时产生限制,必须内聚,并且同时遵循接口的单一职责原则。

客户对接口施加的反作用力
  在我们考虑软件中引起变化的作用力时,通常考虑的都是接口的变化会怎样影响它们的使用者。例如,如果ITimerClient的接口改变了,我们会去关心ITimerClient的所有使用者要做什么样的改变。然而,存在着从另外一个方向施加的作用力。有时,迫使接口改变的,正是它们的使用者。

  例如,有些ITimer的使用者会注册多个超时通知请求。比如对于TimedDoor来说。当它检测到门被打开时,会向ITimer发送一个Register消息,请求一个超时通知。可是,在超时到达前,门关上了,关闭一会儿后又被再次打开。这就导致在原先的超时到达前又注册了一个新的超时请求。最后,最初的超时到达,TimedDoor的TimeOut方法被调用。Door错误的发出了警报。

  使用代码片段3,可以改正上面情形中的错误。在每次超时注册中都包含一个唯一的timeOutID标识码,并在调用ITimerClient的TimeOut方法时,再次校验该标识码。这样TimerClient的每个派生类就都可以根据这个标识码知道应该响应哪个超时请求。

代码片段3 使用timeOutID的ITimer.cs

public interface ITimer
{
  public void Register(int timeout,int timeOutID,TimerClient client);
}

public interface ITimerClient
{
  public void TimeOut(int timeOutID);
}

  显然,这个改变会影响到ITimerClient的所有使用者。但是由于缺少timeOutId是一个必须要改正的错误,所以我们接受这种改变。然而,对于图1中的设计,这个修正还会影响到IDoor以及IDoor的所有客户程序,这就显得有些僵化。为什么ITimerClient中的一个bug会影响到那些不需要定时功能的IDoor的实现类的客户程序呢?如果程序中一部分的更改会影响到程序中完全和它无关的其他部分,那么更改的代价和影响就变得不可预测,并且更改所附带的风险也会急剧增加。

3、接口隔离原则(interface isolation principle)

  不应该强迫客户程序依赖于它们不用的方法,即派生类、实现类要实现内聚,不要实现、覆写那些不需要的方法。

  如果强迫客户程序依赖于那些它们不使用的方法,那么这些客户程序就面临着由于这些未使用方法的改变所带来的变更。这无意中导致了所有客户程序之间的耦合。换种说法,如果一个客户程序依赖于一个含有它不使用的方法的类,但是其他客户程序却要使用该方法,尽管我们遵循了依赖倒置原则,但是客户程序的业务变更同时也会影响到接口,那么当其他客户要求这个接口/抽象类改变时,就会影响到这个客户程序。我们希望尽可能地避免这种耦合,因此我们希望分离接口。

4、类接口与对象接口

  再次考虑一下TimedDoor问题。这里有两个独立的接口ITimer与IDoor,有两个独立的客户——使用ITimer以及IDoor的对象。因为实现这两个接口需要操作同样的数据,所以这两个接口必须在同一个对象中实现。那么怎样才能遵循ISP呢?

  该问题的答案基于这样的事实,就是一个对象的客户不是必须调用该对象的接口去访问它,也可也通过委托的方式去访问它。

4.1、使用委托分离接口

  一个解决方案是创建一个继承ITimerClient的对象,并把对该对象的请求委托给TimedDoor。图2展示了这个解决方案。

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

【设计原则篇】接口隔离原则(ISP)

图 2 Door定时器适配器

  这个解决方案遵循ISP原则,并且避免了Door的客户程序和Timer之间的耦合。即使对代码片段3中所示的Timer进行了改变,也不会影响到任何Door的使用者。此外,ITimedDoor也不必具有和ITimerClient一样的接口。DoorTimerAdapter会将ITimerClient接口转换成ITimedDoor接口。因此,这是一个非常通用的解决方案。参考代码片段4.

代码片段4 ITimedDoor.cs

public interface ITimedDoor : IDoor
{
  public void DoorTimeOut(int timeOutID);
}

DoorTimeAdapter.cs

public class DoorTimeAdapter : ITimerClient
{ 
  private ITimedDoor itsTimedDoor;
  public DoorTimerAdapter(ITimedDoor timedDoor)
  {
    itsTimedDoor = timedDoor;
  }

  public virtual void TimeOut(int timeOutId)
  {
    itsTimedDoor.DoorTimeOut(timeOutId);
  }
}

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

4.2、使用多重继承分离接口

  图3和代码片段5展示了如何使用多重继承来达到ISP的目标。在这个模型中,TimedDoor同时实现了IDoor和ITimerClient。尽管这两个接口的客户程序都可以使用TimedDoor,但是实际上却都不再依赖于TimedDoor类。这样,它们就通过分离的接口使用同一个对象。

【设计原则篇】接口隔离原则(ISP)

图 3 多重继承的Timed Door

代码片段 5 ITimedDoor.cs

public class c TimedDoor : IDoor,ITimerClient
{
  public void TimeOut(int timeOutID)
  {
    // your code
  }
}

  通常我会优先选择这个解决方案。只有当DoorTimerAdapter对象所做的转换是必须的,或者不同的时候会需要不同的转换时,我才会选择图2中的方案而不是图3中的方案。

5、结论

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

《Agile Software Development Principles,Patterns,and Practices》(Robert C.Martin)

原创文章,作者:jamestackk,如若转载,请注明出处:https://blog.ytso.com/273773.html

(0)
上一篇 2022年7月12日
下一篇 2022年7月12日

相关推荐

发表回复

登录后才能评论