什么是里氏替换原则?Why里氏替换原则?
先看看里氏替换原则(Liskov Substitution Principle)的定义:
| Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it. |
所有引用基类的地方必须能透明地使用其子类的对象。
通俗的说,子类可以扩展父类功能,但不能改变父类原有功能。

这是一位女士Barbara Liskov 在1988 年提出——这是最原始的定义:
| “ What is wanted here is something like the following substitution property: 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的子类型。
在面向对象的语言里,继承无疑是一个优秀的特性,它有提高代码复用性、扩展性等优点,但是白璧微瑕,它同样也会带来一些不足:
● 继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法;
● 降低了代码的灵活性。子类必须拥有父类的属性和方法,让子类自由的世界中多了些约 束;
● 增强了耦合性。当父类的常量、变量和方法被修改时,需要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来非常糟糕的结果——大段的代码需要重构。
为了扬长避短,就有必要引入里氏替换原则。
里氏抽丝剥茧
里氏替换原则包含了四层含义:
* 子类必须完全实现父类的方法
在做系统设计时,经常会定义一个接口或抽象类,然后编码实现,调用类则直接传 入接口或抽象类,其实这里已经使用了里氏替换原则。
举个例子来说明这个原则,以CS游戏为例,来描述一下里面用到的枪,类图如图2-1所示:
2-1:CS游戏中的枪支类图

枪的主要职责是射击,如何射击在各个具体的子类中定义,手枪是单发射程比较近,步枪威力大射程远,机枪用于扫射。在士兵类中定义了一个方法killEnemy,使用枪来杀敌人, 具体使用什么枪来杀敌人,调用的时候才知道。
枪支抽象类:
<strong><code class="language-java"><span style="color:#a626a4">public</span> <span style="color:#a626a4">abstract</span> <span style="color:#a626a4">class</span> <span style="color:#4078f2">AbstractGun</span> {
<span style="color:#a0a1a7"><em>//枪用来干什么的?杀敌! </em></span>
<span style="color:#a626a4">public</span> <span style="color:#a626a4">abstract</span> <span style="color:#a626a4">void</span> <span style="color:#4078f2">shoot</span>();
}
</code></strong>
手枪、步枪、机枪的实现类:
<strong><code class="language-java"><span style="color:#a626a4">public</span> <span style="color:#a626a4">class</span> <span style="color:#4078f2">Handgun</span> <span style="color:#a626a4">extends</span> <span style="color:#4078f2">AbstractGun</span> {
<span style="color:#a0a1a7"><em>//手枪的特点是携带方便,射程短 </em></span>
<span style="color:#4078f2">@Override</span>
<span style="color:#a626a4">public</span> <span style="color:#a626a4">void</span> <span style="color:#4078f2">shoot</span>() {
System.out.println(<span style="color:#50a14f">"手枪射击..."</span>);
}
}
<span style="color:#a626a4">public</span> <span style="color:#a626a4">class</span> <span style="color:#4078f2">Rifle</span> <span style="color:#a626a4">extends</span> <span style="color:#4078f2">AbstractGun</span>{
<span style="color:#a0a1a7"><em>//步枪的特点是射程远,威力大 </em></span>
<span style="color:#a626a4">public</span> <span style="color:#a626a4">void</span> <span style="color:#4078f2">shoot</span>(){
System.out.println(<span style="color:#50a14f">"步枪射击..."</span>);
}
}
<span style="color:#a626a4">public</span> <span style="color:#a626a4">class</span> <span style="color:#4078f2">MachineGun</span> <span style="color:#a626a4">extends</span> <span style="color:#4078f2">AbstractGun</span>{
<span style="color:#a626a4">public</span> <span style="color:#a626a4">void</span> <span style="color:#4078f2">shoot</span>(){
System.out.println(<span style="color:#50a14f">"机枪扫射..."</span>);
}
}
</code></strong>
士兵的实现类:
<strong><code class="language-java"><span style="color:#a626a4">public</span> <span style="color:#a626a4">class</span> <span style="color:#4078f2">Soldier</span> {
<span style="color:#a0a1a7"><em>//定义士兵的枪支 </em></span>
<span style="color:#a626a4">private</span> AbstractGun gun; <span style="color:#a0a1a7"><em>//给士兵一支枪 </em></span>
<span style="color:#a626a4">public</span> <span style="color:#a626a4">void</span> <span style="color:#4078f2">setGun</span>(AbstractGun _gun){
<span style="color:#c18401">this</span>.gun = _gun;
}
<span style="color:#a626a4">public</span> <span style="color:#a626a4">void</span> <span style="color:#4078f2">killEnemy</span>(){
System.out.println(<span style="color:#50a14f">"士兵开始杀敌人..."</span>);
gun.shoot();
}
}
</code></strong>
定义士兵使用枪来杀敌,但是这把枪是抽象的,具体是手枪还是步枪需 要在上战场前(也就是场景中)前通过setGun方法确定。场景类Client如下:
场景类:
<strong><code class="language-java"><span style="color:#a626a4">public</span> <span style="color:#a626a4">class</span> <span style="color:#4078f2">Client</span> {
<span style="color:#a626a4">public</span> <span style="color:#a626a4">static</span> <span style="color:#a626a4">void</span> <span style="color:#4078f2">main</span>(String[] args) {
<span style="color:#a0a1a7"><em>//产生三毛这个士兵 </em></span>
<span style="color:#986801">Soldier</span> <span style="color:#986801">sanMao</span> <span style="color:#ab5656">=</span> <span style="color:#a626a4">new</span> <span style="color:#4078f2">Soldier</span>(); <span style="color:#a0a1a7"><em>//给三毛一支枪</em></span>
sanMao.setGun(<span style="color:#a626a4">new</span> <span style="color:#4078f2">Rifle</span>());
sanMao.killEnemy();
}
}
</code></strong>
在这个程序中,给三毛这个士兵一把步枪,然后就开始杀敌了。如果三毛要使用机枪,当然也可以,直接把sanMao.setGun(new Rifle())修改为sanMao.setGun(new MachineGun()) 即可,在编写程序时Solider士兵类根本就不用知道是哪个型号的枪(子类)被传入。
再来想一想,如果有一个玩具手枪,该如何定义呢?我们先在类图2-1上增加 一个类ToyGun,然后继承于AbstractGun类,修改后的类图如图2-2所示:
2-2:枪支类图

