对面向对象设计OOD七大原则的理解

OOD原则的个人看法

       面向对象设计OOD有很多原则可以遵守,但是最终目的可以用其中一个原则概括,即开闭原则。在功能迭代频繁的项目,怎么能不牵一发而动全身显得特别重要,如果做不到这点,那么可能一个小小的功能,就能影响核心功能,随之而来的就是全量测试,心惊胆战的发布上线。另外我们经常能听到、看到某些代码违反了xxx原则,没有做到高内聚、松耦合,这些xxx原则到底是什么呢,为什么要遵守这些原则呢?请看下面的详细介绍。


开闭原则

       一句话概括“对修改关闭,对扩展开放”,这句话不是说不能修改原有代码,不能修改应该是有个这样的前提:为项目添加新的功能或逻辑时,这里的功能和逻辑能在之前的代码里找到类似的,只不过具体实现细节不一样,那么在OOD阶段就要预见这种情况的发生。

举个栗子:

       一个程序猿上班去公司,一开始工资低只能靠人肉腿走到公司,用代码表示:

交通工具是两条腿:

/**
 * 人肉腿
 *
 */
public class Leg {
    
    /**
     * 移动
     */
    public void move(){
        
        System.out.println("两条腿一前一后慢慢走");
    }

}

程序猿上班去公司,只能用自己的腿走:

public class Programmer {
    
    /**
     * 去公司
     */
    public void gotoCompany(){
        
        // 用腿走
        (new Leg()).move();
    }
    
    public static void main(String[] args) {
        
        Programmer p = new Programmer();
        p.gotoCompany();
    }
    
}

       就这样过了好几年,日复一日,程序猿都是这样每天走着上班,但是有一天他工作的公司上市了,程序猿获得一笔股份,套现了不少钱,决定准备把交通工具一下子过渡到小汽车,用代码表示:

添加新的交通工具-汽车

/**
 * 汽车
 */
public class Car {
    
    
    /**
     * 移动
     */
    public void move(){
        
        System.out.println("油门一踩嗖嗖的跑");
    }

}

程序改开汽车去公司:

public class Programmer {
    
    /**
     * 去公司
     */
    public void gotoCompany(){
        
        // 放弃人肉腿, 整天坐汽车
        // (new Leg()).move();
        (new Car()).move();
    }
    
    public static void main(String[] args) {
        
        Programmer p = new Programmer();
        p.gotoCompany();
    }
    
}

       从上面的代码可以看到,添加了一种新的交通工具,程序猿上班去公司的过程都要被改动一次,可能你认为这点改动不算什么,但现实环境往往要复杂的多,比如车辆限行不能开车,用户随心情选择交通方式等等,如果按照上面的写法,可能每天都要改动一次。那这段代码到底要怎么写才能符合开篇提到的开闭原则呢?在OOD设计的时候其实不是靠感觉或猜测就能完成的,而是可以基于前人总结的一些经验,优雅的设计代码框架。下面可以从剩下的6个原则中,寻找我们想要的东西。


单一职责原则

     “一个类只负责一个功能领域中的相应职责”,或者可以只这样说“就一个类而言,应该只有一个引起它变化的原因”。

       上面这段的话从字面意思上也很好理解,一个类应该只肩负着一个责任,这个责任可能是好几个功能。比如程序猿他的责任就是早上去公司,然后工作,晚上下班回家。如果把汽车的移动过程也让程序猿负责,那么程序猿可能就累死了,代码如下:

public class Programmer {
    
    /**
     * 去公司
     */
    public void gotoCompany(){
        
        // 程序猿自己负责汽车是怎么移动的
        this.move();
    }
    
    public static void main(String[] args) {
        
        Programmer p = new Programmer();
        p.gotoCompany();
    }
    
    /**
     * 汽车移动
     */
    public void move(){
        
        System.out.println("油门一踩嗖嗖的跑");
    }
    
}

单一职责原则其实是在提醒我们进行抽象时的原则,如果是上面的代码,那么抽象出来的程序猿是不对的,汽车移动过程的改变也会导致程序猿的改变,就不符合单一职责的定义了,可以进一步把汽车移动的过程抽象出来,就像介绍开闭原则写的例子。所以我们在抽象的时候一定不要忘了是否满足单一职责原则。


依赖倒置原则

      “抽象不应该依赖于细节,细节应该依赖于抽象”。这句话啥意思呢?在实际项目中,往往拿到一个需求的时候,大部分开发人员第一时间都是想着怎么实现具体功能,这样做的结果往往是很快就能完成需求开发,但是当需求迭代的时候,就要花费大量精力改动原来的代码,逻辑越改越复杂,这种情况相信很多人都遇到过。

       怎么才能避免上面的情况呢?依赖倒置原则告诉我们:当接到开发需求的时候,不要着急想着具体功能(细节)是怎么实现的,而是将需求抽象出特征(java成员变量)和行为(java方法),以后类似的需求都是满足这些特征和行为的,这就是“抽象不应该依赖细节”。将特征和行为抽象出来之后,细节就是具体怎么实现这些特征和行为,即“细节应该依赖于抽象”。用一句话概括“要针对接口(或抽象类)编程,而不是针对实现编程”。

       还是用上面程序猿上班的例子,之前我们都是关注程序猿上班的具体过程,比如人肉腿是怎么移动的,汽车是怎么移动的,而忽略了人肉腿和汽车的特征,他们都可以看成交通工具,代码如下:

