【OO设计原则】——04依赖倒置原则

依赖倒置原则DIP(Dependency Inversion Principle)

High level modules should not depend upon low level modules. Both shoulddepend upon abstractions.

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

Abstractions should not depend upon details. Details should depend uponabstractions.

抽象不应该依赖于细节,细节应该依赖于抽象

概念说明:

依赖倒置原则的文字定义不好理解,这里首先对概念进行说明:

依赖:

在程序设计中,如果一个模块a使用/调用了另一个模块b,我们称模块a依赖模块b。

高层模块和低层模块的理解:

每一个逻辑的实现都是由原子逻辑组成的,不可分割的原子逻辑就是低层模块,原子逻辑的再组装就是高层模块。也就是说,往往在一个应用程序中,我们有一些低层次的类,这些类实现了一些基本的或初级的操作,我们称之为低层模块;另外有一些高层次的类,这些类封装了某些复杂的逻辑,并且依赖于低层次的类,这些类我们称之为高层模块。

在Java中,抽象就是指接口或者抽象类,两者都是不能直接被实例化的;细节就是实现类,实现接口或者继承抽象类而产生的就是细节,也就是可以通过new产生的对象。高层模块就是调用端,低层模块就是具体实现类。

依赖倒置原则在Java中的体现就是:

模块间通过抽象发生关系,实现类之间不发生直接依赖关系,其依赖关系是通过接口或者抽象类产生的。

接口或抽象类不依赖于实现类。

实现类依赖接口或抽象类。

依赖分类:

我们现在来看看依赖有几种,依赖也就是耦合,分为下面三种:

  • 零耦合(Nil Coupling)关系,两个类没有依赖关系,那就是零耦合。
  • 具体耦合(Concrete Coupling)关系,两个具体的类之间有依赖关系,那么就是具体耦合关系,如果一个具体类直接引用另外一个具体类就会发生这种关系。
  • 抽象耦合(Abstract Coupling)关系,这种关系发生在一个具体类和一个抽象类之间,这样就使必须发生关系的类之间保持最大的灵活性。

依赖倒置:

为什么叫做依赖倒置(DependencyInversion)呢?面向对象程序设计相对于面向过程(结构化)程序设计而言,依赖关系被倒置了。因为传统的结构化程序设计中,高层模块总是依赖于低层模块。

依赖倒置(Dependence InversionPrinciple)原则讲的是:要依赖于抽象,不要依赖于具体。简单的说,依赖倒置原则要求客户端依赖于抽象耦合。

一句话总结依赖倒置原则就是:面向接口编程,而不是面向实现编程。

依赖倒置出现的原因(解耦合):

“Bad Design”的提出:

Robert C. Martin氏在原文中给出了“Bad Design”的定义:

1. It is hard to change because every change affects too many other parts ofthe system.(Rigidity)

系统很难改变,因为每个改变都会影响其他很多部分。

2. When you make a change, unexpected parts of the system break. (Fragility)

当你对某地方做一修改,系统的看似无关的其他部分都不工作了。

3. It is hard to reuse in another application because it cannot be disentangledfrom the current application. (Immobility)

系统很难被另外一个应用重用,因为你很难将要重用的部分从系统中分离开来。

导致“Bad Design”的很大原因是“高层模块”过分依赖“低层模块”。一个良好的设计应该是系统的每一部分都是可替换的。

如果“高层模块”过分依赖“低层模块”:

一方面一旦“低层模块”需要替换或者修改,“高层模块”将受到影响;另一方面,高层模块很难可以重用。

比如,一个Copy模块,需要把来自Keyboard的输入复制到Print,

即使对Keyboard和Print的封装已经做得非常好,但如果Copy模块里直接使用Keyboard与Print(输出到控制台、屏幕),Copy任很难被其他应用环境(比如需要输出到磁盘时)重用。

问题的解决:

为了解决上述问题,Robert C. Martin氏提出了OO设计的Dependency Inversion Principle (DIP) 原则。

DIP给出了一个解决方案:

在高层模块与低层模块之间,引入一个抽象接口层。

High Level Classes(高层模块)  -->

Abstraction Layer(抽象接口层) -->

Low Level Classes(低层模块)

抽象接口是对低层模块的抽象,低层模块继承或实现该抽象接口。

