设计模式6大设计原则解读——依赖倒置原则

上一篇我们解读了里氏替换原则,今天来说一下依赖倒置原则。

  1. 依赖倒置原则(Dependence Inversion Principle DIP)
    定义:
    1、高层模块不应该依赖低层模块,两者都应该依赖其抽象
    2、 抽象不应该依赖细节
    3、 细节应该依赖抽象
    原文定义:
    High level modules should not depend upon low level modules.Both should depend upon
    abstractions.Abstractions should not depend upon details.Details should depend upon abstractions.
    简要解读:高层模块和低层模块比较容易理解,每一个复杂的业务逻辑的实现,都是由不可再分的原子逻辑构成,不可再分的逻辑就是低层模块的定义,原子逻辑的各种组装就又构成了高层模块。那么抽象和细节又是什么呢?抽象在java中就是讲的接口和抽象类,它们是不能直接被实例化的,必须有具体的类去实现它们,那么这些具体的类就是实现,这些类可以直接被实例化。
    场景再现:类A直接依赖类B,假如要将类A改为依赖类C,则必须通过修改类A的代码来达成。这种场景下,类A一般是高层模块,负责复杂的业务逻辑;类B和类C是低层模块,负责基本的原子操作;假如修改类A,会给程序带来不必要的风险。
    解决方案:将类A修改为依赖接口I,类B和类C各自实现接口I,类A通过接口I间接与类B或者类C发生关联,这样就大大降低了修改类A的可能性。
    理解:依赖倒置原则在java中的体现就是:
    1、模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的;
    2、接口或抽象类不依赖于实现类;
    3、实现类依赖接口或抽象类。
    更细一点来说就是:每个类尽量都有接口或抽象类,或者抽象类和接口两者都具备;变量的声明类型尽量是接口或者是抽象类(有些不必,如xxxUtil类等);任何类都不应该从具体类派生,尽量不要覆写基类的方法,也就是要遵循上一篇讲的里氏替换原则,接口负责定义public属性和方法,并且声明与其他对象的依赖关系,抽象类负责公共构造部分的实现,实现类准确的实现业务逻辑,同时在适当的时候对父类进行细化。
    依赖倒置原则基于这样一个事实:相对于细节的多变性,抽象的东西要稳定的多。以抽象为基础搭建起来的架构比以细节为基础搭建起来的架构要稳定的多。使用接口或者抽象类的目的是制定好规范和契约,而不去涉及任何具体的操作,把展现细节的任务交给实现类去完成。

采用依赖倒置原则的好处:可以降低类之间的耦合性,提高系统稳定性,降低并行开发引起的风险,提高代码可读性和维护性。依赖倒置原则更加精简的定义就是“面向接口编程”,也是面向对象设计的精髓所在。
证实一个定理的正确性,常用的有两种方法,顺证法和反证法。顺证法就是提出一个论题,经过论证之后得出和论题一致的答案。反证法就是提出一个伪命题,最终论证得出一个与已知条件互斥的结论,来证明原命题的正确性。下面我们将使用反证法来解读以下论题:
反论题:不使用依赖倒置原则也可以减少类之间的耦合性,提高系统稳定性,降低并行开发引起的风险,提高代码可读性和维护性。
下面我们通过一个例子来证明反论题是不成立的。我们以司机驾驶汽车这个场景来论证。类图如下:
这里写图片描述

这是一个司机开奔驰汽车的类图,奔驰车提供了一个run方法来实现车辆运行,司机和奔驰车的实现过程如下图:

这里写图片描述

有了司机和奔驰车,我们就来看一下场景类吧,类图如下:

这里写图片描述

现在我们来看,张三开着奔驰没有什么问题,但是现实世界是复杂多变的,软件是对现实世界的抽象,软件体现了人的意志,所以软件需求也是经常变化的,如何应对变化就要看我们设计的软件是否具有很高的扩展性了。假如有一天,张三还要开宝马车,我们来看看是什么情况,我们先按照正常的思路来,先把宝马车的类实现出来,如下图:

这里写图片描述

现在宝马车已经有了,但是我们发现张三并不能开宝马,因为张三没有开宝马的方法,这显然不合理。我们可以发现司机类和奔驰车类是紧耦合的关系,这样就导致系统可维护性降低,可读性也降低了,两个具有相似功能的类需要阅读两个不同的类,那稳定性呢?我们这里只是增加了一个宝马车类就需要修改司机类了,这是易变形,而不是稳定性。这还只是简单的场景,实际复杂的场景中工作量就可想而知了。现在我们已经证实反论题部分不成立了。

