设计模式原则:里氏替换原则

Published on 2024-02-20 16:10 in 分类: 博客 with 狂盗一枝梅
分类: 博客

一、里氏替换原则定义

里氏替换原则(Liskov Substitution Principle,LSP)

第一种定义,也是最正宗的定义: 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.

翻译过来的意思是:如果对每一个类型为S的对象o1,都有类型为T 的对象o2,使得以T定义的所有程序P在所有的对象o1都代换成o2时,程序P的行为没有发生变化,那么类型S是类型T的子类型。

第二种定义: Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.

翻译过来的意思是:所有引用基类的地方必须能透明地使用其子类的对象。

我看完定义之后:

DqDm

WTF,这在说什么鬼话,完全不知道在说什么东西啊

通俗点来说,无论是第一种定义还是第二种定义,都在描述一件事,那就是类的继承,它通过结果式的定义来描述了如何正确使用继承:只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或异常,使用者可能根本就不需要知道是父类还是子类。但是,反过来就不行了,有子类出现的地方,父类未必就能适应。里氏替换原则中的“替换”两字也正是这个意思。

凡事要从正反两面看,为什么会有专门一个“里氏替换原则”来规范继承的使用,这是因为继承本身就存在一些优缺点

继承优点:

● 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;

● 提高代码的重用性;

● 子类可以形似父类,但又异于父类,“龙生龙,凤生凤,老鼠生来会打洞”是说子拥有父的“种”,“世界上没有两片完全相同的叶子”是指明子与父的不同;

● 提高代码的可扩展性,实现父类的方法就可以“为所欲为”了,君不见很多开源框架的扩展接口都是通过继承父类来完成的;

● 提高产品或项目的开放性。

继承的缺点:

● 继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法;

● 降低代码的灵活性。子类必须拥有父类的属性和方法,让子类自由的世界中多了些约束;

● 增强了耦合性。当父类的常量、变量和方法被修改时,需要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来非常糟糕的结果——大段的代码需要重构。

从整体上来看,使用继承利大于弊,引入里氏替换原则,正是为了让“利”的因素发挥最大的作用,同时减少“弊”带来的麻烦。

二、里氏替换原则的四种含义

有Java开发经验的都比较容易理解里氏替换原则的意思了,里氏替换原则是一种“结果式的定义”,也就是说,遵循了里氏替换原则的话,最终的结果会是这样,但是如何去做,需要注意的细节事项却并没有提及。

里氏替换原则其实有四种含义:

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

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

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

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

看完这四条大概一般都懵了,让我们一起来分别看看这四条规则分别是什么意思

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

需要注意的是,这里的父类方法特指“抽象的父类方法”,哈哈哈,不管怎么看,“子类必须完全实现父类方法”这句话似乎都是废话。。。no no no,这句话的真正意思是告诉我们,如果我们实现了父类的方法,但是发现并不合适,比如,如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生“畸变”,则可能需要断开父子继承关系,采用依赖、聚集、组合等关系代替继承。

“子类必须完全实现父类方法“这条规则的意思实际上是要告诉我们如果不能做到,则需要断开继承关系,使用其它方式实现相同的功能。

这里使用士兵与枪的例子来举例说明,类图如下

枪支类图

枪类:

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 {
    //定义士兵的枪支
    private AbstractGun gun;
    //给士兵一支枪
    public void setGun(AbstractGun _gun){
    	this.gun = _gun;
    }
    public void killEnemy(){
        System.out.println("士兵开始杀敌人...");
        gun.shoot();
    }
}

场景类

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

运行结果:

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

Ok,运行正常,结果正常

然而,如果我们这时候要加入一个玩具手枪,会发生什么?

玩具枪类

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

场景类

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

运行结果

士兵开始杀敌人...
玩具枪射击...

YJ0YE

很明显出现了业务上的bug:玩具枪不能用于士兵射击,该如何改造呢?

ToyGun脱离继承,建立一个独立的父类,为了实现代码复用,可以与AbastractGun建立关联委托关系,AbstractToy中声明将声音、形状都委托给AbstractGun处理,仿真枪嘛,形状和声音都要和真实的枪一样了然后两个基类下的子类自由延展,互不影响

玩具枪与真实枪分离的类图

总结:继承的时候要考虑子类是否能够完整地实现父类的业务,不要出现像上面的拿枪杀敌人时却发现是把玩具枪的笑话。

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

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

这个规则描述了一个原因:为什么在子类出现的地方,父类未必就可以胜任,正是因为子类可以有自己的个性。

子类的个性一旦在业务场景中被使用,则父类就无法替代了。

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

