设计模式-里氏替换原则(2)

设计原则

1 单一职责原则
2 里氏替换原则
3 依赖倒置原则
4 接口隔离原则
5 迪米特法则
6 开闭原则

类继承优点

代码共享, 减少创建类的工作量, 每个子类都拥有父类的方法和属性;
提高代码的重用性;
子类可以形似父类, 但又异于父类, “龙生龙, 凤生凤, 老鼠生来会打洞”是说子拥有父的“种”, “世界上没有两片完全相同的叶子”是指明子与父的不同;
提高代码的可扩展性, 实现父类的方法就可以“为所欲为”了, 君不见很多开源框架的扩展接口都是通过继承父类来完成的;
提高产品或项目的开放性

类继承缺点

继承是侵入性的。 只要继承, 就必须拥有父类的所有属性和方法;
降低代码的灵活性。 子类必须拥有父类的属性和方法, 让子类自由的世界中多了些约束;
增强了耦合性。 当父类的常量、 变量和方法被修改时, 需要考虑子类的修改, 而且在缺乏规范的环境下, 这种修改可能带来非常糟糕的结果——大段的代码需要重构。

Java使用extends关键字来实现继承, 它采用了单一继承的规则, C++则采用了多重继承的规则, 一个子类可以继承多个父类。 从整体上来看, 利大于弊, 怎么才能让“利”的因素发挥最大的作用, 同时减少“弊”带来的麻烦呢? 解决方案是引入里氏替换原则(LiskovSubstitution Principle, LSP) 。

什么是里氏替换原则

所有引用基类的地方必须能透明地使用其子类的对象。

通俗点讲, 只要父类能出现的地方子类就可以出现, 而且替换为子类也不会产生任何错误或异常, 使用者可能根本就不需要知道是父类还是子类。 但是, 
反过来就不行了, 有子类出现的地方, 父类未必就能适应。

具体原则

 

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

 

我们在做系统设计时, 经常会定义一个接口或抽象类, 然后编码实现, 调用类则直接传入接口或抽象类, 其实这里已经使用了里氏替换原则。 我们举个例子来说明这个原则,我们来描述一下里面用到的枪 :

枪的主要职责是射击, 如何射击在各个具体的子类中定义, 手枪是单发射程比较近, 步枪威力大射程远, 机枪用于扫射。 在士兵类中定义了一个方法killEnemy, 使用枪来杀敌人,具体使用什么枪来杀敌人, 调用的时候才知道。

Soldier solier=new Soldier();
//给士兵手枪
solier.setGun(new Handgun());
//给士兵步枪
solier.setGun(new Rifle());
//给士兵机枪
solier.setGun(new MachineGun);
solier.killEnemy()

注意 在类中调用其他类时务必要使用父类或接口, 如果不能使用父类或接口, 则说明类的设计已经违背了LSP原则。如果子类不能完整地实现父类的方法, 或者父类的某些方法在子类中已经发生“畸变”, 则建议断开父子继承关系, 采用依赖、 聚集、 组合等关系代替继承 。
 

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

子类当然可以有自己的行为和外观了, 也就是方法和属性, 那这里为什么要再提呢? 是因为里氏替换原则可以正着用, 但是不能反过来用。 在子类出现的地方, 父类未必就可以胜任。 

//正确写法
Rifle parent=new AK47();
//错误写法
AUG child=new Rifle();

错误的写法显然是不行的, 会在运行期抛出java.lang.ClassCastException异常, 这也是大家经常说的向下转型(downcast) 是不安全的, 从里氏替换原则来看, 就是有子类出现的地方父类未必就可以出现。

3.覆盖或实现父类的方法时输入参数可以被放大
根据里氏替换原则, 父类出现的地方子类就可以出现。我们看个例子:

public class Parent {

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


    public Collection doOtherSomething(Map map){
        System.out.println("父类被执行...");
        return map.values();
    }
}
public class Child extends Parent {

    /**
     * 此处方法名称虽然和父类一样,但是参数不一样,不是重写
     * 是重载
     * 重载:不同的参数类型或参数个数,而写多个函数
     * @param map
     * @return
     */
    public Collection doSomething(Map map){
        System.out.println("子类被执行...");
        return map.values();
    }

    public Collection doOtherSomething(Map map) {
        System.out.println("子类被执行...");
        return map.values();
    }

}

测试1-父类输入参数小于子类

public static void test1(){
        HashMap map= new HashMap();
        Parent p=new Parent();
        p.doSomething(map);

        Child c=new Child();
        c.doSomething(map);

        p=c;
        p.doSomething(new HashMap());
    }

结果1

父类被执行...
父类被执行...
父类被执行...

测试2-父类输入参数大于子类

public static void test2(){
        HashMap map= new HashMap();
        Parent p=new Parent();
        p.doOtherSomething(map);

        Child c=new Child();
        c.doOtherSomething(map);

        p=c;
        p.doOtherSomething(map);
    }

结果2

父类被执行...
子类被执行...
子类被执行...

通过测试1发现,如果子类的输入参数被放大,那么子类的行为逻辑和父类行为逻辑一致,满足父类出现的地方子类就可以出现。

通过测试2发现,子类在没有覆写父类的方法的前提下, 子类方法被执行了, 这会引起业务逻辑混乱, 因为在实际应用中父类一般都是抽象类, 子类是实现类, 你传递一个这样的实现类就会“歪曲”了父类的意图, 引起一堆意想不到的业务逻辑混乱, 所以子类中方法的前置条件必须与超类中被覆写的方法的前置条件相同或者更宽松。

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

 父类新增方法

 public Parent other(Child p) {
        System.out.println("父类被执行...");
        return p;
    }

子类新增方法仅有2种方式

方式1: 重载

public Parent other(Parent p) {
        System.out.println("子类被执行...");
        return p;
    }

结果1:

   Parent p = new Parent();
        Child c = new Child();
        p.other(c);
        c.other(c);
父类被执行...
父类被执行...

方式2:重写

 public Child other(Child p) {
        System.out.println("子类被执行...");
        return p;
    }

结果2:

父类被执行...
子类被执行...

采用里氏替换原则的目的就是增强程序的健壮性, 版本升级时也可以保持非常好的兼容性。 即使增加子类, 原有的子类还可以继续运行。 在实际项目中, 每个子类对应不同的业务含义, 使用父类作为参数, 传递不同的子类完成不同的业务逻辑, 非常完美!

总结

在项目中, 采用里氏替换原则时, 尽量避免子类的“个性”, 一旦子类有“个性”, 这个子类和父类之间的关系就很难调和了, 把子类当做父类使用, 子类的“个性”被抹杀——委屈了点; 把子类单独作为一个业务来使用, 则会让代码间的耦合关系变得扑朔迷离——缺乏类替换的标准。

展开阅读全文

没有更多推荐了,返回首页