依赖倒置原则

2015/6/30 posted in  设计模式

什么是依赖倒置原则

要给依赖倒置原则下个定义,三句话足以:

  • 高层模块不应该依赖低层模块,两者都应该依赖其抽象
  • 抽象不应该依赖细节
  • 细节应该依赖抽象

刚读这三句话的时候,我也有些恍惚,体会了好久,又想了好久才有体会。最近刚刚完成一个项目的Demo版,体会了下这个原则,然后想想不依照这个原则的后果,重写的心都有。 先解释一下这几个名词:高层模块和低层模块比较好区分,每个逻辑的实现都是由更小的原子逻辑组成,不可分割的原子逻辑就是低层模块,而原子逻辑的再组装就是高层模块。 至于抽象和细节,在Java中,可以理解为抽象就是指接口或抽象类,两者都是不能直接被实例化。而细节就是实现类,实现接口或继承抽象类而产生的类就是细节。细节是可以直接被实例化,也就是可以加上一个new之后便可以产生一个对象。 依赖倒置原则在Java语言中的表现就是:

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

可以精简的定义为:面向接口编程。 采用依赖导致原则可以减少类间的耦合性,提高系统的稳定性,降低并行开发引起的风险,提高代码的可读性和可维护性。 举一个栗子来具体说明下。

提高系统的稳定性

如果我们有一个司机类和一个奔驰类,来实现让司机开奔驰,我们只需要实现一个奔驰类:

package net.ihypo.dip;

public class Benz {
    public void run(){
        System.out.println("奔驰跑2333");
    }
}

并让奔驰可以跑,就可以了。 然后我们再实现这个司机类:

package net.ihypo.dip;

public class Driver {
    public void driver(Benz benz){
        benz.run();
    }
}

在使用的时候,把奔驰类对象传给司机类对象,这样就可以了:

package net.ihypo.dip;

public class Client {
    public static void main(String[] args) {
        Driver blf2 = new Driver();
        Benz benz = new Benz();
        blf2.driver(benz);
    }
}

功能实现了,就可以兴匆匆去上线发布了,其实明显有问题嘛,其实我刚刚完成Dome版的这个项目就是这么办的,忘了仔细设计未来的功能扩充。 问题来了,如果这个司机土豪又买了一个宝马,换句话说,项目需求改了,功能要求更高了,怎么办,最直接的方法就是重载Driver中的driver函数,当然,这只是因为增加了一个宝马,如果在来几辆车呢? 所谓“危难时刻见真情”,移植到技术上就是“变更才显真功夫”,业务需求变更永无休止,技术前进就永无止境,在发生变更时才能发觉我们的设计或程序是否耦合。明显,上面的程序耦合性过高导致要修改已经完成的代码。 者明显导致系统的可维护性降低,可读性降低,稳定性降低。什么是稳定性?固话的、健壮的才是稳定的,这里只是增加一个车类就需要修改司机类,这不是稳定性,只是易变性。被依赖者的变更竟然让依赖者来承担修改的成本,这样的依赖关系谁肯承担! 设计是否具备稳定性,只要适当地“松松土”,观察“设计的蓝图”是否还可以茁壮地成长就可以得出结论,稳定性较高的设计,在周围环境频繁变化的时候,依然可以做到“我自岿然不动”。 那么这个程序应该怎么改进?这就用到了依赖倒置原则的第一条高层模块不应该依赖低层模块,两者都应该依赖其抽象。 所以应该先有一个司机的抽象:

package net.ihypo.dip;

public interface IDriver {
    void driver(ICar car);
}

以及一个汽车的抽象:

package net.ihypo.dip;

public interface ICar {
    void run();
}

这样,让抽象之间发生关系,让高层模块不一来低层模块。几口只是一个抽象概念,是对一类事物的最抽象描述,具体的实现代码由相应的实现类来完成。 然后分别实现IDriver和ICar来具体化司机类和汽车类,并实现功能。

package net.ihypo.dip;