/**
 * 交通工具
 *
 */
public abstract class Vehicle {
    
    /**
     * 移动
     */
    public abstract void move();

}

人肉腿和汽车都是一种交通工具,只需要实现交通工具的具体移动过程,代码如下:

/**
 * 人肉腿
 *
 */
public class Leg extends Vehicle{
    
    /**
     * 移动
     */
    public void move(){
        
        System.out.println("两条腿一前一后慢慢走");
    }

}
/**
 * 汽车
 */
public class Car extends Vehicle{
    
    
    /**
     * 移动
     */
    public void move(){
        
        System.out.println("油门一踩嗖嗖的跑");
    }

}

对于程序猿来说,他不再关心是用人肉腿或乘坐汽车去公司,只要有个交通工具就行了,代码如下:

/**
 * 程序猿
 *
 */
public class Programmer {
    
    /**
     * 去公司
     * @param vehicle 抽象的交通工具
     */
    public void gotoCompany(Vehicle vehicle){
        
        // 执行具体的移动过程
        vehicle.move();
    }
    
    public static void main(String[] args) {
        
        Programmer p = new Programmer();
        
        // 根据用户实际情况选择人肉腿或汽车
        Vehicle vehicle = new Leg();  
        // Vehicle vehicle = new Car();
        
        p.gotoCompany(vehicle);
    }
    
}
      到这里,程序猿去公司的过程已经和人肉腿和汽车完全解耦了,只依赖抽象的交通工具,以后需要添加新的交通工具或改变交通工具时,只需传入具体的交通工具,程序猿去公司的过程(即方法:gotoCompany)并不会改变。



里氏替换原则

    “所有引用基类的地方必须能透明地使用其子类的对象”,里氏替换原则的定义是很好理解的,就是用子类对象替换基类对象,程序都是可以正常运行的,上面介绍依赖倒置原则中的Vehicle就是基类,Leg和Car就是子类,在程序猿的去公司的方法gotoCompany(Vehicle vehicle)中,可以用Leg和Car的对象传入。难道这就结束了?里氏替换原则这么简单吗?

里氏替换原则在告诉我们上面基本定义外,还对子类继承父类做了以下约束,这才是里氏替换原则的核心:

1)子类可以实现父类的抽象方法,但是不能覆盖父类的非抽象方法

2)子类中可以增加自己特有的方法

3)当子类覆盖或实现父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松

4)当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。

       这4条约束其实都是在强调一个事,就是子类可以扩展自己的方法,但是不要覆盖父类已经实现的方法。为啥会这样要求呢,大部分人平时继承父类都是没有注意到这点的,如果有需要,会毫不犹豫的重写父类已实现的方法。其实这些约束牵扯到继承的本质,当使用继承的时候,我们都是用is-a判断能否继承一个父类,比如前面的例子中,抽象的交通工具Vehicle是父类,Leg和Car是交通工具,满足is-a,所以Leg和Car继承Vehicle,如果为Vehicle加上载客的行为呢,代码如下:

/**
 * 交通工具
 *
 */
public abstract class Vehicle {
    
    /**
     * 移动
     */
    public abstract void move();
    
    /**
     * 载客
     */
    public void carryPassenger(){
        
        System.out.println("每位乘客交1元钱");
    }

}

Leg是没有载客功能的,如果继续继承Vehicle,那么必须复写载客方法carryPassenger,如下:

/**
 * 人肉腿
 *
 */
public class Leg extends Vehicle{
    
    /**
     * 移动
     */
    public void move(){
        
        System.out.println("两条腿一前一后慢慢走");
    }
    
    @Override
    public void carryPassenger() {
        
        System.out.println("无载客功能");
        
    }

}

     上面Leg复写了carryPassenger,告诉其他调用方它无载客功能。如果一个调用方引用了Vehicle,默认是有载客行为,但将Leg对象替换Vehicle传入的时候,却告知没有载客行为,不符合调用方的预期,可能对系统产生很大影响,甚至需要修改代码,这又不符合开闭原则。难道is-a判断是否有继承关系不对的?其实是我们自己搞错了is-a的含义,在现实生活中,Leg是Vehicle,但是在面向对象设计中,Leg需要满足Vehicle所有特性才满足is-a,因为Vehicle有载客功能,而Leg没有该特性,所以Leg不是Vehicle。如果大家还有疑问,面向对象中有个经典问题:正方形是不是长方形,可以参考下这个博客,点这里:http://blog.csdn.net/javayuan/article/details/1191751