玩具枪是不能用来射击的,杀不死人的,这个不应该写在shoot方法中。
<strong><code class="language-java"><span style="color:#a626a4">public</span> <span style="color:#a626a4">class</span> <span style="color:#4078f2">ToyGun</span> <span style="color:#a626a4">extends</span> <span style="color:#4078f2">AbstractGun</span> {
<span style="color:#a0a1a7"><em>//玩具枪是不能射击的,但是编译器又要求实现这个方法,怎么办?虚构一个呗! </em></span>
<span style="color:#4078f2">@Override</span>
<span style="color:#a626a4">public</span> <span style="color:#a626a4">void</span> <span style="color:#4078f2">shoot</span>() {
<span style="color:#a0a1a7"><em>//玩具枪不能射击,这个方法就不实现了</em></span>
}
}
</code></strong>
由于引入了新的子类,场景类中也使用了该类,Client类稍作修改:
<strong><code class="language-java"><span style="color:#a626a4">public</span> <span style="color:#a626a4">class</span> <span style="color:#4078f2">Client</span> {
<span style="color:#a626a4">public</span> <span style="color:#a626a4">static</span> <span style="color:#a626a4">void</span> <span style="color:#4078f2">main</span>(String[] args) {
<span style="color:#a0a1a7"><em>//产生三毛这个士兵 Soldier sanMao = new Soldier(); </em></span>
sanMao.setGun(<span style="color:#a626a4">new</span> <span style="color:#4078f2">ToyGun</span>()); sanMao.killEnemy();
}
}
</code></strong>
结果:
<strong><code class="language-java">士兵开始杀敌人...
</code></strong>
玩具枪开始杀人了!在这种情况下,我们发现业务调用类已 经出现了问题,正常的业务逻辑已经不能运行,那怎么办?有两种办法。
● 在Soldier类中增加instanceof的判断,如果是玩具枪,就不用来杀敌人。这个方法可以 解决问题,但是,在程序中,每增加一个类,所有与这个父类有关系的类都必须修改,显然,这个方案被否定了。
● ToyGun脱离继承,建立一个独立的父类,为了实现代码复用,可以与AbastractGun建 立关联委托关系,如图2-3所示:
2-3:玩具枪与真实枪分离的类图