这句话其实还没说完,应该说:覆盖或实现父类的方法时输入参数可以被放大,但是不能被缩小

方法中的输入参数称为前置条件,这是什么意思呢?

大家做过WebService开发就应该知道有一个“契约优先”的原则,也就是先定义出WSDL接口,制定好双方的开发协议,然后再各自实现。

里氏替换原则也要求制定一个契约,就是父类或接口,这种设计方法也叫做Design by Contract(契约设计)。契约制定了,也就同时制定了前置条件和后置条件:前置条件就是你要让我执行,就必须满足我的条件;后置条件就是我执行完了需要反馈,标准是什么。

对应到java中的函数,那就是:我这个方法若是想要被调用到,我的这个输入参数应该满足什么条件?首先要明确,被调用到不一定是我们的目的,因为可能会产生混乱的后果。

0nQo

我们先定义一个Father类

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

再定义一个Son类

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

要注意,这里子类的doSomething方法不是覆写,而是重载,和父类的方法是两个方法。

定义场景类

public class Client {
    public static void main(String[] args) {
        Father f = new Father();
        HashMap map = new HashMap();
        f.doSomething(map);
    }
}

运行结果:

父类被执行...

根据里氏替换原则,我们将场景类中使用的Father类替换成Son类试试看

public class Client {
    public static void main(String[] args) {
        Son f = new Son();
        HashMap map = new HashMap();
        f.doSomething(map);
    }
}

运行结果:

父类被执行...

可以看到,这个程序完全是符合里氏替换原则的,无论是Father类还是Son类,最终执行的结果都一样。

解释下这个问题:父类方法的输入参数是HashMap类型,子类的输入参数是Map类型,也就是说子类的输入参数类型的范围扩大了,子类代替父类传递到调用者中,子类的方法永远都不会被执行。这是正确的,如果你想让子类的方法运行,就必须覆写父类的方法。

然而,如果我改下上面的代码,让Father类的输入参数类型宽于子类的输入参数类型,会出现什么问题呢?

父类的前置条件变大:

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

子类的前置条件缩小:

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

还是和之前一样的,先看下父类的运行场景

public class Client {
    public static void main(String[] args) {
        Father f = new Father();
        HashMap map = new HashMap();
        f.doSomething(map);
    }
}

运行结果:

父类被执行...

再看下子类的运行场景

public class Client {
    public static void main(String[] args) {
        Son f = new Son();
        HashMap map = new HashMap();
        f.doSomething(map);
    }
}

运行结果:

子类被执行...

dx5GD

这很明显违反了里氏替换原则:父类替换成子类,运行逻辑发生了错乱,在外层调用不变的情况下,我只是调整了前置条件大小,就让程序调用的方法发生了变化,这种做法若是在实际工作中,将会是个致命的错误。

总结:

在一个类中关联了一个父类,调用了一个父类的方法,子类可以覆写这个方法,也可以重载这个方法,前提是要扩大这个前置条件,就是输入参数的类型宽于父类的类型覆盖范围,若是缩小了前置条件范围,则会违反里氏替换原则:会出现“父类存在的地方,子类就未必可以存在”,调用者很有可能会调用到子类方法,从而让程序运行出现业务逻辑混乱,所以子类中方法的前置条件必须与父类中被覆写的方法的前置条件相同或者更宽松。

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

父类的一个方法的返回值是一个类型T,子类的相同方法(重载或覆写)的返回值为S,那么里氏替换原则就要求S必须小于等于T,也就是说,要么S和T是同一个类型,要么S是T的子类。

如果是覆写,父类和子类的同名方法的输入参数是相同的,两个方法的范围值S小于等于T,这是覆写的要求,这才是重中之重,子类覆写父类的方法,天经地义;

如果是重载,则要求方法的输入参数类型或数量不相同,在里氏替换原则要求下,就是子类的输入参数宽于或等于父类的输入参数,也就是说你写的这个方法是不会被调用的,参考上面讲的前置条件。

三、最佳实践

采用里氏替换原则的目的就是增强程序的健壮性,版本升级时也可以保持非常好的兼容性。

经过上面文章的解释了历史替换规则的四种含义,特别是比较难理解的第3、4条,我们会发现无论是覆写还是重载父类的非抽象方法,都可能会出现问题,最好的方法就是:不覆盖或者重载父类的方法,因此以下几条意见仅供参考

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

2、尽量不要覆盖或者重载父类的方法,避免调用者错误的调用到子类的方法。

参考文档:

《设计模式之禅(第二版)》第2章《里氏替换原则》

寂然解读设计模式 - 里氏替换原则

END.


#设计模式
目录