王大虎

王大虎:里氏替换原则

王大虎

2.1 爱恨纠葛的父子关系

在面向对象的语言中,继承是必不可少的、非常优秀的语言机制,它有如下优点:

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

·提高代码的重用性;

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

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

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

自然界的所有事物都是优点和缺点并存的,即使是鸡蛋,有时候也能挑出骨头来,继承的缺点如下:

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

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

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

Java使用extends关键字来实现继承,它采用了单一继承的规则,C++则采用了多重继承的规则,一个子类可以继承多个父类。从整体上来看,利大于弊,怎么才能让“利”的因素发挥最大的作用,同时减少“弊”带来的麻烦呢?解决方案是引入里氏替换原则(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.(所有引用基类的地方必须能透明地使用其子类的对象。)

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

王大虎

2.2 纠纷不断,规则压制

里氏替换原则为良好的继承定义了一个规范,一句简单的定义包含了4层含义。

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

我们在做系统设计时,经常会定义一个接口或抽象类,然后编码实现,调用类则直接传入接口或抽象类,其实这里已经使用了里氏替换原则。我们举个例子来说明这个原则,大家都打过CS吧,非常经典的FPS类游戏,我们来描述一下里面用到的枪,类图如图2-1所示。

 

图2-1 CS游戏中的X类图

枪的主要职责是射击,如何射击在各个具体的子类中定义,X是单发射程比较近,X威力大射程远,X用于扫射。在士兵类中定义了一个方法killEnemy,使用枪来杀敌人,具体使用什么枪来杀敌人,调用的时候才知道,AbstractGun类的源程序如代码清单2-1所示。

代码清单2-1 X的抽象类

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

X、X、X的实现类如代码清单2-2所示。

代码清单2-2 X、X、X的实现类

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

有了X,还要有能够使用这些X的士兵,其源程序如代码清单2-3所示。

代码清单2-3 士兵的实现类

public class Soldier { //定义士兵的X private AbstractGun gun; //给士兵一支枪 public void setGun(AbstractGun _gun){ this.gun = _gun; } public void killEnemy(){ System.out.println(“士兵开始杀敌人…”); gun.shoot(); } }

 

注意粗体部分,定义士兵使用枪来杀敌,但是这把枪是抽象的,具体是X还是X需要在上战场前(也就是场景中)前通过setGun方法确定。场景类Client的源代码如代码清单2-4所示。

王大虎

代码清单2-4 场景类

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

有人,有枪,也有场景,运行结果如下所示。

王大虎
王大虎

士兵开始杀敌人… X射击…

在这个程序中,我们给三毛这个士兵一把X,然后就开始杀敌了。如果三毛要使用X,当然也可以,直接把sanMao.killEnemy(new Rifle())修改为sanMao.killEnemy(new MachineGun())即可,在编写程序时Solider士兵类根本就不用知道是哪个型号的枪(子类)被传入。

注意 在类中调用其他类时务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已经违背了LSP原则。

我们再来想一想,如果我们有一个玩具X,该如何定义呢?我们先在类图2-1上增加一个类ToyGun,然后继承于AbstractGun类,修改后的类图如图2-2所示。

 

图2-2 X类图

首先我们想,玩具枪是不能用来射击的,杀不死人的,这个不应该写在shoot方法中。新增加的ToyGun的源代码如代码清单2-5所示。

代码清单2-5 玩具枪源代码

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

由于引入了新的子类,场景类中也使用了该类,Client稍作修改,源代码如代码清单2-6所示。

代码清单2-6 场景类

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

修改了粗体部分,X具枪传递给三毛用来杀敌,代码运行结果如下所示:

 

士兵开始杀敌人…

 

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

·在Soldier类中增加instanceof的判断,如果是玩具枪,就不用来杀敌人。这个方法可以解决问题,但是你要知道,在程序中,每增加一个类,所有与这个父类有关系的类都必须修改,你觉得可行吗?如果你的产品出现了这个问题,因为修正了这样一个Bug,就要求所有与这个父类有关系的类都增加一个判断,客户非跳起来跟你干架不可!你还想要客户忠诚你吗?显然,这个方案被否定了。

·ToyGun脱离继承,建立一个独立的父类,为了实现代码复用,可以与AbastractGun建立关联委托关系,如图2-3所示。

 

王大虎

图2-3 玩具枪与真实枪分离的类图

例如,可以在AbstractToy中声明将声音、形状都委托给AbstractGun处理,X嘛,形状和声音都要和真实的枪一样了,然后两个基类下的子类X延展,互不影响。

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

注意 如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生“畸变”,则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。

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

子类当然可以有自己的行为和外观了,也就是方法和属性,那这里为什么要再提呢?是因为里氏替换原则可以正着用,但是不能反过来用。在子类出现的地方,父类未必就可以胜任。还是以刚才的关于X的例子为例,X有几个比较“响亮”的型号,比如AK47、AUG狙击X等,把这两个型号的枪引入后的Rifle子类图如图2-4所示。

 

发表评论

电子邮件地址不会被公开。 必填项已用*标注