依赖倒置原则的定义

依赖倒置原则(Dependence Inversion Principle,DIP)的原始定义:

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中,抽象就是接口或抽象类,两者都不能示例化;细节就是实现类,即实现接口或西城抽象类而产生的类,可以直接被示例话。

依赖倒置原则在Java语言中的表现:

  • 模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系;
  • 接口或抽象类不依赖于实现;
  • 实现类依赖接口或抽象类。

精简定义即“面向接口编程”——OOD(Object-Oriented Design,面向对象设计)的精髓之一。


依赖倒置原则的优点

依赖倒置原则 可以减少类间的耦合性,提高系统稳定性,降低并行开发引起的风险,提高代码可读性和可维护性。

使用反证法证明依赖倒置原则的优点:

论题: 依赖倒置原则可以减少类间的耦合性,提高系统稳定性,降低并行开发引起的风险,提高代码可读性和可维护性。 反论题: 不实用依赖倒置原则也可以减少类间的耦合性,提高系统稳定性,减低并行开发引起的风险,提高代码可读性和可维护性。

可通过例子证明反论题是不成立的。例如,司机驾驶奔驰车的类图如下:
image.png
Driver(司机)类代码如下:

  1. public class Driver {
  2. // 司机的主要职责为驾驶汽车
  3. public void drive(Benz benz) {
  4. benz.run();
  5. }
  6. }

Benz(奔驰)类的方法run代表车辆行驶,代码如下:

  1. public class Benz {
  2. // 汽车行驶
  3. public void run() {
  4. System.out.println("奔驰车开始运行");
  5. }
  6. }

Client场景类源代码如下:

  1. public class Client {
  2. public static void main(String[] args) {
  3. Driver zhangSan = new Driver();
  4. Benz benz = new Benz();
  5. zhangSan.drive(benz);
  6. }
  7. }

如果张三不只开奔驰,还要开宝马,情况则稍稍复杂。Bmw(宝马)类代码如下:

  1. public class Bmw {
  2. public void run() {
  3. System.out.println("宝马开始运行");
  4. }
  5. }

有了宝马类,但Driver类中却没有开动宝马车的方法。一个司机可以开奔驰车却不能开宝马车是不合理的,即设计出现了问题: Driver类和Benz类之间是紧耦合关系,导致系统的可维护性大大降低,可读性降低。 可证明反论题部分不成立。

对于 “减少并行发开引起的风险” ,并行开发最的啊的风险就是 风险扩散 ,即本来是一段程序的错误或异常,逐步波及一个功能、一个模块,甚至毁坏整个项目。

并行开发: 开发过程中,团队的不同人员负责不同的功能模块。例如,甲负责开发汽车类,乙负责司机类等。

在不使用依赖倒置的环境中,所有的开发工作都是“单线程”的,甲做完乙再做。依赖倒置原则可以解决模块之间的项目依赖关系,降低风险。

由以上可证明,如果不使用依赖倒置原则就会加重类间的耦合性,降低系统的稳定性,增加并行开发引起的风险,降低代码的可读性和可维护性。

承接上面的例子,引入依赖倒置原则后的类图如下:
image.png
建立两个接口:IDriver和ICar,分别定义司机和汽车的各个职能。其代码如下:

  1. // 司机接口
  2. public interface IDriver {
  3. public void drive(ICar car);
  4. }
  5. // 司机实现类
  6. public class Driver implements IDriver {
  7. public void drive(ICar car) {
  8. car.run();
  9. }
  10. }

在IDriver中,通过传入ICar接口实现了抽象之间的依赖关系,Driver实现类中也传入了ICar接口,具体型号在高层模块声明。ICar及其实现类代码如下:

  1. public interface ICar {
  2. public void run();
  3. }
  4. public class Benz implements ICar {
  5. public void run() {
  6. System.out.println("Benz is running");
  7. }
  8. }
  9. public class Bmw implements ICar {
  10. public void run() {
  11. System.out.println("Bmw is running");
  12. }
  13. }

业务场景中,贯彻“抽象不依赖细节”,即抽象的ICar接口不依赖Benz和Bmw两个细节实现类,因此在高层模块中的应用都是抽象的。Client场景类代码如下:

  1. public class Client {
  2. public static void main(String[] args) {
  3. IDriver zhangSan = new Driver();
  4. ICar benz = new Benz();
  5. zhangSan.drive(benz);
  6. }
  7. }

