设计模式6大设计原则解读——里氏替换原则

上一篇我们解读了单一职责原则,本篇我们来说一下里氏替换原则。

  1. 里氏替换原则(Liskov Substitution Principle)
    定义1 如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1都代替成o2时,程序P的行为没有发生变化,那么类型S是类型T的子类型。
    原文定义: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.
    定义2 所有引用基类的地方必须能透明地使用其子类的对象
    原文定义:Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.
    简要解读:里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或异常,使用者可能根本就不需要知道是父类还是子类。

它包含以下4层含义
1、子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。(子类必须完全实现父类的方法)
2、子类中可以增加自己特有的方法。(子类可以有自己的个性)
3、当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。(覆盖或实现父类的方法时输入参数可以被放大)
4、当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。(覆写或实现父类的方法时输出结果可以被缩小)

场景再现:有一功能P1,由类A完成。现需要将功能P1进行扩展,扩展后的功能为P,其中P由原有功能P1与新功能P2组成。新功能P由类A的子类B来完成,则子类B在完成新功能P2的同时,有可能会导致原有功能P1发生故障。

解决方案:当使用继承时,遵循里氏替换原则。类B继承类A时,除添加新的方法完成新增功能P2外,尽量不要重写父类A的方法,也尽量不要重载父类A的方法。

理解:在面向对象的语言中,继承是必不可少,也是非常优秀的一种语言机制。很多开源框架的扩展接口都是通过继承父类来实现的。

继承的优点如下:
1、代码共享,减少创建类的数量,每个子类都拥有父类的属性和方法
2、提高代码重用性
3、子类可以形似父类,但是又可以有自己的特点,与父类不同
4、提高代码可扩展性,只要实现父类的方法就可以做自己独特的事情
5、提高产品的开放性

继承的缺点如下:
1、继承是侵入性的,只要继承,就必须拥有父类的所有属性和方法
2、降低代码灵活性,子类必须拥有父类的属性和方法,在实现自己个性自由的情况下又多了些约束
3、增强了耦合性,当父类进行修改时也要考虑子类的修改,如果规范不够的话,可能会造成大段代码需要重构
继承包含这样一层含义:父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的规范和契约,虽然它不强制要求所有的子类必须遵从这些契约,但是如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。而里氏替换原则就是表达了这一层含义。

继承作为面向对象三大特性之一,在给程序设计带来巨大便利的同时,也带来了弊端。比如使用继承会给程序带来侵入性,程序的可移植性降低,增加了对象间的耦合性,如果一个类被其他的类所继承,则当这个类需要修改时,必须考虑到所有的子类,并且父类修改后,所有涉及到子类的功能都有可能会产生故障。

下面对里氏替换原则包含的4层含义进行简要解读

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

我们在做系统设计的时候,经常使用的就是设计接口或者抽象类,然后由具体实现类再去实现具体业务逻辑,调用的时候通过传入接口或者抽象类,这实际上已经应用了里氏替换原则。
下面我们使用典型的FPS类型的游戏CS中的枪支来进行讲解。类图如下:

这里写图片描述

枪的职责是射击,具体怎样射击需要在子类中去实现,手枪是单发射程比较近,步枪威力大射程远,机枪是用来扫射敌人。士兵类中定义了一个killEnemy方法,使用枪来击杀敌人。具体使用什么类型的枪来击杀敌人,需要等到调用的时候才能知道。AbstractGun代码如下:

这里写图片描述

这里写图片描述

这里写图片描述

有人,有枪,有场景,给三毛一把步枪就可以击杀敌人了,运行结果如下:

这里写图片描述

如果三毛想用其他枪去击杀敌人,只要为setGun方法设置相应的枪的类型就好了,在编写程序的时候,士兵根本不需要知道传入什么类型的枪。
注意:在类中调用其他类的方法时一定要使用父类或者接口,如果不能使用父类或接口,说明类的设计已经违反了LSP原则。

