依赖倒置原则

什么是依赖倒置原则

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

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

刚读这三句话的时候,我也有些恍惚,体会了好久,又想了好久才有体会。最近刚刚完成一个项目的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开发的精髓不就如此么,车市驱动开发,先写好单元测试类,然后再写实现类。 抽象是对实现的约束,对依赖者而言,也是一种契约,不仅仅约束自己,还同时约束自己与外部的关系,其目的是保证所有的细节不脱离不了这个圈圈,始终让你的对象做到“言必信,行必果”。

最佳实践

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

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

2015/06/30 10:08 上午 posted in  设计模式

里氏替换原则

在面向对象的语言中,无论是C++还是Java,继承是常见的、优秀的语言机制之一。继承的优秀,带来了很多好处,比如代码重用,提高代码拓展性等等。在学C++的时候,刚刚接触继承,便被它的神奇震惊了。既然可以实现父类全部的属性和方法,那么只需要找到几个类的共性岂不是可以少写很多代码? 但是在实际用起来却不是这样,继承中很多不方便或者很多不得不接受的东西,比如如果要继承,你不得不接受父类所有的属性和方法,哪怕有相当一部分是用不到的。还有,如果本来在接受一些用不到的属性和方法的子类突然变了需求,那么如果重构的话,将花费更大的力气。 Java采用了单继承的方式,相对C++比,更少了一些灵活,甚至让人感觉弱化了继承,但我也和大多数人一样认为,利大于弊。但是只是靠这些并不能减少更多的弊端,怎么让利起到更多的作用,而减少弊端?这就是引入里氏替换原则(LSP)的原因。 里氏替换原则有两种定义:

  • 第一种定义:如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1都带换为o2时,程序P的行为没有发生变化,那么类型S就是类型T的子类型。
  • 第二种定义:所有引用基类的地方必须能透明地使用其子类对象。

其实说的通俗一点,就是只要父类出现的地方子类一定就可以出现,而且替换为子类也不会产生任何错误或异常。 为了让良好的继承定义了一个规范,里氏替换原则一句简单的定义包含了4层含义。

1.子类必须完全实现父类的方法

如果在做系统设计的时候,定义了一个接口或者抽象类,然后编码实现,调用类则直接传入接口或抽象类,其实这里已经使用了里氏替换原则。 如果我们定义了一个枪的抽象类:

abstract class AbstractGun{
    public abstract void shoot();
}

再定义一个士兵类,和他使用枪支的方法:

class Soldier{
    private AbstractGun gun;
    //获得一把枪
    public void setGun(AbstractGun gun){
        this.gun = gun;
    }
    public void killEnemy(){
        System.out.println("士兵开始杀人...");
        gun.shoot();
    }
}

如果我们需要多种枪支,只不过是需要分别继承于枪支抽象类:

class Handgun extends AbstractGun{

    @Override
    public void shoot() {
        System.out.println("手枪射击...");
    }
}

class Rifle extends AbstractGun{

    @Override
    public void shoot() {
        System.out.println("步枪射击...");
    }
}

我们在使用枪*支的时候不需要做什么区分,直接声明出来给士兵就够了:

public void Test(){
    Soldier sanmaoSoldier = new Soldier();
    sanmaoSoldier.setGun(new Handgun());
    sanmaoSoldier.killEnemy();

    sanmaoSoldier.setGun(new Rifle());
    sanmaoSoldier.killEnemy();
}

在这样的程序中,哪怕程序中枪支或士兵的需求发生变化,也不过只是需要修改相应的部分就可以。 在类中调用其他类时务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已经违背了LSP原则。 如果在增加一个玩具枪会怎么样?

class Toygun extends AbstractGun{

    @Override
    public void shoot() {
        //玩具枪不能射击,这个方法无法实现
    }
}

那么把玩具枪传给士兵会怎么样?因为玩具枪无法射击,那么这个类并没有达到预期的效果,也就是说发生了子类替换父类之后出现错误的情况。 应该怎么解决这个问题,有两种解决方式:

  1. 在士兵类中增加instanceof判断,如果是玩具枪,就不用来kill。
  2. 让玩具枪与抽象枪类脱离关系,建立一个独立的父类,为了实现代码复用,可以与抽象枪类建立关联委托关系。

第一种方法明显的不好,首先他是修改了和枪类无关的士兵了,让需要重写的范围增大了,还有,最要紧的就是如果增加一个需求就要修改一次的花,如果再加几次需求,任务量将比较大。 虽然按道理来说玩具枪继承抽象枪是完全没有问题的,但是要根据实际情况来,如果继承了,就出现了子类不能完整的实现父类的业务的情况。 如果子类不能完整的实现父类的方法,或者父类的某些方法在子类中已经发生“畸变”。则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。

