天天看点

[5+1]接口隔离原则(一)

前言

面向对象的SOLID设计原则,外加一个迪米特法则,就是我们常说的5+1设计原则。

[5+1]接口隔离原则(一)

↑ 五个,再加一个,就是5+1个。哈哈哈。↑

这六个设计原则的位置有点不上不下。

论原则性和理论指导意义,它们不如封装继承抽象或者高内聚低耦合,所以在写代码或者code review的时候,它们很难成为“应该这样做”或者“不应该这样做”的一个有说服力的理由。论灵活性和实践操作指南,它们又不如设计模式或者架构模式,所以即使你能说出来某段代码违反了某项原则,常常也很难明确指出错在哪儿、要怎么改。

所以,这里来讨论讨论这六条设计原则的“为什么”和“怎么做”。顺带,作为面向对象设计思想的一环,这里也想聊聊它们与抽象、高内聚低耦合、封装继承多态之间的关系。

[5+1] 接口隔离原则(一)

是什么

一般我们会说,接口隔离原则是指:把庞大而臃肿的接口拆分成更小、更具体的接口。

不过,这并不是接口隔离原则的定义。实际上,接口隔离原则的定义其实是这样的:

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

The Interface Segregation Principle

https://drive.google.com/file/d/0BwhCYaYDn8EgOTViYjJhYzMtMzYxMC00MzFjLWJjMzYtOGJiMDc5N2JkYmJi/view

也就是说,客户端不应被迫依赖它们压根用不上的接口;或者反过来说,客户端应该只依赖它们要用的接口。

[5+1]接口隔离原则(一)

↑接口隔离原则的准确定义↑

这里的“接口”有一点迷惑性。虽然命名和定义中讨论的都是“接口”,但是这里的接口并非我们代码中的interface,而是粒度更细致的“接口方法”。例如,我们有这样一段代码:

public interface SomeInterface{
    Dto query(Queryer queryer);
    int update(Queryer queryer, Dto data);
}      

从interface的角度来看,这段代码只声明了一个接口。但是,从“接口方法”的角度来看,这段代码声明了两个接口:一个用于查询数据,一个用于更新数据。如果一个客户端——例如QueryDataController——只需要使用其中的query()方法,那么对它来说,虽然SomeInterface是一个必要的依赖项,但是update()方法却不是。

另外一个令我感到迷惑的是,接口隔离原则的命名与定义实在有点有点名不副实。它的命名说的是“怎么做”,而并不是概括“做什么”;而它的定义虽然提到了“接口”,可是却闭口不谈“隔离”。这就使得接口隔离原则不能像其它设计原则那样顾名思义。如果是我的话,也许会把这一原则命名为“最小依赖原则”或者“必要依赖原则”。

不过,如果这样命名的话,那么这一设计原则的指向性又有点太模糊了。除了接口隔离之外,我们还有很多种办法可以为客户端“减负”:例如以后会提的迪米特法则、门面模式等,都可以实现这一目标。也许,就是考虑到区分度,所以才把这个“最小依赖原则”称为“接口隔离原则”吧。

此外,接口隔离原则的定义可谓别有深意。它总让我想起著名的“奥卡姆剃刀”法则:如无必要,勿增实体。实际上,接口隔离原则也是“奥卡姆剃刀”法则的一种应用:如无必要,勿增接口依赖。如果觉得接口隔离原则的说服力不太够,可以试试扛出这把“奥卡姆剃刀”来。

[5+1]接口隔离原则(一)

↑奥卡姆剃刀↑

岔开说一句,有的时候真的觉得“天地有道、万物一理”这话很有道理。例如,同样的一条道理,我们可以总结为“如无必要勿增实体”,也可以总结为“接口隔离原则”,还可以表述为“less is more”、“句有可削,足见其疏;字不得减,乃知其密”、“断舍离”、甚至是“简约而不简单”。不得不说,世界真奇妙。

