六大设计原则--里氏替换原则【Liskov Substitution Principle】

声明:本文内容是从网络书籍整理而来,并非原创。

定义

  • 最正宗的定义:

    If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.

    如果对每一个类型为 T1的对象 o1,都有类型为 T2 的对象o2,使得以 T1定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型。

  • 第二个定义

    functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.

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

第二个定义是最清晰明确的,通俗点讲只要父类能出现的地方我子类就可以出现,而且调用子类还不产生任何的错误或异常,调用者可能根本就不需要知道是父类还是子类。但是反过来就不成了,有子类出现的地方,父类未必就能适应。

里氏替换法则包含了四层意思:

  1. 子类必须完全的实现父类的方法。我们在做系统设计时,经常会定义一个接口或者抽象类,然后编写实现,调用类则直接传入接口或抽象类,其实这里已经使用了里氏替换法则。举个CS打枪的例子:
    六大设计原则--里氏替换原则【Liskov Substitution Principle】_第1张图片
    枪的主要职责就是射击,怎么射击就是在各个具体的子类中定义了,手枪是单发射程比较近,步枪威力大射程远,机枪用于扫射,然后在士兵类中定义了一个方法 killEnemy 杀敌人,使用枪来杀,具体使用什么枪来杀敌人,调用的时候才知道,我先看 AbstractGun类的程序:

    public abstract class AbstractGun { 
    //枪用来干什么的?射击杀戮! 
    public abstract void shoot(); 
    } 

    以下是三个具体的枪械的实现类:

    public class Handgun extends AbstractGun {  
        //手枪的特点是携带方便,射程短 
        @Override 
        public void shoot() { 
            System.out.println("手枪射击..."); 
        } 
    } 
    
    public class Rifle extends AbstractGun{ 
        //步枪的特点是射程远,威力大 
        public void shoot(){ 
            System.out.println("步枪射击..."); 
        } 
    } 
    
    public class MachineGun extends AbstractGun{ 
        public void shoot(){ 
            System.out.println("机枪扫射..."); 
        } 
    }
    
    

    再来看士兵类的源码:

    public class Soldier { 
        public void killEnemy(AbstractGun gun){ 
            System.out.println("士兵开始杀人..."); 
            gun.shoot(); 
        } 
    } 
    
    

    注意看这里的构造方法,我们要求传入进来的是一个抽象的枪,具体是手枪还是步枪需要在调用的时候传入,我们来看 Client 类:

    public class Client { 
    public static void main(String[] args) { 
        //产生三毛这个士兵 
        Soldier sanMao = new Soldier(); 
        sanMao.killEnemy(new Rifle()); 
    } 
    }

    运行结果:

    士兵开始杀人... 
    步枪射击... 

    在这个程序中,我们给三毛这个士兵一把步枪,然后就开始杀敌了,如果三毛要使用机枪当然也可以,直接把 sanMao.killEnemy(new Rifle()) 修改为 sanMao.killEnemy(new MachineGun())就可以了,Soldier根本就不用知道是哪个子类。我们在类中调用其他类是务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已经违背了LSP原则。

    我们再来想想,如果我们有一个玩具手枪,该怎么去定义呢?我们先在类图上增加一个类:
    六大设计原则--里氏替换原则【Liskov Substitution Principle】_第2张图片
    增加了一个 ToyGun 这个类,继承于 AbstractGun 抽象类。首先我们想,玩具枪是不能用来射击的,杀不死人的, 当然你要把玩具枪往让头上砸也能砸死人, 这个不算是在 shoot 方法中的功能。 我们来看ToyGun类:

    public class ToyGun extends AbstractGun { 
    //玩具枪式不能射击的,但是编译器又要求实现这个方法,怎么办?虚假一个呗! 
    @Override 
    public void shoot() { 
        //玩具枪不能射击,这个方法就不能实现了 
    } 
    }

    然后我们来看这个场景:

    public class Client {
    public static void main(String[] args) { 
        //产生三毛这个士兵 
        Soldier sanMao = new Soldier(); 
        sanMao.killEnemy(new ToyGun()); 
    } 
    }

    士兵使用玩具枪来杀人了,看运行结果:

    士兵开始杀人...

    坏了,士兵拿着玩具枪来杀人,射击不出子弹呀!如果在 CS 游戏中有这种事情发生,那你就等着被人爆头吧,然后看着自己凄凉的倒地。在这种情况下,我们已经发现业务调用类已经出现了问题,这个是业务已经不能运行了,那怎么办?有两种解决办法:

    1. 在 Soldier中增加 instanceof 的判断,如果是玩具枪,就不用来杀敌人。这个方法可以解决问题,但是你要知道在项目中,特别是产品,增加一个类,我要让所有与这个父类有关系的类都需要修改,你觉的可行吗?你要是在产品中出现这个问题,因为修正了这样一个 BUG,就要求所有与这个父类有关系的类都增加一个判断?客户非跳起来跟你干!你还想要你的客户忠诚你吗?这个方案否定。
    2. ToyGun 脱离继承,建立独立的父类,为了做到代码可以服用,可以与 AbastractGun建立关联委托关系,如下图:
      六大设计原则--里氏替换原则【Liskov Substitution Principle】_第3张图片
      比如可以在 AbstractToy中定义声音、形状都都委托给 AbstractGun,仿真枪嘛当然要让形状、声音和真实的枪都一样了,然后两个父类下的子类各自发展,互不影响。

    在 Java 的基础知识中,每位老师都会讲继承,Java 的三大特征嘛,继承、封装、多态,继承就是告诉你拥有父类的方法和属性,然后你就可以重写父类的方法。按照类的继承原则,我们上面的玩具枪继承AbstractGun也是没有问题的呀,毕竟也是枪嘛,但是到我们的具体项目中就要考虑这个问题了:子类是否完整的实现了父类的业务,否则就会出现像上面的拿枪杀敌人时却发现是把玩具枪的笑话。

  2. 子类可以有自己的个性。子类当然可以有自己的行为和外观了,也就是方法和属性,那这里为什么要再提呢?是因为里氏替换法则可以正着用,但是不能反过来用。在子类出现的地方,父类未必就可以胜任。还是以刚才的那个枪械的例子为来说明,步枪有几个比较响亮的型号比如 AK47、G3 狙击步枪等,我们来看类图:
    六大设计原则--里氏替换原则【Liskov Substitution Principle】_第4张图片
    很简单,G3继承了 Rifle 类,狙击手(Snipper)则直接使用 G3狙击步枪,我们来看一下程序:

    public class G3 extends Rifle { 
    //狙击枪都是携带一个精准的望远镜 
    public void zoomOut(){ 
        System.out.println("通过望远镜观看敌人..."); 
    } 
    
    public void shoot(){ 
        System.out.println("G3射击..."); 
    } 
    }

    然后我们再声明一个狙击手类:

    public class Snipper {
    public void killEnemy(G3 g3){ 
        //首先看看敌人的情况,别杀死敌人,自己也被人干掉 
        g3.zoomOut(); 
        //开始射击 
        g3.shoot(); 
    } 
    }

    狙击手,为什么叫 Snipper?snipe 翻译过来就是鹬,就是鹬蚌相争,渔翁得利中的那个动物,英国贵族到印度打猎, 发现这个鹬很聪明, 人一靠近就飞走了, 没办法就开始伪装、 远程精准射击, 于是乎 snipper就诞生了。
    我们来看一下业务业务场景是怎么调用的:

    public class Client { 
    public static void main(String[] args) { 
        //产生三毛这个狙击手 
        Snipper sanMao = new Snipper(); 
        sanMao.killEnemy(new G3()); 
    } 
    }

    运行结果如下:

    通过望远镜观看敌人... 
    G3射击...

    在这里我们直接调用了子类,一个狙击手是很依赖枪支的,别说换一个型号的枪了,就是换一个同型号的枪也会影响射击的,所以这里就直接传递进来了子类。那这个时候,我们能不能直接使用父类传递进来呢?修改一下 Client类:

    public class Client { 
    public static void main(String[] args) { 
        //产生三毛这个狙击手 
        Snipper sanMao = new Snipper(); 
        Rifle rifle = new Rifle(); 
        sanMao.killEnemy((G3)rifle); 
    } 
    }

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

  3. 覆盖或实现父类的方法时输入参数可以被放大。方法中的输入参数叫做前置条件,这是什么意思呢?大家做过 Web Service 开发就应该知道有一个“契约优先”的原则,也就是先定义出 WSDL 接口,制定好双方的开发协议,然后再各自实现。里氏替换法则也要求制定了一个契约,就是父类或接口,这种设计方法也叫做 Design by Contract,契约优先设计,是和里氏替换法则融合在一起的。契约制定了,但是契约有前置条件和后置条件,前置条件就是你要让我执行,就必须满足我的条件;后置条件就是我执行完了,必须符合规定的契约。这个比较难理解,我们来看一个例子,我们先定义个 Father类:

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

    这个类非常简单,就是把 HashMap转换为 Collection 集合类型,然后我们来看子类:

    public class Son extends Father {
    //放大输入参数类型 
    public Collection doSomething(Map map){ 
        System.out.println("子类被执行..."); 
        return map.values(); 
    }  
    }

    大家注意看子类的方法,和父类同样的一个方法名称,但是又不是重写(Override)父类的方法,你加个@Override 试试看,报错的,为什么呢?是输入参数类型不同,编译器就不认为是重写父类的方法了,那这是什么?是重载(Overload) !不用大惊小怪的,不在一个类就不能是重载了?继承是什么意思,子类拥有父类的所有属性和方法,那方法名重复输入参数类型又不相同当然是重载了。我们再来看业务调用类:

    public class Client {  
    public static void invoker(){ 
        //父类存在的地方,子类就应该能够存在 
        Father f = new Father(); 
        HashMap map = new HashMap(); 
        f.doSomething(map); 
    } 
    
    
    public static void main(String[] args) {     
        invoker(); 
    } 
    } 

    运行结果如下:

    父类被执行... 

    里氏替换法则说是父类出现的地方子类就能出现,我们把上面的父类部分修改为子类,程序如下:

    public class Client {  
    public static void invoker(){ 
        //父类存在的地方,子类就应该能够存在 
        Son f = new Son(); 
        HashMap map = new HashMap(); 
        f.doSomething(map); 
    } 
    
    
    public static void main(String[] args) {     
        invoker(); 
    } 
    } 

    运行结果还是一样,看明白是怎么回事了吗?父类方法的输入参数是 HashMap 类型,子类的输入参数是 Map 类型,也就是说子类的输入参数类型的范围扩大了,子类代替父类传递到调用类用,子类的方法永远都不回被执行,这是正确的,如果你想让子类的方法运行,你就必须重写父类的方法。大家可以这样想想看,在一个 Invoker类中关联了一个父类,调用了一个父类的方法,子类可以重写这个方法,也可以重载这个方法,前提是要扩大这个前置条件,就是输入参数的类型大于父类的类型覆盖范围。可能比较理难理解, 那我们再反过来想一下, 如果 Father 类的输入参数类型大于子类的输入参数类型, 会出现什么问题?就会出现父类存在的地方,子类就未必可以存在,因为一旦把子类作为参数传入进去,调用者就很可能进入子类的方法范畴。我们把上面的例子修改一下,先看父类:

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

    把父类的前置条件修改为 Map 类型,我们再修改一下子类方法的输入参数,相对父类缩小输入参数的类型范围,也就是缩小前置条件:

    public class Son extends Father { 
    //缩小输入参数范围 
    public Collection doSomething(HashMap map){ 
        System.out.println("HashMap转Collection被执行..."); 
        return map.values(); 
    } 
    }

    再来看业务场景类:

    public class Client { 
    public static void invoker(){ 
        //有父类的地方就有子类 
        Father f= new Father(); 
        HashMap map = new HashMap(); 
        f.doSomething(map); 
    } 
    
    
    public static void main(String[] args) { 
        invoker(); 
    } 
    }

    运行结果如下:

    父类被执行...

    那我们再把里氏替换法则引入进来会有什么问题?有父类的地方子类就可以使用,好,我们把这个Client 类修改一下,程序如下:

    public class Client { 
    public static void invoker(){ 
        //有父类的地方就有子类 
        Son f= new Son(); 
        HashMap map = new HashMap(); 
        f.doSomething(map); 
    } 
    
    
    public static void main(String[] args) { 
        invoker(); 
    } 
    }

    运行结果如下:

    子类被执行...

    完蛋了吧?!子类在没有重写父类的方法的前提下,子类方法被执行了,这个绝对会引起以后的业务逻辑混乱,因为在项目的应用中父类一般都是抽象类,子类是实现类,你传递一个这样的实现类就会引起一堆意想不到的业务逻辑混乱,所以子类中方法的前置条件必须与超类中被覆盖的方法的前置条件相同或者更宽松。

  4. 覆盖或实现父类的方法是输出结果可以被缩小。这个是什么意思呢,父类的一个方法返回值是一个类型 T,子类相同方法(重载或重写)返回值为 S,那么里氏替换法则就要求 S必须小于等于 T,也就是说要么S 和T 是同一个类型,要么 S 是T 的子类,为什么呢?分两种情况,如果是重写,方法的输入参数父类子类是相同的,两个方法的范围值 S 小于等于 T,这个是重写的要求,这个才是重中之重,子类重写父类的方法,天经地义;如果是重载,则要求方法的输入参数不相同,在里氏替换法则要求下就是子类的输入参数大于等于父类的输入参数,那就是说你写的这个方法是不会被调用到的,参考上面讲的前置条件。

总结

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

你可能感兴趣的:(设计模式,设计原则,里氏替换原则)