2.子类可以有自己的个性

子类当然可以有自己独特的属性和方法。但是,里氏替换原则可以正着用,但不能反过来,也就是说,有父类的地方一定可以用子类代替,但有子类的地方不一定可以能用父类替换。 比如在步枪类下有两种枪,一个是AK47,一个是狙击步枪AWP:

class AK47 extends Rifle{

    public void shoot() {
        System.out.println("AK射击...");
    }
}

class AWP extends Rifle{

    public void zoomOut(){
        System.out.println("通过望远镜观察敌人...");
    }

    public void shoot() {
        System.out.println("AWP射击...");
    }
}

我们还可以定义一个狙击手类:

class Snipper{
    private void killEnemy(AWP awp){
        awp.zoomOut();
        awp.shoot();
    }
}

在这个类下,狙击手可以使用狙击枪,进行观察和射击,而之前定义的士兵类也可以使用狙击枪射击,但不能观察。这就说明有父类的地方完全可以用子类替换,当然,士兵类使用狙击枪是不会观察的,但依然可以保证枪能用。 但是如果把一个步枪传递给狙击手会怎么样?

public void Test(){
    Snipper sanmaoSnipper = new Snipper();
    sanmaoSnipper.setGun((AWP)new Rifle());
    sanmaoSnipper.killEnemy();
}

因为步枪没有狙击镜,不能观察,所以在向下转型是不安全的。从里氏替换原则看,就是有子类出现的地方父类未必就可以出现。

3.覆盖或实现父类的方法时输入参数可以被放大

如果我们需要调用子类时是使用父类的方法,但是还要有子类独特的方法存在,也就是说子类不能覆写父类的方法,因为在一些情况下需要调用父类的方法,但是子类还需要有自己的方法,因为在某些情况下子类还需要调用自己的方法。 其实很简单也不过就是重载么,重载判断的特征值是参数,所以在子类重载父类的方法的时候需要把参数的范围放大,举个例子,有这么一个父类:

class Father{
    public Collection soSomething(HashMap map){
        System.out.println("父类被执行...");
        return map.values();
    }
}

然后有一个子类继承了他:

class Son extends Father{
    public Collection doSomething(Map map){
        System.out.println("子类被执行...");
        return map.values();
    }
}

当我们执行父类的方法的时候,

public void text(){
    Father father = new Father();
    HashMap map = new HashMap<>();
    father.soSomething(map);
}

可以很明确,就是父类的方法得到了执行,而且,如果换做子类执行:

public void text(){
    Son son = new Son();
    HashMap map = new HashMap<>();
    son.soSomething(map);
}

因为重载的优先级问题,子类还是会执行父类的方法,而如果子类想执行自己的方法的时候,只需要吧HashMap换做Map。 如果没有按照放大的规则来,

class Father{
    public Collection soSomething(hMap map){
        System.out.println("父类被执行...");
        return map.values();
    }
}
class Son extends Father{
    public Collection doSomething(HashMap map){
        System.out.println("子类被执行...");
        return map.values();
    }
}

那么在把HashMap传递给父类的时候执行的父类的方法,而传递给子类的时候执行的却是子类的方法。而我们的本意却是执行父类继承来的方法。所以子类中方法的前置条件必须与超类中被覆写的方法的前置条件相同或更宽松

4.覆写或实现父类的方法时输出结果可以被缩小

第三点讲的主要是重载,而已经明确子类是要覆写父类地方方法,假设父类的一个方法返回值是T,子类的同名方法返回值是S,父类和子类的同名方法的输入参数应该是相同的,两个方法的范围之S(子类)小于等于T。这是对覆写的要求,是重中之重。这样的目的是为了保证有父类的地方子类一定适用。 采用里氏替换原则的目的就是增强程序的健壮性,版本升级时也可以保持非常好的兼容性,即使增加子类,原有的子类还可以继续运行。在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑。 在项目中,应该尽量避免子类的“个性”,一旦子类有“个性”,这个子类和父类之间的关系就很难调和了,把子类当作父类使用,子类的“个性”被抹杀;把子类单独作为一个业务使用,则会让代码间的耦合关系变的扑朔迷离(缺乏类替换的标准)。

2015/05/21 10:07 上午 posted in  设计模式

单一职责原则