这样,高层模块不直接依赖低层模块,高层模块与低层模块都依赖抽象接口层。当然,抽象也不依赖低层模块的实现细节,低层模块依赖(继承或实现)抽象定义。

Robert C. Martin氏给出的DIP方案的类的结构图:

PolicyLayer -->   Copy模块

MechanismInterface(abstract) -->  抽象

MechanismLayer -->  Keyboard模块  Print模块

UtilityInterface(abstract) -->  抽象

UtilityLayer  公用基础模块

类与类之间都通过Abstract Layer来组合关系。

例01

下面一个司机驾驶奔驰的例子,具体体现依赖倒置的两大原则,类图如下:

其中司机Driver有一个drive方法来驾驶奔驰车,奔驰汽车Benz有一个run方法来启动汽车。Driver类依赖于Benz类, 相关代码:

public class Benz {
    public void run() {
        System.out.println("奔驰汽车开始运行...");
    }
}
/**
* Driver 类直接依赖(具体耦合) Benz类
*/
public class Driver {
    public void drive(Benz benz) {
        benz.run();
    }
}

 

public class Client {
    public static void main(String[] args) {
        Driver zhangSan = new Driver();
        Benz benz = new Benz();
        //张三开奔驰
        zhangSan.drive(benz);
    }
}

运行程序,我们创造的张三司机可以顺利的驾驶奔驰汽车了,如果现在张三司机不仅要开奔驰还要开宝马,怎么做呢,先生成宝马汽车:

public class BMW {
    public void run() {
        System.out.println("宝马汽车开始运行...");
    }
}

现在BMW宝马汽车有了,但是张三却无法驾驶它,因为司机类Driver的drive方法耦合的是Benz类的对象参数,没法传递新生成的BMW对象,这时你可能会去Driver里添加一个可以专门传递BMW对象的方法,很多时候你都会这样去做,随着业务的增加,我们的Driver会不断的修改,这导致我们的系统不稳定且越来越难以维护,这就说明系统设计存在问题,Driver和Benz严重耦合导致新需求来时只能再去修改Driver类。而依赖倒置就是为了解决这类问题的,我们按照依赖倒置原则重新设计类图如下:

在该类图当中IDriver接口依赖ICar接口,drive方法传递ICar接口类型的参数。子类Driver实现IDriver接口,奔驰和宝马汽车的具体类则实现ICar接口。相关代码:

public interface IDriver {
    //是司机就可以驾驶汽车
    public void drive(ICar car);
}
public interface ICar {
    //是汽车就可以跑
    public void run();
}
public class Benz implements ICar {
    @Override
    public void run() {
        System.out.println("奔驰汽车开始运行...");
    }
}
public class BMW implements ICar {
    @Override
    public void run() {
        System.out.println("宝马汽车开始运行...");
    }
}
public class Driver implements IDriver {
    @Override
    public void drive(ICar car) {
        car.run();
    }
}
public class Client {
    public static void main(String[] args) {
        IDriver zhangSan = new Driver();
        ICar benz = new Benz();
        //张三开奔驰
        zhangSan.drive(benz);
    }
}

可以看到我们的张三司机可以开奔驰汽车,其中IDriver接口的drive方法现在不依赖于具体的Benz或者BMW的实现类,即“抽象不依赖于细节”,而我们的Driver实现类的drive方法也是依赖于ICar而不是具体的Benz或者BMW对象,即“细节依赖于抽象”。抽象的Driver依赖于抽象的Car, 司机开的是抽象的车,在构造接口的阶段我们根本都不需要关心司机将来要开的具体是什么样的鸟车,我们只需要知道司机会驾驶车就可以了。这样的做到的好处就是,假如现在司机要开宝马汽车,好,客户端代码就可以轻松的应对:

