前言
面向对象的SOLID设计原则,外加一个迪米特法则,就是我们常说的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
也就是说,客户端不应被迫依赖它们压根用不上的接口;或者反过来说,客户端应该只依赖它们要用的接口。
↑接口隔离原则的准确定义↑
这里的“接口”有一点迷惑性。虽然命名和定义中讨论的都是“接口”,但是这里的接口并非我们代码中的interface,而是粒度更细致的“接口方法”。例如,我们有这样一段代码:
public interface SomeInterface{ Dto query(Queryer queryer); int update(Queryer queryer, Dto data); }
从interface的角度来看,这段代码只声明了一个接口。但是,从“接口方法”的角度来看,这段代码声明了两个接口:一个用于查询数据,一个用于更新数据。如果一个客户端——例如QueryDataController——只需要使用其中的query()方法,那么对它来说,虽然SomeInterface是一个必要的依赖项,但是update()方法却不是。
另外一个令我感到迷惑的是,接口隔离原则的命名与定义实在有点有点名不副实。它的命名说的是“怎么做”,而并不是概括“做什么”;而它的定义虽然提到了“接口”,可是却闭口不谈“隔离”。这就使得接口隔离原则不能像其它设计原则那样顾名思义。如果是我的话,也许会把这一原则命名为“最小依赖原则”或者“必要依赖原则”。
不过,如果这样命名的话,那么这一设计原则的指向性又有点太模糊了。除了接口隔离之外,我们还有很多种办法可以为客户端“减负”:例如以后会提的迪米特法则、门面模式等,都可以实现这一目标。也许,就是考虑到区分度,所以才把这个“最小依赖原则”称为“接口隔离原则”吧。
此外,接口隔离原则的定义可谓别有深意。它总让我想起著名的“奥卡姆剃刀”法则:如无必要,勿增实体。实际上,接口隔离原则也是“奥卡姆剃刀”法则的一种应用:如无必要,勿增接口依赖。如果觉得接口隔离原则的说服力不太够,可以试试扛出这把“奥卡姆剃刀”来。
↑奥卡姆剃刀↑
岔开说一句,有的时候真的觉得“天地有道、万物一理”这话很有道理。例如,同样的一条道理,我们可以总结为“如无必要勿增实体”,也可以总结为“接口隔离原则”,还可以表述为“less is more”、“句有可削,足见其疏;字不得减,乃知其密”、“断舍离”、甚至是“简约而不简单”。不得不说,世界真奇妙。
为什么
其实,如果从正面来考虑“遵守接口隔离原则有什么好处”,恐怕我们很难得到令人信服的答案。因为接口隔离原则和“奥卡姆剃刀”原则类似,并不是逻辑上不可辩驳的定理或结论,而只能作为启发式技巧来帮助我们发展模型。
但是,如果从反面来论述“违反接口隔离原则有什么坏处”,就很容易理解了。“违反接口隔离原则”就像消失的地衣、或者变色的石蕊试纸一样,提示着我们“这里似乎有点问题”。
↑还记得这漂亮的小彩纸么↑
例如,我们有这样一个接口:
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); }
相比前面两类接口,这种接口恐怕最为司空见惯的——但是,未必是恰当的。它同样向调用方透露了太多不必要的信息,同样违反了接口隔离原则。同样的,这个接口也不是一个合格的抽象,也不够“高内聚低耦合”,也不够“开闭”;而且它的实现类肯定会违反单一职责原则;如果实现类的子类写得不够用心,还很容易违反里氏替换原则(然而如果用心写,又不得不付出额外的心血)……
我们很难说这些问题全都是因为这些接口违反了接口隔离原则。它们之间也许没有因果关系,但一定有很强的关联关系。就好像母鸡下蛋时一定会“咯咯哒”地叫一样:很难说清二者之间的因果关系,但我们都知道,母鸡“咯咯哒”地叫了,我们就有鸡蛋吃了。
↑有谁还会唱这首歌吗↑
怎么做
相比其它原则,遵守接口隔离原则实在是太容易了:把接口中多余的部分“剔除”掉,比如拆分到其它接口中去,或者隐藏到接口内部去,就可以了。
例如,前面例子中的第一个接口,就可以修改成这样:
// 把第一个接口,拆分成两个接口 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]开闭原则(二)》
什么是扩展?就Java而言,实现接口(implements SomeInterface)、继承父类(extends SuperClass),甚至重载方法(Overload),都可以称作是“扩展”。什么是修改?在Java中,严格来说,凡是会导致一个类重新编译、生成不同的class文件的操作,都是对这个类做的修改。实践中我们会放宽一点,只有改变了业务逻辑的修改,才会归入开闭原则所说的“修改”之中。 花园的景昕,公众号:景昕的花园[5+1]开闭原则(一)
《[5+1]里氏替换原则(二)》
里氏替换原则(Liskov Substitution principle)是一条针对对象继承关系提出的设计原则。它以芭芭拉·利斯科夫(Barbara Liskov)的名字命名。1987年,芭芭拉在一次名为“数据的抽象与层次”的演讲中首次提出这条原则;1994年,芭芭拉与另一位女性计算机科学家周以真(Jeannette Marie Wing)合作发表论文,正式提出了这条面向对象设计原则
花园的景昕,公众号:景昕的花园[5+1]里氏替换原则(一)