设计模式六大原则(2):里氏替换原则
1. 定义?里氏替代原则,(Liskov Substitution Principle,LSP ?)?? :? ? 肯定有不少人跟我刚看到这项原则的时候一样,对这个原则的名字充满疑惑。其实原因就是这项原则最早是在1988年,由麻省理工学院的一位姓里的女士(Barbara Liskov)提出来的。 ? 定义1: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的子类型。) ?定义2:Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.(所有引用基类的地方必须能透明地使用其子类的对象。) ? ??第二个定义是最清晰明确的! 2.理解:? ?通俗点讲,只要父类能出现的地方,子类就可以出现,并且替换为子类也不会产生任何错误或异常,使用者可能根本就不需要知道是父类还是子类。但是,反过来就不行了,有子类出现的地方,父类未必就能适应。 继承机制的优点:
继承机制的缺点:
里氏替换原则为良好的继承定义了一个规范,通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。一句简单的定义包含了四层含义:
? ? ? ? ??
做系统设计时,经常会定义一个接口或抽象类,然后编码实现(定义的方法或接 口),调用类则直接传入接口或抽象类,这里也是LSP的应用体现。
? ? ? ? ? 继承包含这样一层含义:父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的规范和契约,虽然它不强制要求所有的子类必须遵从这些契约,但是如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。而
里氏替换原则就是表达了这一层含义
。
? ? ? ? ?
我们举个例子来说明这个原则,大家都打过CS吧,非常经典的FPS类游戏,我们来描述一下里面用到的枪,类图如图2-1所示。
图2-1 CS游戏中的枪械类图 ???? 枪的主要职责是射击,如何射击在各个具体的子类中定义,手 枪是单发射程比较近,步枪威力大射程远,机枪用于扫射。在士兵类中定义了一个方法killEnemy,使用枪来杀敌人,具体使用什么枪来杀敌人,调用的时候才知道,AbstractGun类的源程序如代码清单2-1所示。 代码清单2-1 枪支的抽象类
???? 手 枪、步枪、机枪的实现类如代码清单2-2所示。 代码清单2-2 手 枪、步枪、机枪的实现类
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
Handgun
extends ?
//手 枪的特点是携带方便,射程短
?
@Override
?
shoot() {
?
System.out.println(
"手 枪射击..."
);
?
}
?
}
?
Rifle
AbstractGun{
?
//步枪的特点是射程远,威力大
?
shoot(){
?
"步枪射击..."
);
?
}
?
}
?
MachineGun
AbstractGun{
?
shoot(){
?
"机枪扫射..."
);
?
}
?
???? 有了枪械,还要有能够使用这些枪械的士兵,其源程序如代码清单2-3所示。
代码清单2-3 士兵的实现类
11
|
Soldier {
?
killEnemy(AbstractGun gun){
?
"士兵开始杀人..."
);
?
gun.shoot();
?
}
?
???? 注意粗体部分,定义士兵使用枪来杀敌,但是这把枪是时抽象的,具体是手 枪还是步枪需要在上战场前(也就是场景中)前确定。场景类Client的源代码如代码清单2-4所示。
代码清单2-4 场景类
13
|
Client {
?
static?
main(String[] args) {
?
//产生三毛这个士兵
?
Soldier sanMao =
new ?
Soldier();
?
sanMao.killEnemy(
Rifle());
?
???? 有人,有枪,也有场景,运行结果如下所示。
士兵开始杀人... 步枪射击... ???? 在这个程序中,我们给三毛这个士兵一把步枪,然后就开始杀敌了,如果三毛要使用机枪当然也可以,直接把sanMao.killEnemy(new?Rifle())修改为 sanMao.killEnemy(new?MachineGun())就可以了,在编写程序时Solider士兵类根本就不用知道是哪个型号的枪(子类)被传入。 ???? 注意?在类中调用其他类时务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已经违背了LSP原则。 ???? 我们再来想一想,如果我们有一个玩具手 枪,该如何定义呢?我们先在类图2-1上增加一个类ToyGun,然后继承于AbstractGun类,修改后的类图如图2-2所示。
图2-2 枪械类图 ???? 首先我们想,玩具枪是不能用来射击的,杀不死人的,这个不应该写在shoot方法中。新增加的ToyGun的源代码如代码清单2-5所示。 代码清单2-5 玩具枪源代码 ToyGun
//玩具枪是不能射击的,但是编译器又要求实现这个方法,怎么办?虚构一个呗!
?
//玩具枪不能射击,这个方法就不实现了
?
???? 由于引入了新的子类,场景类中也使用了该类,Client稍作修改,源代码如代码清单2-6所示。
代码清单2-6 玩具枪源代码 ToyGun());
?
???? 修改了黑体部分,把玩具枪传递给三毛用来杀敌,代码运行结果如下所示:
???? 坏了,士兵拿着玩具枪来杀人,射不出子弹呀!如果在CS游戏中有这种事情发生,那你就等着被人爆头吧,然后看着自己凄凉的倒地。在这种情况下,我们发现业务调用类已经出现了问题,正常的业务逻辑已经不能运行,那怎么办?好办,有两种解决办法: ???? Number one:在Soldier类中增加instanceof的判断,如果是玩具枪,就不用来杀敌人。这个方法可以解决问题,但是你要知道,在程序中,每增加一个类,所有与这个父类有关系的类都必须修改,你觉得可行吗?如果你的产品出现了这个问题,因为修正了这样一个Bug,就要求所有与这个父类有关系的类都增加一个判断,客户非跳起来跟你干架不可!你还想要你的客户忠诚你吗?显然,这个方案被否定了。 ???? Number two:ToyGun脱离继承,建立一个独立的父类,为了做到代码可以复用,可以与AbastractGun建立关联委托关系,如图2-3所示。
图2-3 玩具枪与真实枪分离的类图 ???? 例如,可以在AbstractToy中声明声音、形状都委托给AbstractGun处理,仿真枪嘛,形状和声音都要和真实的枪一样了,然后两个基类下的子类自由延展,互不影响。 ???? 在Java的基础知识中,每位老师都会讲继承,Java的三大特征嘛,继承、封装、多态。继承就是告诉你拥有父类的方法和属性,然后你就可以重写父类的方法。按照继承原则,我们上面的玩具枪继承AbstractGun是绝对没有问题的,玩具枪也是枪嘛,但是在具体应用场景中就要考虑下面这个问题了:子类是否能够完整地实现父类的业务,否则就会出现像上面的拿枪杀敌人时却发现是把玩具枪的笑话。 ???? 注意?如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生“畸变”,则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。
? ? ?2.子类可以有自己的方法和属性
<子类可以有自己的个性>
? ? ?因为里氏替换原则可以正着用,但是不能反过来用。在子类出现的地方,父类未必就可以胜任。还是以刚才的关于枪械的例子为例,步枪有几个比较“响亮”的型号,比如AK47、G3狙击步枪等,把这两个型号的枪引入后的Rifle子类图如图2-4所示。
图2-4 增加AK47和G3后的Rifle子类图 ???? 很简单,G3继承了Rifle类,狙击手(Snipper)则直接使用G3狙击步枪,源代码如代码清单2-7所示。 代码清单2-7 G3狙击枪源码代码
17
|
G3
Rifle {
?
//狙击枪都是携带一个精准的望远镜
?
zoomOut(){
?
"通过望远镜查看敌人..."
);
?
shoot(){
?
"G3射击..."
);
?
}
?
}
|
???? 有狙击枪就有狙击手,狙击手类的源代码如代码清单2-8所示。
代码清单2-8 G3狙击手类的源码代码
???? 狙击手使用狙击枪来杀死敌人,业务场景Client类的源代码如代码清单2-9所示。
代码清单2-9 狙击手使用G3杀死敌人
Snipper();
通过望远镜查看敌人...
G3射击...
????? 在这里系统直接调用了子类,一个狙击手是很依赖枪支的,别说换一个型号的枪了,就是换一个同型号的枪也会影响射击,所以这里就直接把子类传递了进来。这个时候,我们能不能直接使用父类传递进来呢?修改一下Client类,如代码清单2-10所示。
代码清单2-10 使用父类作为参数
Rifle();
? ? 3.覆盖或实现父类的方法时输入参数可以被放大。
我们来看一个例子,我们先定义个Father类,如代码清单2-11所示。
代码清单2-11 Father类源代码
Collection doSomething(HashMap map){
return
?
map.values();
代码清单2-12 子类源代码
//放大输入参数类型
map.values();
代码清单2-13 场景类源代码
Father();
HashMap();
父类被执行...
????? 根据里氏替换原则,父类出现的地方子类就可以出现,我们把上面的粗体部分修改为子类,如下代码清单2-14所示。
代码清单2-14 子类替换父类后的源代码
Son();
代码清单2-15 父类的前置条件较大
????? 把父类的前置条件修改为Map类型,我们再修改一下子类方法的输入参数,相对父类缩小输入参数的类型范围,也就是缩小前置条件,源代码如代码清单2-16所示。
代码清单2-16 子类的前置条件较小
//缩小输入参数范围
?
Collection doSomething(HashMap map){
?
"HashMap转Collection被执行..."
????? 在父类的前置条件大于子类的前置条件的情况下,业务场景的源代码如代码清单2-17所示。
代码清单2-17 子类的前置条件较小
//有父类的地方就有子类
?
Father f=
????? 代码运行结果如下所示:
????? 那我们再把里氏替换原则引入进来会有什么问题?有父类的地方子类就可以使用,好,我们把这个Client类修改一下,源代码如代码清单2-18所示。
代码清单2-18 采用里氏替换原则后的业务场景类
子类被执行...
????? 完蛋了吧?!子类在没有覆写父类的方法的前提下,子类方法被执行了,这会引起业务逻辑混乱,因为在实际应用中父类一般都是抽象类,子类是实现类,你传递一个这样的实现类就会“歪曲”了父类的意图,引起一堆意想不到的业务逻辑混乱,所以子类中方法的前置条件必须与超类中被覆盖的方法的前置条件相同或者更宽松。
?4.覆写或实现父类的方法时输出结果可以被缩小。
? ??这是什么意思呢,父类的一个方法的返回值是一个类型T,子类的相同方法(重载或覆写)的返回值为S,那么里氏替换原则就要求S必须小于等于T,也就是说要么S和T是同一个类型,要么S是T的子类,为什么呢?分两种情况,如果是覆写,父类和子类的同名方法的输入参数是相同的,两个方法的范围值S小于等于T,这是覆写的要求,这才是重中之重,子类覆写父类的方法,天经地义。如果是重载,则要求方法的输入参数类型或数量不相同,在里氏替换原则要求下,就是子类的输入参数大于或等于父类的输入参数,也就是说你写的这个方法是不会被调用到的,参考上面讲的前置条件。 ? ? ? ?采用里氏替换原则的目的就是增强程序的健壮性,版本升级时也可以保持非常好的兼容性。即使增加子类,原有的子类还可以继续运行。在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑,非常完美!
5.小结:
Liskov替换原则并不是要求子类不能新增父类没有的方法或者属性。因为从调用父类的客户程序的角度来说,它关心的仅仅是父类的行为,只要子类对于父类的行为是可替换的,就不算是违背该原则。
恰恰相反,当你发现父类拥有子类不希望继承,或者勉强继承会对子类造成破坏时 ,正可以说明这个继承体系可能存在问题,违背了Liskov替换原则。这就充分说明,子类并不关心父类的行为,但却需要遵循父类制定的规范或契约,以满足客户调用父类的期望。正所谓"萧规曹随",如果前人制定的规范我们不遵循,反而要去打破,那就不是继承,而是铁了心要另起炉灶了。
一个经典的违反Liskov替换原则的例子是正方形与矩形之间的关系。这样的例子在谈对象设计的原则时,已经啰嗦得够多,这里我就不再赘述了 。这个例子带来的教训就是,现实世界中继承的例子,不能够完全直接套用在程序世界中。不过,作为设计的参照物,现实世界的很多规律与法则,我们仍然不可忽视。例如鲸鱼和鱼,应该属于什么关系?从生物学的角度看,鲸鱼应该属于哺乳动物,而不是鱼类。
3.问题由来
有一功能P1,由类A完成。现需要将功能P1进行扩展,扩展后的功能为P,其中P由原有功能P1与新功能P2组成。新功能P由类A的子类B来完成,则子类B在完成新功能P2的同时,有可能会导原有功能P1发生故障。解决方案:遵循LSP:类B在继承类A时,除添加新的方法完成新增功能P2外,尽量不要重写父类A的方法,也尽量不要重载父类的A的方法。
4.好处
即为正确使用继承的好处。
5.难点
5.1 ?如何根据类的继承原则,确定是要继承当前类结构,还是要另起炉赵。
5.2 ?如何根据情况,在违背Liskov替换原则时,提出一种方案。
6.实践建议
6.1 ?在类中调用其它类时务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已违背了LSP;(例:Interface in = new Instance(););
6.2 ?如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生“畸变”,则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。
6.3 如果你的程序中出现了if/else之类对子类类型进行判断的条件,则说明类的设计已违背了LSP。
7.范例
7.1 ?矩形与正方形(现实中的继承,却不能直接用于程序中)
对于长方形的类,如果它的长宽相等,那么它就是一个正方形,因此,长方形类的对象中有一些正方形的对象。对于一个正方形的类,它的方法有个setSide和getSide,它不是长方形的子类,和长方形也不会符合LSP。
- //长方形类:??
- public?class?Rectangle{??
- ????...??
- ????setWidth(int?width){??
- ????????this.width=width;??
- ????}??
- ????setHeight(int?height){??
- ????????this.height=height??
- ????}??
- }??
- //正方形类:??
- class?Square{??
- this.?height=width;??
- ????setHeight(int?height){??
- this.setWidth(height);??
- }??
- //例子中改变边长的方法:??
- void?resize(Rectangle?r){??
- ????while(r.getHeight()<=r.getWidth){??
- ????????r.setHeight(r.getWidth+1);??
- }??
那么,如果让正方形当做是长方形的子类,会出现什么情况呢?我们让正方形从长方形继承,然后在它的内部设置width等于height,这样,只要width或者height被赋值,那么width和height会被同时赋值,这样就保证了正方形类中,width和height总是相等的.现在我们假设有个客户类,其中有个方法,规则是这样的,测试传入的长方形的宽度是否大于高度,如果满足就停止下来,否则就增加宽度的值。现在我们来看,如果传入的是基类长方形,这个运行的很好。根据LSP,我们把基类替换成它的子类,结果应该也是一样的,但是因为正方形类的width和height会同时赋值,这个方法没有结束的时候,条件总是不满足,也就是说,
替换成子类后,程序的行为发生了变化,它不满足LSP
。
7.2?鲸鱼与鱼(避开违背LSP的一种解决方案)
鲸鱼和鱼,应该属于什么关系?从生物学的角度看,鲸鱼应该属于哺乳动物,而不是鱼类。没错,在程序世界中我们可以得出同样的结论。如果让鲸鱼类去继承鱼类,就完全违背了Liskov替换原则。因为鱼作为父类,很多特性是鲸鱼所不具备的,例如通过腮呼吸,以及卵生繁殖。那么,二者是否具有共性呢?有,那就是它们都可以在水中"游泳",从程序设计的角度来说,它们都共同实现了一个支持"游泳"行为的接口。
如下所示的设计,可以看做是解决违背Liskov替换原则的一种常规方案,即提取两者之间的共同点,定义一个更为通用的接口,或者新的父类。

7.3 ?继承的风险
举例说明继承的风险,我们需要完成一个两数相减的功能,由类A来负责:
copy
class?A{??
- int?func1(int?a,?int?b){??
- return?a-b;??
- class?Client{??
- ????static?void?main(String[]?args){??
- ????????A?a?=?new?A();??
- ??????????
- ????????System.out.println("100-50="+a.func1(100,?50));??
- ????????System.out.println("100-80="+a.func1(80));??
- ??
- 运行结果:??
- 100-50=50??
- 80=20??
后来,我们需要增加一个新的功能:完成两数相加,然后再与100求和,由类B来负责。即类B需要完成两个功能:
两数相减
两数相加,再加100
由于类A已经实现了第一个功能,所以类B继承类A后,只需要再完成第二个功能就可以了,代码如下:
copy
class?B?extends?A{??
- return?a+b;??
- int?func2(int?b){??
- return?func1(a,b)+100;??
- class?Client{??
- void?main(String[]?args){??
- ????????B?a?=?new?B();??
- ????????System.out.println("100-50="+b.func1( ????????System.out.println("100-80="+b.func1( ????????System.out.println("100+20+100="+b.func2(20));??
- 150??
- 180??
- 100+20+100=220??
我们发现原来运行正常的相减功能发生了错误。原因就是类B在给方法起名时 无意中重写了父类的方法 ,造成所有运行相减功能的代码全部调用了类B的重写后的方法,造成原本运行正常的功能出现了错误。
在本例中,使用A类完成的功能,换成子类B之后,发生了异常。 在实际编程中,我们常会通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的几率非常大。如果非要重写父类的方法,比较通用的做法是:原来的父类和子类都继承一个更通谷的基类,原来的继承关系去掉,采用依赖、聚合、组合等关系替代。
(编辑:李大同)
【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容!