读源码学架构系列:依赖倒置原则

依赖倒置原则(DIP):

  • 高层模块不应该依赖于低层模块,二者都应该依赖于抽象。
  • 抽象不应该依赖于细节,细节应该依赖于抽象。

这里的倒置是什么意思?

在传统的软件开发方法,比如结构化分析和设计中,总是倾向于创建一些高层模块依赖于低层模块、策略依赖于细节的软件结构。实际上这些方法的目的之一就是要定义子程序层次结构,该层次结构描述了高层模块怎样调用低层模块。一个设计良好的面向对象的程序,其依赖程序结构相对于编译的过程式方法设计的通常结构而言就是被倒置了。

当高层模块依赖于低层模块时意味着什么呢?

高层模块包含了一个应用程序中的重要的策略选择和业务模块。然而,如果这些高层模块依赖于低层模块,那么对低层模块的改动就会直接影响到高层模块,从而迫使它们依次做出改动。如果高层模块依赖于低层模块,那么在不同的上下文中重用高层模块就会变得非常困难。然而,如果高层模块独立于低层模块,那么高层模块就可以非常容易地被重用。这条原则是框架设计的核心原则。

所有结构良好的面向对象架构都具有清晰的层次定义,每个层次通过一个定义良好的、受控的接口向外提供了一组内聚的服务。

对上面这句话,如果只是简单的理解可能会致使设计者设计出如下的的层次化方案:

这种方案看起来似乎是正确的,然而它存在一个隐藏的错误特征:Policy Layer对于其下一直到Utility Layer的改动都是敏感的,这种依赖关系是传递的。

一个更为合适的模型应该是这样:

每个较高层次都为它所需要的服务声明一个抽象接口,较低的层次实现了这些抽象接口,每个高层类都通过该抽象接口使用下一层,这样高层就不依赖于低层,低层反而依赖于在高层中声明的抽象服务接口。这样就完全解除了前面那张图片中各个层之间的传递依赖关系。

这里的倒置不仅仅是依赖关系的倒置,也是接口所有权的倒置。通常我们会认为工具库应该拥有它们自己的接口,但是当应用了DIP时,我们发现,往往是客户拥有抽象接口,而它们的服务者则从这些抽象接口派生。

这也是著名的Hollywood原则:Don't call us, we'll call you.低层模块实现了在高层模块中声明并被高层模块调用的接口

DIP也可以简单的理解为:依赖于抽象。即建议不应该依赖于具体类,程序中所有的依赖关系都应该终止于抽象类或接口。

因此:

  • 任何变量都不应该持有一个指向具体类的指针或者引用
  • 任何类都不应该从具体类派生
  • 任何方法都不应该覆写它的任何基类中的已经实现了的方法

当然,每个程序中都会有违反上述规则的情况。有时必须要创建具体类的实例,而创建这些实例的模块将会依赖于它们。另外,上述规则对于那些虽是具体但却稳定的类来说似乎不太合理,因为如果一个具体类不太会改变,并且也不会创建其他类似的派生类,那么依赖于它并不会造成损害。

其实,前面讲的SPI就是典型的DIP的应用,JDKJDBC声明了依赖:Driver接口,具体的实现由数据库的厂商实现,JDBC规范只依赖于接口,并不依赖于任何具体的数据库厂商的驱动的实现。同样,Spring框架中的扩展点、Dubbo中各层暴露的扩展点也是一样。

假设有这样一个需求:

有一个Button对象可以感知外部环境的变化,当接收到Poll消息时,它会判断按钮是否被按下。它不关心是通过什么样的机制去感知的。可能是一个物理开关,也可能是一个光感器,甚至可能是手机上的某个appButton对象可以检测到用户激活或者关闭了它。

同时有一个Lamp对象会影响外部环境。当接收到TurnOn消息时,它显示某种灯光。当接收到TurnOff消息时,它把灯光熄灭。它可以是你家阳台上的彩灯,也可以是你家客厅的吊灯,甚至是小米的智能台灯。

如何设计一个用Button对象控制Lamp对象的系统呢?

最简单的实现:

考虑一下上面的这个模型的Java实现:

public class Button {

    private Lamp lamp;
    private boolean isActivated;

    public Button(Lamp lamp) {
        this.lamp = lamp;
    }

    public void poll() {
        if (isActivated) {
            lamp.turnOff();
        } else {
            lamp.turnOn();
        }
    }

    public void activate() {
        this.isActivated = true;
    }

    public void deactivate() {
        this.isActivated = false;
    }

}
public class Lamp {

    public void turnOn() {
        System.out.println("The lamp now is turn on.");
    }

    public void turnOff() {
        System.out.println("The lamp now is turn off.");
    }

}

上面的设计中,Button类直接依赖了Lamp类,当Lamp类改变时,Button类也会受到影响;此外,如果想要重用Button来控制其它的对象,比如Fan(风扇)对象是不可能的。也就是说,这个设计中,Button控制着Lamp对象,并且也只能控制Lamp对象。

这个设计明显的违反了DIP原则。程序的高层策略没有和低层实现分离。抽象没有和具体细节分离。

那什么是高层策略呢?

它是应用背后的抽象,也就是那些不随具体细节的改变而改变的真理。在上面的Button / Lamp的例子中,背后的抽象是检测用户的激活/关闭指令,并将指令传给目标对象。具体用什么机制检测用户的指令呢?无关紧要;目标对象是什么?无关紧要;这些都是具体的细节,不会影响到我们的抽象。

重新设计如下:

新的设计中,Button控制的是那些愿意实现Switchable接口的任何设备,这就意味着Button对象能够控制还没有被创造出来的对象。

通过倒置依赖关系的方向,使得Lamp依赖于其他类而不是被其他类依赖。

至此,我们看到使用传统的过程化程序设计所创建出来的依赖关系结构,策略是依赖于细节的。这样会使策略受到细节改变的影响。面向对象的程序设计倒置了依赖的关系结构,使得细节和策略依赖于抽象,并且常常是客户拥有服务接口。

这种依赖关系的倒置正是好的面向对象设计的标志所在,如果程序的依赖关系是倒置的,它就是面向对象的设计;如果程序的依赖关系不是倒置的,它就是过程化的设计。

依赖倒置原则是实现许多面向对象技术所宣称的好处的基本底层机制。它的正确应用对于创建可重用的框架来说是必须的。同时它对于构建在变化面前富有弹性的代码也是非常重要的。由于抽象和细节被分离,所以代码也非常容易维护。

References:
  1. 敏捷软件开发
  2. Dependency Inversion Principle