public class Client {
    public static void main(String[] args) {
        IDriver zhangSan = new Driver();
        ICar bmw = new BMW();
        //张三开宝马
        zhangSan.drive(bmw);
    }

这样宝马车就可以开动起来了,注意在Client代码当中司机和汽车对象变量的引用我们都是使用的接口类型IDriver和ICar, 而不是具体的Driver和BMW对象,在创建对象以后的操作都是完全针对接口类型进行的,这就是“高层模块不依赖于低层”。

例02

在前面单一职责中提到的鸭子类设计的例子时候留下了一个坑,这里就利用依赖倒置原则把这个坑填上。在单一职责中把鸭子呱呱叫和飞行两个变化的行为单独提取到了两个接口当中,每个Duck实现类都需要去选择是否实现相应的接口,比较麻烦,现在我们为每一种接口建立一组实现子类。类图设计如下:

现在我们为鸭子类添加呱呱叫和飞行这两种行为的抽象依赖,完整的类图设计如下:

相关实现代码如下:

public interface FlyBehavior {
    public void fly();
}
public class FlyWithWings implements FlyBehavior {
    @Override
    public void fly() {
        System.out.println("I'm Flying");
    }
}
public class FlyNoWay implements FlyBehavior {
    @Override
    public void fly() {
        //do nothing
    }
}
public interface QuackBehavior {
    public void quack();
}
public class Quack implements QuackBehavior {
    @Override
    public void quack() {
        System.out.println("Quack");
    }
}
public class Squeak implements QuackBehavior {
    @Override
    public void quack() {
        System.out.println("Squeak");
    }
}
public class MuteQuack implements QuackBehavior {
    @Override
    public void quack() {
        //do nothing
    }
}

定义时使用接口依赖飞与叫的行为(抽象耦合),不用关心具体的实现(换句话说任意的满足接口约束的实现行为都可以传入)

public abstract class Duck {
    FlyBehavior flyBehavior;
    QuackBehavior quackBehavior;

    public abstract void display();

    public void performFly() {
        flyBehavior.fly();
    }

    public void performQuack() {
        quackBehavior.quack();
    }

    public void setFlyBehavior(FlyBehavior fb) {
        this.flyBehavior = fb;
    }

    public void setQuackBehavior(QuackBehavior qb) {
        this.quackBehavior = qb;
    }