接口隔离原则

     “使用多个专门的接口,而不使用单一的总接口,即客户端不应该依赖那些它不需要的接口”。这里定义的接口不仅仅指java中的interface,而是泛指抽象,在java中包括父类和接口。对于父类而言,如果子类继承父类,就等于继承了父类所有特性(先不考虑权限修饰符),子类如果不需要某些特性只能重写,这就引发里氏替换原则的约束问题,违反is-a规则。对于接口来说,虽然不存在这个问题,如果将所有方法统一定义在个别几个接口中,而不是按照功能或特征划分,那么在接口中新增一个方法,所有实现该接口的类都要实现该方法,就算是空实现,但是也要修改代码,这就又违反了开闭原则。

       接口隔离原则还是比较容易理解的,在实际项目中,只要接口和基类抽象的合理,并在后面功能迭代中,严格遵守接口功能划分,通过接口实际的意义选择是添加特性(方法或成员变量)还是新增接口。


合成复用原则

“尽量使用对象组合,而不是继承来达到复用的目的”。在java中复用代码的方式有继承、依赖、关联(组合和聚合),合成复用指的就是组合/聚合复用,这里主要针对继承复用和组合/聚合复用的对比,总结下使用组合/聚合复用的优点。

       先简单提下依赖关系,依赖关系表示一个类依赖于另一个类的定义,在Java语言中体现为局域变量、方法的形参,或者对静态,在上面的例子中,程序猿类中的方法gotoCompany(Vehicle vehicle)的参数是Vehicle ,所以说Programmer依赖于Vehicle,通过传递Vehicle对象,达到调用Vehicle方法的目的。虽然依赖关系也是复用代码的一种方式,但是这种关系只是临时的,类与类没有什么特殊关系,使用比较简单也很好理解,可以说这种调用方式和面向对象没有什么关系,在面向过程的编程中,用的基本上都是这种方式,所以这里就不详细介绍了。

       组合/聚合在项目代码里也是经常用到的,只是很多人不知道这个名词,在java中如果一个类是另外一个类的成员变量,它们就构成了组合关系或者聚合关系,代码如下:

/**
 * 程序猿
 *
 */
public class Programmer {
    
    // 以成员变量的方式实现组合|聚合关系
    private Vehicle vehicle;
    
    /**
     * 传入交通工具
     * @param vehicle
     */
    public void setVehicle(Vehicle vehicle) {
        this.vehicle = vehicle;
    }

    /**
     * 去公司
     * @param vehicle 交通工具
     */
    public void gotoCompany(){
        
        // 执行具体的移动过程
        this.vehicle.move();
    }
    
    public static void main(String[] args) {
        
        Programmer p = new Programmer();
        
        // 根据用户实际情况选择人肉腿或汽车
        Vehicle vehicle = new Leg();  
        // Vehicle vehicle = new Car();
        p.setVehicle(vehicle);
        p.gotoCompany();
    }
    
}

       在以前的例子中,程序猿的要去公司,都是通过传参的方式接收交通工具,而上面的代码里,交通工具Vehicle变成了程序猿的成员变量,通过setter传入具体的交通工具,gotoCompany方法中可以直接使用成员变量vehicle完成去公司的过程。在这个例子中,程序猿Programmer和交通工具Vehicle的关系为has-a,组合/聚合的关系也是has-a,组合和聚合的区别在于如果类A has-a 类B,如果类A对象销毁了,类B对象继续存在,即生命周期不一样,则为聚合关系;另外一种情况,如果类A对象销毁了,类B对象也销毁,即生命周期一样,则为组合关系,组合关系可称为强的聚合关闭,一般用contains-a表示。

       既然合成复用原则建议用组合/聚合关系代替继承实现代码复用,那么这样做有什么好处呢?通过继承来进行复用的主要问题在于继承复用会破坏系统的封装性,因为继承会将基类的实现细节暴露给子类,由于基类的内部细节通常对子类来说是可见的,所以这种复用又称“白箱”复用,如果基类发生改变,那么子类的实现也不得不发生改变;从基类继承而来的实现是静态的,不可能在运行时发生改变,没有足够的灵活性;而且继承只能在有限的环境中使用(如类没有声明为不能被继承)。所以慎用继承,但是也不是绝对的,大部分情况下可以用is-a和has-a选择是用继承还是组合/聚合。


迪米特法则

      “一个软件实体应当尽可能少地与其他实体发生相互作用”,这篇博客很好得解释了迪米特法则法则,还有很形象的例子,我就费劲描述了,博客链接点这里:https://www.jianshu.com/p/14589fb6978e


总结

       上面介绍的这些法则可能从字面意思和例子中很好理解,但是实际开发中很难做到,所以更需要在实际项目多用这些法则,因为要实现开闭原则,往往是多个原则的结合,用的多了才能明白遵守这些法则的含义和好处。


你可能感兴趣的:(面向对象设计)