接下来,如果又增加了一个玩具枪,我们先来设计一个类图,类图如下:

这里写图片描述

我们发现,玩具枪是不能杀人的,不能写到shoot方法中,那么看代码如下图:

这里写图片描述

运行结果如下图:

这里写图片描述

给三毛一把玩具枪,但是不能击杀敌人。现在发现业务调用类出现了问题。
接下来我们把类图改进一下,有两种方式可以采用:
一种是在Soldier类中增加instanceof判断,如果是玩具枪就不击杀敌人。这个方法可以解决问题,但是与父类有关的子类也要相应修改 。如果与父类相关的类很多,那工作量就是个问题了,每个相关的类都需要做相应调整。
另一种方式是ToyGun脱离继承,建立一个独立父类,为了实现代码复用,可以和AbstractGun建立关联委托关系,类图如下:

这里写图片描述

可以在AbstractToy中声明将玩具枪的形状、声音等都委托给AbstractGun来处理,仿真枪的形状和声音都应该和真枪类似,然后两个基类下的子类自由扩展,互不影响。
注意:如果子类不能完整的实现父类的方法,或者父类的某些方法在子类中发生了其他变化,建议切断父子继承关系,使用其他方式如依赖、聚集、组合等方式来替换掉继承关系。

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

子类可以拥有自己独特的定义(方法和属性),在枪里面就是行为、外观等。里氏替换原则不可以颠倒过来使用,在子类出现的地方父类就不一定能够胜任了。还是刚才的例子,步枪有AK47、AUG狙击步枪等。把这两个型号的枪引入步枪后的类图如下图所示:
这里写图片描述

代码如下图:

这里写图片描述

这里写图片描述

运行结果如下:

这里写图片描述

我们这里传入的是子类AUG,但是相反,我们尝试传父类进来,类图如下:

这里写图片描述

结果发现报了异常,java.lang.ClassCastException异常,向下转型不安全问题就体现出来了,从里氏替换原则来看,就是子类出现的地方父类未必就可以出现。

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

方法中输入参数称为前置条件,类比Web Service开发的“契约优先”原则,也就是先定义出WSDL接口,制定好开发协议,然后再各自实现。里氏替换原则其实也有个契约,这个契约就是父类或接口,这种设计方法也叫Design By Contract(契约设计),与里氏替换原则有异曲同工之妙。契约定好了,前置和后置条件就规定好了,前置条件是说你要让我执行就要符合我的条件,后置条件就是执行完成后我要反馈的标准。下面通过一个例子来说明:

这里写图片描述

这里写图片描述

这是发生在父子类的重载方法,场景类如下图:

这里写图片描述

根据里氏替换原则,父类出现的地方子类可以出现,我们修改代码,如下图:

这里写图片描述

运行结果是一样的,父类的传入参数是HashMap类型,子类的传入参数是Map类型,也就是说子类的输入参数类型宽于父类的参数类型,子类的输入参数类型范围扩大了,子类代替父类传入到调用者中,子类方法永远都不会被执行。如果想让子类方法执行,可以重写也可以重载,但是前提都是子类的参数类型要宽于父类的参数类型覆盖的范围。

4、覆写或实现父类的方法时输出结果可以被缩小
父类的一个方法的返回值是类型T,子类的相同方法(重载或重写)的返回值是类型S,里氏替换原则要求S必须小于或等于T,也就是S与T类型相同或者S是T的子类。

采用里氏替换原则的目的就是增强程序健壮性,版本升级的时候也能很好的兼容,即使增加子类,原有的子类也不受到影响,还能正常运行,在实际项目中,子类代表了不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑是非常符合常理的一件事情,但是在应用里氏替换原则的时候,尽量避免子类的个性,如果有的话也要考虑单独抽取出来,利用其他方式来替换继承关系。

里氏替换原则就介绍到这里,下一篇我们将介绍依赖倒置原则。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值