为什么

其实,如果从正面来考虑“遵守接口隔离原则有什么好处”,恐怕我们很难得到令人信服的答案。因为接口隔离原则和“奥卡姆剃刀”原则类似,并不是逻辑上不可辩驳的定理或结论,而只能作为启发式技巧来帮助我们发展模型。

但是,如果从反面来论述“违反接口隔离原则有什么坏处”,就很容易理解了。“违反接口隔离原则”就像消失的地衣、或者变色的石蕊试纸一样,提示着我们“这里似乎有点问题”。

[5+1]接口隔离原则(一)

↑还记得这漂亮的小彩纸么↑

例如,我们有这样一个接口:

public interface FlowService{
    Flow approve(Flow curFlow);    
    User queryUser(Long userId);
}      

这个接口的怪异之处不言而喻:一个流程审批的方法,和一个查询用户信息的方法,怎么会出现在同一个接口里呢?我们很难推断个中缘由。看可以肯定,这个接口违反了接口隔离原则:一个只需要处理流程审批的调用者,才不关心怎样查询用户信息呢。

由此我们还会发现,这个接口的实现类也被迫违反单一职责原则:它不仅要承担流程审批的职责,还要承担查询用户的职责。由此,这些实现类也就变得低内聚、高耦合了起来。也许在某个时刻,这种“大杂烩”式的接口能给我们带来一时的便利;但是长远来看,它一定会成为系统扩展、演化路上的绊脚石。

当然,现在绝大多数程序员都不会再写这种“大杂烩”接口了。不过,我们还能见到一些其它的违反了接口隔离原则的情况。

例如,我经常见到这样的接口:

public interface SomeService{
    void doSth(Dto data);    
    void step1(Dto data);    
    void step2(Dto data);    
    void step3(Dto data);
}      

这个接口定义了四个方法。其中,只有doSth(Dto)方法是提供给外部使用的;其余step1(Dto)/step2(Dto)/step3(Dto)方法,都只是doSth(Dto)方法的中间步骤,仅在SomeService实现类中被调用。

虽然这四个方法都是为了同一个功能服务的,但是,这个接口还是违反了接口隔离原则:一个调用者只需要知道这个接口能做什么——也就是只需要调用doSth()方法,但并不需要、也不应该关心doSth()方法分了几个步骤、每一个步骤是什么。

由此我们可以说,这个接口不是一个合格的抽象,因为它把接口方法的实现细节暴露了出来。同时,它也不够“高内聚低耦合”。而且,如果某个实现类脱离了这种“三个步骤”的框架,那这个接口反而成了扩展的阻碍。可见,这个接口对“开闭原则”的支持也不够好。还有……

还有这样的接口:

public interface UserService{
    User queryById(Long userId);    
    User queryByIdCard(String idCard);    
    User queryByPhone(String phone);    
    void registerByEmail(User user);    
    void reigsterByPhone(User user, String verifyCode);
}      

相比前面两类接口,这种接口恐怕最为司空见惯的——但是,未必是恰当的。它同样向调用方透露了太多不必要的信息,同样违反了接口隔离原则。同样的,这个接口也不是一个合格的抽象,也不够“高内聚低耦合”,也不够“开闭”;而且它的实现类肯定会违反单一职责原则;如果实现类的子类写得不够用心,还很容易违反里氏替换原则(然而如果用心写,又不得不付出额外的心血)……

我们很难说这些问题全都是因为这些接口违反了接口隔离原则。它们之间也许没有因果关系,但一定有很强的关联关系。就好像母鸡下蛋时一定会“咯咯哒”地叫一样:很难说清二者之间的因果关系,但我们都知道,母鸡“咯咯哒”地叫了,我们就有鸡蛋吃了。

[5+1]接口隔离原则(一)

↑有谁还会唱这首歌吗↑

怎么做