public class Benz implements ICar{
    @Override
    public void run(){
        System.out.println("奔驰跑2333");
    }
}


package net.ihypo.dip;

public class Driver implements IDriver{
    @Override
    public void driver(ICar car) {
        // TODO Auto-generated method stub
        car.run();
    }
}

Driver只是传入了ICar接口,至于是哪个具体的类型,需要在高层模块中声明:

package net.ihypo.dip;

public class Client {
    public static void main(String[] args) {
        Driver blf2 = new Driver();
        ICar benz = new Benz();
        blf2.driver(benz);
    }
}

这样,即使需求改变了,也不需要修改即成的部分,提高系统的稳定性。

降低并行开发引起的风险

如果在开始的那个栗子中,如果甲乙两个人同时开发这个程序,甲负责司机类,乙负责汽车类,那么如果甲开发的速度比较快,那么如果甲开发完之后能不能进行测试阶段呢?并不能,因为乙还不没有开发完,并没有数据参数让你来测试。 但是用了接口就不一样了,接口在一开始就是定好的,就像一个契约,接口所定的东西就是未来参数多实现的东西,完全不用在乎乙开发的如何,因为他所写的也是符合这个契约的。 有一个JMock工具,其最基本的功能就是根据抽象虚拟出一个对象进行测试,测试类代码如下:

public class DriverTest extends TestCase{
    Mockery context = new JUnit4Mockery();

    @Test
    public void testDriver(){
        final ICar car = context.mock(ICar.class);
        IDriver driver = new Driver();

        context.checking(new Expectations(){{
            oneOf(car).run();
        }});
        driver.driver(car);
    }
}

我们只需要一个ICar接口就可以独立的对Driver类进行单元测试。 两个相互依赖的对象可以分别进行开发,孤立地进行单元测试,进而保证并行开发的效率和质量,TDD开发的精髓不就如此么,车市驱动开发,先写好单元测试类,然后再写实现类。 抽象是对实现的约束,对依赖者而言,也是一种契约,不仅仅约束自己,还同时约束自己与外部的关系,其目的是保证所有的细节不脱离不了这个圈圈,始终让你的对象做到“言必信,行必果”。

最佳实践

依赖倒置原则的本质就是通过抽象使各个类或模块的实现彼此独立,不相互影响,实现模块间的松耦合。怎么在项目中使用这个规则呢?只要遵循下面的几个规则就可以:

  • 每个类尽量都有接口或抽象类,或者抽象类和接口两者都具备
  • 变量的表面类型尽量是接口或者抽象类:当然只是尽量,比如如果使用clone方法就必须要使用实现类,这是JDK的规范
  • 任何类都不应该从具体类派生:如果一个项目处于开发状态确实不应该出现从具体类派生出子类的情况,但这不是绝对的,因为人都是会犯错误的,有时候设计缺陷是在所难免的,因此只要不超过两层的继承都是可以忍受的。
  • 尽量不要覆写基类方法
  • 结合里氏替换原则

依赖倒置原则的优点在小型项目中很难体现出来,例如小于10人月的项目,使用简单的SSH框架,基本上不话费力气就可以完成,是否采用依赖倒置原则影像不大。 但是在一个中大型项目中,采用依赖倒置原则有非常多的有点,特别是规避一些非技术因素引起的问题,通过采用接口等抽象对实现类进行约束可以减少需求变化引起的工作量剧增的情况。 依赖倒置原则是6个设计原则中最难实现的原则,它是实现开闭原则的重要途径,依赖倒置原则没有实现,就别想实现对拓展开发,对修改关闭。 但是,依赖倒置原则不是万能的,现实世界中的确存在必须依赖细节的事物,比如法律,就必须依赖细节的定义。 我们在实际的项目中使用依赖倒置原则时需要审时度势,不要抓住一个原则不放,每个原则的邮电都是有限度的,并不是放之四海皆准的真理,所以别为了遵循一个原则而放弃了一个项目的终极目标:投产上线和盈利!