Client属于高层业务逻辑,它对低层模块的依赖都建立在抽象上(zhangSan苗面类型为IDriver,benz的表面类型为ICar)。如果要开宝马,修改业务场景即可:

  1. public class Client {
  2. public static void main(String[] args) {
  3. IDriver zhangSan = new Driver();
  4. ICar bmw = new Bmw();
  5. zhangSan.drive(bmw);
  6. }
  7. }

新增加低层模块时,只修改高层模块(场景类),对低层模块不需要做任何修改,业务即可运行,将“变更”引起的 风险扩散 降到最低。

因此在并行开发时,如果两个类有依赖关系,只需要制定处两者之间的接口(或抽象类)就可以独立开发了,且项目之间的单元测试也可以独立地运行。如司机汽车的例子,甲负责IDriver,乙负责ICar,制定好接口后,如果甲率先完成开发,通过引入JMock工具(用来根据抽象虚拟一个对象进行测试),测试类代码如下:

  1. public class DriverTest extends TestCase {
  2. Mockery context = new JUnit4Mockery();
  3. @Test
  4. public void testDriver() {
  5. // 根据接口虚拟对象
  6. final ICar car = context.mock(ICar.class);
  7. IDriver driver = new Driver();
  8. // 内部类
  9. context.checking(new Expectations() {
  10. oneOf(car).run();
  11. });
  12. driver.drive(car);
  13. }
  14. }

抽象是一种“契约”,保证所有“细节”不脱离契约的范畴,确保双方按照“契约”共同发展。


依赖的三种写法

依赖可以传递,A对象依赖B对象,B依赖C,C依赖D等。只要做到抽象依赖,再多层都无所谓。对象的依赖关系又三种传递方式,如下所示。

构造函数传递依赖对象

在类中通过构造函数生命依赖对象,在依赖注入中称为 构造函数注入 。按照此方式,IDriver和Driver修改后代码如下:

  1. // 司机接口
  2. public interface IDriver {
  3. public void drive();
  4. }
  5. // 司机实现类
  6. public class Driver implements IDriver {
  7. private ICar car;
  8. // 构造函数注入
  9. public Driver(ICar car) {
  10. this.car = car;
  11. }
  12. public void drive() {
  13. this.car.run();
  14. }
  15. }

Setter方法传递依赖对象

在抽象中设置Setter方法声明依赖关系,在依赖注入中称为 Setter依赖注入 ,按此方式注入,IDriver和Driver修改后代码如下:

  1. // 司机接口
  2. public interface IDriver {
  3. public void setCar(ICar car);
  4. public void drive();
  5. }
  6. // 司机实现类
  7. public class Driver implements IDriver {
  8. private ICar car;
  9. // Setter注入
  10. public setCar(ICar car) {
  11. this.car = car;
  12. }
  13. public void drive() {
  14. this.car.run();
  15. }
  16. }

接口声明依赖对象

在接口的方法中生命依赖对象,最初的示例即为接口声明依赖的方式,也叫 接口注入


最佳实践

依赖倒置原则的本质是 通过抽象(接口或抽象类)使各个类或模块的实现彼此独立,互不影响,实现模块间的松耦合 。使用依赖倒置原则要遵循以下几个规则:

  • 每个类尽量都有接口和抽象类,或者两者都具备;

    依赖倒置的基本要求,接口和抽象类都属于抽象的,有了抽象才可能依赖倒置。

  • 变量的表面类型尽可能是接口或抽象类;

    并不绝对。对于一些不需要借口或抽象类的类不必强求。

  • 任何类都不应该从具体类派生;

    并不绝对。任何设计都有缺陷,只要不超过两层继承都可以忍受。对于维护项目,通过继承关系重写一个方法可能可以修正一个很大的bug,基本可以不考虑此规则。

  • 尽量不重写基类方法;

    如果基类是一个抽象类,且这个方法已经实现了,子类尽量不要重写。 类间依赖的是抽象,重写了抽象方法,对依赖的稳定性有一定影响。

  • 结合里氏替换原则。

    接口负责定义public的属性和方法,并且声明与其他对象的依赖关系,抽象类负责公共构造部分的实现,实现类准确的实现业务逻辑,同时在适当的时候对父类进行细化。

依赖正置 指类间的依赖是实实在在的类间依赖,即面向实现编程(这也是正常人的思维方式)。根据系统设计的需要产生了抽象间的依赖代替了人们传统思维中的事物间依赖, “倒置” 就产生了。依赖倒置原则的优点在小型项目中难以体现,但在大型项目中优势很大。

依赖倒置原则是6各设计原则中最难实现的,它是实现开闭原则的中药徒刑。依赖倒置原则没有实现,很难实现对扩展开放,对修改关闭。 依赖倒置的核心是“面向接口编程”。