    public void swim() {
        System.out.println("All ducks float, event decoys!");
    }
}
public class MallardDuck extends Duck{
    @Override
    public void display() {
        System.out.println("I'm real Mallard duck");
    }
}

//在具体使用时,向mallardDuck注入依赖
public class DuckClient {
    public static void main(String[] args) {
        Duck mallardDuck = new MallardDuck();

        QuackBehavior quack = new Quack();
        FlyBehavior flyWithWings = new FlyWithWings();

        mallardDuck.setFlyBehavior(flyWithWings);
        mallardDuck.setQuackBehavior(quack);

        mallardDuck.performQuack();
        mallardDuck.performFly();
    }
}

按照依赖倒置原则,不管我们的抽象类Duck还是实现类MallardDuck都是对FlyBehavior和QuackBehavior进行了抽象的依赖。在更换鸭子行为的时候,我们的Duck实现类跟具体的行为实现完全解耦,Duck类只需要接收抽象的行为接口,任意实现接口的行为都可以快速的完成修改。

这个例子告诉我们一个OO法则:多用组合,少用继承。组合尽量持有对抽象的依赖,这样才能够解耦具体实现类。

依赖倒置中依赖传递的三种方式

1. 构造函数依赖注入

public interface IDriver {
    //是司机就可以驾驶汽车
    public void drive();
}
public class Driver implements IDriver {
    private ICar car;
    public Driver(ICar car) {
        this.car = car;
    }
    @Override
    public void drive() {
        car.run();
    }
}

2. Setter依赖注入

public interface IDriver {
    //设置车辆
    public void setCar(ICar car);
    //是司机就可以驾驶汽车
    public void drive();
}
public class Driver implements IDriver {
    private ICar car;
    @Override
    public void setCar(ICar car) {
        this.car = car;
    }
    @Override
    public void drive() {
        car.run();
    }
}

3. 接口方法依赖注入

public interface IDriver {
    //是司机就可以驾驶汽车
    public void drive(ICar car);
}
public class Driver implements IDriver{
    @Override
    public void drive(ICar car) {
        car.run();
    }
}

很显然,在三种依赖注入方式当中setter依赖注入方式应该是最灵活的,因为可以在任意阶段进行注入,也可以随时切换注入的对象类型,实现动态插拔替换,这正是策略模式的原型。

总结:

依赖倒置原则体现的OO特征:抽象、多态。

如何使用依赖倒置原则,遵循以下几个原则:

1.每个类尽量有接口或者抽象类,接口和抽象类都属于抽象,有了抽象才能依赖倒置。

2.变量的表面类型尽量是接口或者抽象类

3.尽量不要覆写基类的方法,子类尽量不要覆写抽象类已经实现的方法。

4.开发阶段尽量不要从具体类派生新类,只是在开发阶段,因为在维护阶段是要不断扩展修改的。

依赖倒置原则是开闭原则的强化原则,满足依赖倒置原则也就做到了对扩展开放,对修改关闭,不能满足依赖倒置就很难做到开闭原则。这个原则也是6个设计原则当中最难以实现的原则,但是一旦你实现了依赖倒置就能够打破传统思维,摆脱对具体实现的耦合性依赖,做到以不变应万变。

依赖倒置原则固然好,但设计原则不是万能的,实际当中还是会依赖一些细节,另外DIP是假定所有的具体类都会变化,这也不是全对,有些具体类就相当稳定(比如静态公共类、工具类)。使用这个类的客户端就完全可以依赖这个具体类而不用再弄一个抽象类。所以不要为了遵循设计原则而去遵循设计原则。

补充:java中抽象类与接口的区别和使用原则

(1):针对接口编程的意思是说,应当使用接口和抽象类进行变量的类型声明,参量的类型声明,方法的返还类型声明,以及数据类型的转换等。不要针对实现编程的意思就是说,不应当使用具体类进行变量的类型声明,参量类型声明,方法的返还类型声明,以及数据类型的转换等。

要保证做到这一点,一个具体的类应等只实现接口和抽象类中声明过的方法,而不应当给出多余的方法.只要一个被引用的对象存在抽象类型,就应当在任何引用此对象的地方使用抽象类型,包括参量的类型声明,方法返还类型的声明,属性变量的类型声明等.

(2)接口与抽象的区别就在于抽象类可以提供某些方法的部分实现,而接口则不可以,这也大概是抽象类唯一的优点.如果向一个抽象类加入一个新的具体方法,那么所有的子类型一下子就都得到得到了这个新的具体方法,而接口做不到这一点.如果向一个接口加入了一个新的方法的话,所有实现这个接口的类就全部不能通过编译了,因为它们都没有实现这个新声明的方法.这显然是接口的一个缺点.

(3)一个抽象类的实现只能由这个抽象类的子类给出,也就是说,这个实现处在抽象类所定义出的继承的等级结构中,而由于一般语言都限制一个类只能从最多一个超类继承,因此将抽象作为类型定义工具的效能大打折扣。

反过来,看接口,就会发现任何一个实现了一个接口所规定的方法的类都可以具有这个接口的类型,而一个类可以实现任意多个接口.

(4)从代码重构的角度上讲,将一个单独的具体类重构成一个接口的实现是很容易的,只需要声明一个接口,并将重要的方法添加到接口声明中,然后在具体类定义语句中加上保留字以继承于该接口就行了.

而作为一个已有的具体类添加一个抽象类作为抽象类型不那么容易,因为这个具体类有可能已经有一个超类.这样一来,这个新定义的抽象类只好继续向上移动,变成这个超类的超类,如此循环,最后这个新的抽象类必定处于整个类型等级结构的最上端,从而使等级结构中的所有成员都会受到影响.

(5)接口是定义混合类型的理想工具,所为混合类型,就是在一个类的主类型之外的次要类型.一个混合类型表明一个类不仅仅具有某个主类型的行为,而且具有其他的次要行为.

(6)联合使用接口和抽象类,由于抽象类具有提供缺省实现的优点,而接口具有其他所有优点,所以联合使用两者就是一个很好的选择.

首先,声明类型的工作仍然接口承担的,但是同时给出的还有一个抽象类,为这个接口给出一个缺省实现.其他同属于这个抽象类型的具体类可以选择实现这个接口,也可以选择继承自这个抽象类.如果一个具体类直接实现这个接口的话,它就必须自行实现所有的接口;相反,如果它继承自抽象类的话,它可以省去一些不必要的的方法,因为它可以从抽象类中自动得到这些方法的缺省实现;如果需要向接口加入一个新的方法的话,那么只要同时向这个抽象类加入这个方法的一个具体实现就可以了,因为所有继承自这个抽象类的子类都会从这个抽象类得到这个具体方法.这其实就是缺省适配器模式(Defaule Adapter).