写出符合规范的代码能够快速、直观的解决实际问题,但写出优美的代码,能够具有更好的可拓展性,让你当发生需求变化的时候不需要大范围的重构。 而所谓的优美,并不是说有一个漂亮的排版,而是从项目的设计层次上更加科学。符合科学的设计原则,能让代码更加健壮、可拓展。 单一设计原则(SRP),是一个本着单一原则,对接口和对象进行设计,以达到功能分离,从而达到当需求变更时能更少的重构的目的。 在做用户、机构、角色管理这些模块的时候,使用的基本都是RBAC(Role-Based Access Control,基于角色的访问控制,通过分配和取消角色来完成用户权限的授予和取消,使动作主体(用户)与资源行为(权限)分离)。说白了,就是让业务对象(被操作者)和业务逻辑(操作方法)分离。 如果有这么一个接口:

interface UserInfo{
    void setUserID(String ID);
    String getUserID();
    void setPassword(String password);
    String getPassword();
    void setUserName(String name);
    String getUserName();
    boolean changePassword(String oldPassword);
    boolean deleteUser();
    void mapUser();
    boolean addOrg(int orgID);
    boolean addRole(int roleID);
}

我们叫它牛类,自己独占实现了很多功能,的确能减少类和接口的数量,方便管理,作为单纯实现功能的话还是不错的。但这样科学吗?显然不可取。如果无论是用户模块需求发生变化,还是对管理的规则改变,都将导致对这个接口以及实现这个接口的类重写。 这显然没有符合单一设计原则,而应该做的就是把用户信息抽取成一个业务对象(Business Object,BO),而把行为抽取成为一个业务逻辑(Business Logic,Biz)。 那么就会把上述接口拆分成两个:UserBo和UserBiz,分别负责收集和反馈用户的属性信息和负责用户的行为,完成用户信息的维护和变更。

abstract class UserInfo implements UserBiz,UserBo{

}

interface UserBo{
    void setUserID(String ID);
    String getUserID();
    void setPassword(String password);
    String getPassword();
    void setUserName(String name);
    String getUserName();
}

interface UserBiz{
    boolean changePassword(String oldPassword);
    boolean deleteUser();
    void mapUser();
    boolean addOrg(int orgID);
    boolean addRole(int roleID);
}
class IUserInfo extends UserInfo{

    ....
}

因为是面向接口的编程,因此,产生IUserInfo对象之后,可以用UserBo接口使用,也可以用UserBiz接口使用。那么,获得用户就可以直接使用UserBo接口,而管理用户,维护用户信息则使用UserBiz接口:

UserInfo user = new IUserInfo();

UserBo userBo = (UserBo)user;
//就认为他是一个纯粹的BO
userBo.setPassword("admin");

UserBiz userBiz = (UserBiz)user;
//就认为他是一个纯粹的BIZ
userBiz.deleteUser();

这样,即使当用户或用户管理模块发生变动,则只需要修改变动的模块就可以,而没有必要将使用其模块的相关模块修改。那么总结一下,如果要依照单一职责原则,应该让类有且仅有一个原因使其变更。 也就是说,如果两个职责的变化不相互影响,就不应该放在同一个接口中实现,比如:

interface Phone{
    //打电话
    void dial(String phoneNumber);
    //通话
    void chat(Object o);
    //通话完毕,挂断电话
    void hangup();
}

这样的接口是否合理,是否符合单一原则? 那么做个假设,之前手机只能打电话,现在需求变了,要求手机还可以上网,那么会不会引起这个对象以及相关类的修改呢?必须会啊,那就说明这个接口还是存在问题的。 那么应该再把这个接口拆分,拆分为协议管理和数据传送,那么一个管理连接问题,一个管理数据传送问题各司其职。那么如果要添加上网功能的话,也不过就是新实现一个数据传输接口的类。 那么使用单一职责原则的好处也很明显了:

  • 降低类的复杂性,实现什么职责都有清晰明确的定义
  • 可读性提高
  • 可维护性提高
  • 变更引起的风险更低,一个接口只对相应的实现类有影响,对其他的接口无影响。

单一原则提出一个写程序的标准,用“职责“或变化原因来衡量接口或类设计的是否优良,但是”职责“和”变化原因“都是不可度量的,因项目而异,因环境而异。 对于接口,在设计的时候一定要做到单一,但对于实现类要考虑的方面将要更多,如果对单一原则生搬硬套,则实现类的数量将会剧增,反而人为的增加了系统的复杂性。规则是死的,人是活的,这些都可以灵活使用起来。 那么对于单一职责原则:接口一定要做到单一职责,类的设计尽量做到只有一个原因引起变化(不会有多个导致其重构的原因)

2015/05/20 10:06 上午 posted in  设计模式