相比其它原则,遵守接口隔离原则实在是太容易了:把接口中多余的部分“剔除”掉,比如拆分到其它接口中去,或者隐藏到接口内部去,就可以了。

例如,前面例子中的第一个接口,就可以修改成这样:

// 把第一个接口,拆分成两个接口
public interface FlowService{
    Flow approve(Flow curFlow);
}
public interface UserService{
    User queryUser(Long userId);
}      

简单的一次拆分,就可以让新的接口遵循接口隔离原则,让“凯撒的归凯撒,上帝的归上帝”了。

第二个接口的改造更简单一些;不过,考虑到为接口方法定义实现步骤的需求,我们还需要一个实现类:

public interface SomeService{
    void doSth(Dto data);
}
public abstract class SomeServiceAsSkeleton{
    public void doSth(Dto data){
         step1(data);        
         step2(data);        
         step3(data);    
     }    
     protected abstract void step1(Dto data);    
     protected abstract void step2(Dto data);    
     protected abstract void step3(Dto data);
 }      

这是模板模式的常见写法,想必原先的作者也是想使用模板模式吧。不过,接口定义的是对接口外部提供的功能,而抽象类定义的才是内部子类的基础实现。后者不需要、也不应该放到接口中。

第三个接口的改造还要更复杂一些:它的接口固然可以简单地合并成一个,但是考虑到不同情况下需要使用不同的查询参数,它的实现类还需要多花费些心思:

public interface UserService{
    /**根据入参中的不同数据,使用不同的查询条件*/    
    User queryUser(UserQuery query);
}

public interface UserRegster<T extends UserRegDto>{
    /**不同的子类使用不同的数据和实现*/    
    void register(T user);
}
public class UserQuery{
     private Long userId;     
     private String idCard;     
     private String phone;     
     private String email;    
     // getter和setter略
}
public class UserRegDto{
    private Long userId;     
    private String idCard;
}
public class UserRegByEmailDto extends UserRegDto{
     private String email;
}
public class UserRegByPhoneDto extends UserRegDto{
     private String phone;     
     private String verifyCode;
 }      

总之,如果只是遵循接口隔离原则,接口设计确实挺简单。不过,再和其它方方面面综合起来考虑的话,这个简单的接口设计确实也不太简单。说到底,接口代表的是功能抽象,而非简单的interface,还应该认真对待。

往期索引

《面向对象是什么》

从具体的语言和实现中抽离出来,面向对象思想究竟是什么?公众号:景昕的花园面向对象是什么

《抽象》

抽象这个东西,说起来很抽象,其实很简单。

花园的景昕,公众号:景昕的花园抽象

《高内聚与低耦合》

《细说几种内聚》

《细说几种耦合》

"高内聚"与"低耦合"是软件设计和开发中经常出现的一对概念。它们既是做好设计的途径,也是评价设计好坏的标准。

花园的景昕,公众号:景昕的花园高内聚与低耦合

《封装》

《继承》

《多态》

——“面向对象的三大特性是什么?”——“封装、继承、多态。”

《[5+1]单一职责原则》

单一职责原则非常好理解:一个类应当只承担一种职责。因为只承担一种职责,所以,一个类应该只有一个发生变化的原因。花园的景昕,公众号:景昕的花园[5+1]单一职责原则

《[5+1]开闭原则(一)》

《[5+1]开闭原则(二)》

什么是扩展?就Java而言,实现接口(implements SomeInterface)、继承父类(extends SuperClass),甚至重载方法(Overload),都可以称作是“扩展”。什么是修改?在Java中,严格来说,凡是会导致一个类重新编译、生成不同的class文件的操作,都是对这个类做的修改。实践中我们会放宽一点,只有改变了业务逻辑的修改,才会归入开闭原则所说的“修改”之中。花园的景昕,公众号:景昕的花园[5+1]开闭原则(一)

《[5+1]里氏替换原则(二)》

花园的景昕,公众号:景昕的花园[5+1]里氏替换原则(一)