下面我们继续论证,减少并行开发的风险,我们知道并行开发最大的风险就是风险扩散,本来是一段程序的错误或异常,逐渐波及到整个功能,甚至整个模块整个项目。如果甲负责汽车类的实现,乙负责司机类的实现,在甲没有完成开发的情况下乙是没有办法完全实现司机类的方法的。缺少汽车类,司机类编译都不会通过,在不使用依赖倒置原则的时候,只能是单线程的进行工作,只有等一个人完成了开发另一个人才能继续进行功能的开发,但现在已经不是一个人开发的时代了,一个项目是由整个团队共同完成的,那么就涉及到并行开发了,并行开发就需要考虑模块之间的依赖关系,并处理好这些依赖,否则整个项目就会一团糟。根据以上论证,我们可以推翻反论题,证明我们的反论题不成立,进而证明我们的依赖倒置原则能够有这些好处。
接下来我们看看引入依赖倒置原则后的情况,如下图:

这里写图片描述

使用接口来定义司机和汽车类的功能,下图是司机接口及司机实现类的代码:

这里写图片描述

这里写图片描述

在IDriver中通过传入ICar接口实现了抽象之间的依赖关系,Driver实现类也传入了ICar接口,但是确定是哪种类型的车也是由高层模块调用的时候才能知道。
ICar及两个实现类代码如下:

这里写图片描述

在业务场景类中,我们遵循“抽象不应该依赖细节”,我们认为“抽象”(ICar接口)不依赖BMW和Benz两个实现类(细节),因此在高层模块中应用的都是抽象。代码如下:

这里写图片描述

Client属于高层模块,它对低层的依赖都建立在抽象上,张三的表面类型是IDriver,奔驰的表面类型是ICar。看也许你会说高层模块也调用了低层模块new Benz(),new Driver(),这怎么解释呢?上面说了张三的表面类型是IDriver,是一个接口,是抽象的、非实体化的,在后续的操作中都是以IDriver类型进行的操作,屏蔽了细节对抽象的影响。如果张三现在要开宝马呢,很简单只要修改业务场景类就可以了。如下图:

这里写图片描述

在增加低层模块时只是修改了高层模块(业务场景类Client),对其他低层模块不产生任何影响,不需做其他修改,这样就把变更引起的风险扩散降到最低。

那么接下来考虑并行开发的影响,实际上,测试驱动TDD(Test- Driven Development)开发模式就是对依赖倒置原则最高级的应用。TDD要求写好测试类,对于有些没有实现的类,我们可以采用打桩的方式(通过mock框架)来实现。如果多个人负责不同的功能,编码实现过程有快有慢,只需要预先设计好接口然后各自实现就可以了,单元测试依然是可以单独进行,不会互相产生影响。抽象是对实现的约束,不仅约束自己的实现还约束自己与外部的关系,目的就是保证所有细节不脱离契约的规定,确保约束双方能够按照契约共同开发。

依赖是可以传递的,但是只要遵循一点,以抽象的方式来传递,不管依赖关系多么繁杂也没有关系。
依赖关系有三种方式来传递
1、构造函数传递依赖对象

这里写图片描述

2、setter方法传递依赖对象

这里写图片描述

3、接口声明传递依赖对象

在接口的方法中声明依赖对象,也叫接口注入。上面的例子就采用了这种方式。

说了这么多,我们来看看“倒置”,理解倒置先来看看什么是“正置”,依赖正置就是类之间的依赖是实实在在的实现类之间的依赖,也就是面向实现编程,这也是正常人的思维方式,我要开奔驰就依赖奔驰车,开宝马就依赖宝马车,而软件是对现实世界的抽象,也就有了抽象类和接口,然后我们根据系统设计的需要就产生了抽象间的依赖,代替了人们传统思维中的事物间的依赖,“倒置”就是这么产生的。

依赖倒置的原则在小项目中体现的不明显,但是在大中型项目中采用依赖倒置原则就会有非常多的优点,特别是规避一些非技术因素引起的问题。项目越大,需求变化引起改变的几率也越大,通过使用抽象类和接口对实现进行约束,可减少由于需求变化引起的工作量剧增的情况。人员的变动在大型项目中也是很常见的,如果设计优良,代码结构清晰,人员变化对项目的影响是非常小的甚至为零。大型项目的维护周期一般都比较长,采用依赖倒置原则可以让维护人员轻松的扩展和维护。

依赖倒置原则是实现开闭原则的重要途径,也是最难实现的原则。依赖倒置没有实现,就不要说对扩展开放,对修改关闭了,在实际开发中,大家只要抓住面向接口编程就等于抓住了依赖倒置原则的核心。最后想要说明的是每一个原则都有其特定适用场景,具体要根据实际的项目情况去合理应用。

依赖倒置原则就说到这里,下一篇我们将介绍接口隔离原则。