例如,可以在AbstractToy中声明将声音、形状都委托给AbstractGun处理,然后两个基类下的子类自由延展,互不影响。
如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发 生“畸变”,则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。
* 子类可以有自己的个性
子类可以有自己的行为和外观了,也就是方法和属性—— 里氏替换原则可以正着用,但是不能反过来用。在子类出现的地方,父类未必就可以胜任。还是以刚才的关于枪支的例子为例,步枪有几个比较“响亮”的型号,比如AK47、AUG 狙击步枪等,把这两个型号的枪引入后的Rifle子类图如图2-4所示:
2-4:增加AK47和AUG后的Rifle子类图

AUG继承了Rifle类,狙击手(Snipper)则直接使用AUG狙击步枪。
AUG狙击枪类代码:
<strong><code class="language-java"><span style="color:#a626a4">public</span> <span style="color:#a626a4">class</span> <span style="color:#4078f2">AUG</span> <span style="color:#a626a4">extends</span> <span style="color:#4078f2">Rifle</span> {
<span style="color:#a0a1a7"><em>//狙击枪都携带一个精准的望远镜</em></span>
<span style="color:#a626a4">public</span> <span style="color:#a626a4">void</span> <span style="color:#4078f2">zoomOut</span>(){
System.out.println(<span style="color:#50a14f">"通过望远镜察看敌人..."</span>);
}
<span style="color:#a626a4">public</span> <span style="color:#a626a4">void</span> <span style="color:#4078f2">shoot</span>(){
System.out.println(<span style="color:#50a14f">"AUG射击..."</span>);
}
}
</code></strong>
狙击手类:
<strong><code class="language-java"><span style="color:#a626a4">public</span> <span style="color:#a626a4">class</span> <span style="color:#4078f2">Snipper</span> {
<span style="color:#a626a4">public</span> <span style="color:#a626a4">void</span> <span style="color:#4078f2">killEnemy</span>(AUG aug){
<span style="color:#a0a1a7"><em>//首先看看敌人的情况,别杀死敌人,自己也被人干掉 </em></span>
aug.zoomOut();
<span style="color:#a0a1a7"><em>//开始射击 </em></span>
aug.shoot();
}
}
</code></strong>
狙击手使用狙击枪来杀死敌人,业务场景Client类如下:
<strong><code class="language-java"><span style="color:#a626a4">public</span> <span style="color:#a626a4">class</span> <span style="color:#4078f2">Client</span> {
<span style="color:#a626a4">public</span> <span style="color:#a626a4">static</span> <span style="color:#a626a4">void</span> <span style="color:#4078f2">main</span>(String[] args) {
<span style="color:#a0a1a7"><em>//产生三毛这个狙击手 </em></span>
<span style="color:#986801">Snipper</span> <span style="color:#986801">sanMao</span> <span style="color:#ab5656">=</span> <span style="color:#a626a4">new</span> <span style="color:#4078f2">Snipper</span>();
sanMao.setRifle(<span style="color:#a626a4">new</span> <span style="color:#4078f2">AUG</span>());
sanMao.killEnemy();
}
}
</code></strong>
运行结果:
<strong><code class="language-java">通过望远镜察看敌人...
AUG射击...
</code></strong>
在这里,系统直接调用了子类,狙击手是很依赖枪支的,别说换一个型号的枪了,就是换一个同型号的枪也会影响射击,所以这里就直接把子类传递了进来。这个时候,我们能不 能直接使用父类传递进来呢?修改一下Client类:
<strong><code class="language-java"><span style="color:#a626a4">public</span> <span style="color:#a626a4">class</span> <span style="color:#4078f2">Client</span> {
<span style="color:#a626a4">public</span> <span style="color:#a626a4">static</span> <span style="color:#a626a4">void</span> <span style="color:#4078f2">main</span>(String[] args) {
<span style="color:#a0a1a7"><em>//产生三毛这个狙击手 </em></span>
<span style="color:#986801">Snipper</span> <span style="color:#986801">sanMao</span> <span style="color:#ab5656">=</span> <span style="color:#a626a4">new</span> <span style="color:#4078f2">Snipper</span>();
sanMao.setRifle((AUG)(<span style="color:#a626a4">new</span> <span style="color:#4078f2">Rifle</span>()));
sanMao.killEnemy();
}
}
</code></strong>
显然是不行的,会在运行期抛出java.lang.ClassCastException异常,这也是大家经常说的 向下转型(downcast)是不安全的,从里氏替换原则来看,就是有子类出现的地方父类未必就可以出现。
* 覆盖或实现父类的方法时输入参数可以被放大
方法中的输入参数称为前置条件,这是什么意思呢?大家做过Web Service开发就应该知道有一个“契约优先”的原则,也就是先定义出WSDL接口,制定好双方的开发协议,然后再各自实现。里氏替换原则也要求制定一个契约,就是父类或接口,这种设计方法也叫做 Design by Contract(契约设计),与里氏替换原则有着异曲同工之妙。契约制定了,也就同 时制定了前置条件和后置条件,前置条件就是你要让我执行,就必须满足我的条件;后置条 件就是我执行完了需要反馈,标准是什么。这个比较难理解,来看一个例子。
先定义一个Father类:
<strong><code class="language-java"><span style="color:#a626a4">public</span> <span style="color:#a626a4">class</span> <span style="color:#4078f2">Father</span> {
<span style="color:#a0a1a7"><em>//把HashMap转换为Collection集合类型</em></span>
<span style="color:#a626a4">public</span> Collection <span style="color:#4078f2">doSomething</span>(HashMap map){
System.out.println(<span style="color:#50a14f">"父类被执行..."</span>);
<span style="color:#a626a4">return</span> map.values();
}
}
</code></strong>
再定义一个子类:
<strong><code class="language-java"><span style="color:#a626a4">public</span> <span style="color:#a626a4">class</span> <span style="color:#4078f2">Son</span> <span style="color:#a626a4">extends</span> <span style="color:#4078f2">Father</span> {
<span style="color:#a0a1a7"><em>//放大输入参数类型 </em></span>
<span style="color:#a626a4">public</span> Collection <span style="color:#4078f2">doSomething</span>(Map map){
System.out.println(<span style="color:#50a14f">"子类被执行..."</span>);
<span style="color:#a626a4">return</span> map.values();
}
}
</code></strong>
子类的doSomething方法与父类的方法名相同,但又不是覆写(Override)父类的方法。方法名虽然相同,但方法的输入参数不同,就不是覆写,是重载(Overload)!——继承,子类拥有父类的所有属性和方法,方法名相同,输入参数类型又不 相同,当然是重载。
场景类的调用如下:
<strong><code class="language-java"><span style="color:#a626a4">public</span> <span style="color:#a626a4">class</span> <span style="color:#4078f2">Client</span> {
<span style="color:#a626a4">public</span> <span style="color:#a626a4">static</span> <span style="color:#a626a4">void</span> <span style="color:#4078f2">invoker</span>(){
<span style="color:#a0a1a7"><em>//父类存在的地方,子类就应该能够存在 </em></span>
<span style="color:#986801">Father</span> <span style="color:#986801">f</span> <span style="color:#ab5656">=</span> <span style="color:#a626a4">new</span> <span style="color:#4078f2">Father</span>();
<span style="color:#986801">HashMap</span> <span style="color:#986801">map</span> <span style="color:#ab5656">=</span> <span style="color:#a626a4">new</span> <span style="color:#4078f2">HashMap</span>();
f.doSomething(map);
}
<span style="color:#a626a4">public</span> <span style="color:#a626a4">static</span> <span style="color:#a626a4">void</span> <span style="color:#4078f2">main</span>(String[] args) {
invoker();
}
}
</code></strong>
运行结果:
<strong><code class="language-java">父类被执行...
</code></strong>
根据里氏替换原则,父类出现的地方子类就可以出现,修改场景类:
<strong><code class="language-java"><span style="color:#a626a4">public</span> <span style="color:#a626a4">class</span> <span style="color:#4078f2">Client</span> {
<span style="color:#a626a4">public</span> <span style="color:#a626a4">static</span> <span style="color:#a626a4">void</span> <span style="color:#4078f2">invoker</span>(){
<span style="color:#a0a1a7"><em>//父类存在的地方,子类就应该能够存在 </em></span>
<span style="color:#986801">Son</span> <span style="color:#986801">f</span> <span style="color:#ab5656">=</span><span style="color:#a626a4">new</span> <span style="color:#4078f2">Son</span>();
<span style="color:#986801">HashMap</span> <span style="color:#986801">map</span> <span style="color:#ab5656">=</span> <span style="color:#a626a4">new</span> <span style="color:#4078f2">HashMap</span>();
f.doSomething(map);
}
<span style="color:#a626a4">public</span> <span style="color:#a626a4">static</span> <span style="color:#a626a4">void</span> <span style="color:#4078f2">main</span>(String[] args) {
invoker();
}
}
</code></strong>
运行结果还是一样。父类方法的输入参数是HashMap类型,子类的输入参数是Map类型,也就是说子类的输入参数类型的范围扩大了,子类代替父类传递到调用者中,子类的方法永远都不会被执行。这是正确的,如果你想让子类的方法运行,就必须覆写父类的方法。大家可以这样想,在一个Invoker类中关联了一个父类,调用了一个父 类的方法,子类可以覆写这个方法,也可以重载这个方法,前提是要扩大这个前置条件,就是输入参数的类型宽于父类的类型覆盖范围。这样说可能比较难理解,我们再反过来想一 下,如果Father类的输入参数类型宽于子类的输入参数类型,会出现什么问题呢?会出现父类存在的地方,子类就未必可以存在,因为一旦把子类作为参数传入,调用者就很可能进入 子类的方法范畴。
把上面的例子修改一下,扩大父类的前置条件:
<strong><code class="language-java"><span style="color:#a626a4">public</span> <span style="color:#a626a4">class</span> <span style="color:#4078f2">Father</span> {
<span style="color:#a626a4">public</span> Collection <span style="color:#4078f2">doSomething</span>(Map map){
System.out.println(<span style="color:#50a14f">"父类被执行..."</span>);
<span style="color:#a626a4">return</span> map.values();
}
}
</code></strong>
把父类的前置条件修改为Map类型,再修改一下子类方法的输入参数,相对父类缩小输入参数的类型范围,也就是缩小前置条件:
<strong><code class="language-java"><span style="color:#a626a4">public</span> <span style="color:#a626a4">class</span> <span style="color:#4078f2">Son</span> <span style="color:#a626a4">extends</span> <span style="color:#4078f2">Father</span> {
<span style="color:#a0a1a7"><em>//缩小输入参数范围 </em></span>
<span style="color:#a626a4">public</span> Collection <span style="color:#4078f2">doSomething</span>(HashMap map){
System.out.println(<span style="color:#50a14f">"子类被执行..."</span>);
<span style="color:#a626a4">return</span> map.values();
}
}
</code></strong>
在父类的前置条件大于子类的前置条件的情况下,业务场景:
<strong><code class="language-java"><span style="color:#a626a4">public</span> <span style="color:#a626a4">class</span> <span style="color:#4078f2">Client</span> {
<span style="color:#a626a4">public</span> <span style="color:#a626a4">static</span> <span style="color:#a626a4">void</span> <span style="color:#4078f2">invoker</span>(){
<span style="color:#a0a1a7"><em>//有父类的地方就有子类</em></span>
Father f= <span style="color:#a626a4">new</span> <span style="color:#4078f2">Father</span>();
<span style="color:#986801">HashMap</span> <span style="color:#986801">map</span> <span style="color:#ab5656">=</span> <span style="color:#a626a4">new</span> <span style="color:#4078f2">HashMap</span>();
f.doSomething(map);
}
<span style="color:#a626a4">public</span> <span style="color:#a626a4">static</span> <span style="color:#a626a4">void</span> <span style="color:#4078f2">main</span>(String[] args) {
invoker();
}
}
</code></strong>
运行结果:
<strong><code class="language-java">父类被执行...
</code></strong>
再把里氏替换原则引入进来——有父类的地方子类就可以使用,把Client类修改一下:
<strong><code class="language-java"><span style="color:#a626a4">public</span> <span style="color:#a626a4">class</span> <span style="color:#4078f2">Client</span> { <span style="color:#a626a4">public</span> <span style="color:#a626a4">static</span> <span style="color:#a626a4">void</span> <span style="color:#4078f2">invoker</span>(){
<span style="color:#a0a1a7"><em>//有父类的地方就有子类 </em></span>
<span style="color:#986801">Son</span> <span style="color:#986801">f</span> <span style="color:#ab5656">=</span><span style="color:#a626a4">new</span> <span style="color:#4078f2">Son</span>();
<span style="color:#986801">HashMap</span> <span style="color:#986801">map</span> <span style="color:#ab5656">=</span> <span style="color:#a626a4">new</span> <span style="color:#4078f2">HashMap</span>();
f.doSomething(map);
}
<span style="color:#a626a4">public</span> <span style="color:#a626a4">static</span> <span style="color:#a626a4">void</span> <span style="color:#4078f2">main</span>(String[] args) {
invoker();
}
}
</code></strong>
运行结果:
<strong><code class="language-java">子类被执行...
</code></strong>
子类在没有覆写父类的方法的前提下,子类方法被执行了,这会引起业务 逻辑混乱,因为在实际应用中父类一般都是抽象类,子类是实现类,你传递一个这样的实现 类就会“歪曲”了父类的意图,引起一堆意想不到的业务逻辑混乱,所以子类中方法的前置条 件必须与超类中被覆写的方法的前置条件相同或者更宽松。
* 覆写或实现父类的方法时输出结果可以被缩小
父类的一个方法的返回值是一个类型T,子类的相同方法(重载或覆写)的返回值为S,那么里氏替换原则就要求S必须小于等于T,也就是说,要么S和T是同一 个类型,要么S是T的子类,为什么呢?分两种情况,如果是覆写,父类和子类的同名方法的 输入参数是相同的,两个方法的范围值S小于等于T,这是覆写的要求,这才是重中之重,子类覆写父类的方法,天经地义。如果是重载,则要求方法的输入参数类型或数量不相同,在 里氏替换原则要求下,就是子类的输入参数宽于或等于父类的输入参数,也就是说你写的这 个方法是不会被调用的,参考上面讲的前置条件。
采用里氏替换原则的目的就是增强程序的健壮性,版本升级时也可以保持非常好的兼容性。即使增加子类,原有的子类还可以继续运行。在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑。
245

被折叠的 条评论
为什么被折叠?



