继承与里氏替换原则

继承的优点:

  • 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;
  • 提高代码的重用性;
  • 提高代码可扩展性,实现父类的方法后就可以“为所欲为”了;
  • 提高产品和项目的开放性。

继承的缺点:

  • 继承是入侵性的,只要继承就必须拥有父类的所有属性和方法;
  • 降低代码的灵活性,子类必须拥有父类的属性和方法,收到了约束;
  • 增强了耦合性,父类的常量、变量和方法被修改,要考虑子类的修改,可能导致大量的代码重构。

引入 里氏替换原则(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 refernences to base classes must be able to use objects of derived classes without knowing it.(所有引用基类的地方必须能透明地使用其子类的对象)

通俗地说,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或异常,反过来则不成立。


里氏替换原则的规则

里氏替换原则为良好的继承定义了规范,包含了四层含义:

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

对于描述一款FPS游戏中的抢,其类图如下:
image.png
枪的主要职责是设计,具体如何射击在子类中定义。士兵类中定义了killEnemy方法击杀敌人,具体使用什么强击杀敌人调用时才知道。 AbstractGun类 的源程序代码如下:

  1. // 枪支的抽象类
  2. public abstract class AbstractGun {
  3. public abstract void shoot();
  4. }

手枪、步枪、机枪的实现类 源程序代码如下:

  1. public class HandGun extends AbstractGun {
  2. @Override
  3. public void shoot() {
  4. System.out.println("手枪射击");
  5. }
  6. }
  7. public class Rifle extends AbstractGun {
  8. @Override
  9. public void shoot() {
  10. System.out.println("步枪射击");
  11. }
  12. }
  13. public class MachineGun extends AbstractGun {
  14. @Override
  15. public void shoot() {
  16. System.out.println("机枪扫射");
  17. }
  18. }

使用枪支的 士兵类 源程序代码如下:

  1. public class Soldier {
  2. // 定义士兵的枪支
  3. private AbstractGun gun;
  4. // 给士兵一支枪
  5. public void setGun(AbstractGun _gun) {
  6. this.gun = _gun;
  7. }
  8. public void killEnemy() {
  9. System.out.println("士兵开始射击敌人");
  10. gun.shoot();
  11. }
  12. }

定义士兵使用枪支杀敌,但这把枪是抽象的,具体是什么强还需要在场景中通过setGun方法确定。 场景类Client 的源代码如下:

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

在这个程序中,直接传入需要给三毛的配枪即可,编写程序时Soldier士兵类无需知道哪个型号的枪被传入。

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

如果新增一个玩具手枪ToyGun类,类图如下:
image.png
玩具枪是不能用来射击的,杀不死人,因此不应该实现shoot方法,代码如下:

  1. public class ToyGun extends AbstractGun {
  2. @Override
  3. public void shoot() {
  4. // 玩具枪不能设计,因此不实现
  5. }
  6. }

但此时在Client类中的main函数中,会出现将玩具枪分配给三毛,但是不能射击的情况,即业务逻辑已经出现问题不能运行。有两种解决办法:

  • 在Solider类汇总增加 instanceof 来判断是否是玩具枪。但这样解决,每增加一个类,所有与这个父类有关系的类都需要修改。
  • 令ToyGun脱离继承,建立一个独立的父类。为了实现代码复用可以与AbstractGun建立关联关系,类图如下:

image.png
例如,可以在AbstractToy中关联AbstractGun,将声音、形状等都委托给AbstractGun处理,两个基类下的子类自由延展互不影响。代码如下(笔者自己理解):

  1. // 枪的抽象类
  2. public abstract class AbstractGun {
  3. public abstract void shoot();
  4. public abstract void sound();
  5. }
  6. // 玩具的抽象类
  7. public abstract class AbstractToy {
  8. // 关联AbstractGun
  9. public AbstractGun gun;
  10. public abstract void play();
  11. }
  12. // 手枪类
  13. public class HandGun extends AbstractGun {
  14. @Override
  15. public void shoot() {
  16. System.out.println("shootshootshoot");
  17. }
  18. @Override
  19. public void sound() {
  20. System.out.println("Bangabngabng");
  21. }
  22. }
  23. // 玩具枪类
  24. public class ToyGun extends AbstractToy {
  25. public ToyGun(AbstractGun _gun) {
  26. gun = _gun;
  27. }
  28. @Override
  29. public void play() {
  30. System.out.println("Play Toy ~~");
  31. gun.sound();
  32. }
  33. }
  34. // 场景类
  35. public class Client {
  36. public static void main(String[] args) {
  37. AbstractToy toyGun = new ToyGun(new HandGun());
  38. toyGun.play();
  39. }
  40. }

运行结果:

  1. Play Toy ~~
  2. Bangabngabng

具体应用场景中,需要考虑子类是否能够完整地实现父类的业务,否则会导致业务逻辑出错。

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

子类可以有自己的方法和属性

子类可以有自己的方法和属性,这导致里氏替换原则可以正向使用,不能反向使用。即在子类出现的地方,父类未必适用。例如对步枪Rifle类,有AK47、AUG狙击步枪等子类,类图如下:
image.png
AUG继承Rifle类,Snipper直接使用AUG步枪。AUG类代码如下:

  1. public class AUG extends Rifle {
  2. // 狙击镜
  3. public void zoomOut() {
  4. System.out.println("观察敌人");
  5. }
  6. public void shoot() {
  7. System.out.println("AUG shoot");
  8. }
  9. }

狙击手Snipper类如下:

  1. public class Snipper {
  2. public void killEnemy(AUG aug) {
  3. aug.zoomOut();
  4. aug.shoot();
  5. }
  6. }

业务场景类Client源代码如下:

  1. public class Client {
  2. public static void main(String[] args) {
  3. Snipper sanMao = new Snipper();
  4. sanMao.setRifle(new AUG());
  5. sanMao.killEnemy();
  6. }
  7. }

这里直接调用了子类AUG。如果传入父类,Client类修改如下:

  1. public class Client {
  2. public static void main(String[] args) {
  3. Snipper sanMao = new Snipper();
  4. // 此处传入父类,然后强转
  5. sanMao.setRifle((AUG)new Rifle());
  6. sanMao.killEnemy();
  7. }
  8. }

会抛出java.lang.ClassCastException异常,即向下转型(downcast)是不安全的,即里氏替换原则中,有子类出现的地方父类未必可以出现。

重写或实现父类的方法时输入参数可以被放大

对于父类和子类的Java代码:

  1. public class Father {
  2. public Collection doSomething(HashMap map) {
  3. System.out.println("父类被执行");
  4. return map.values();
  5. }
  6. }
  7. public class Son extends Father {
  8. // 放大输入参数
  9. public Collection doSomething(Map map) {
  10. System.out.println("子类被执行");
  11. return map.values();
  12. }
  13. }

doSomething 方法子类与父类方法名相同,但参数由HashMap放大为了Map,属于重载(Overload)。

对于场景类代码:

  1. public class Client {
  2. public static void invoker() {
  3. // 父类存在的地方,子类就应该能够存在
  4. Father f = new Father();
  5. HahsMap map = new HashMap();
  6. f.doSomething(map);
  7. }
  8. public static void main(String[] args) {
  9. invoker();
  10. }
  11. }

运行结果为:

  1. 父类被执行

根据里氏替换原则,用子类替换父类:

  1. public class Client {
  2. public static void invoker() {
  3. // 父类存在的地方,子类就应该能够存在
  4. Son f = new Son();
  5. HahsMap map = new HashMap();
  6. f.doSomething(map);
  7. }
  8. public static void main(String[] args) {
  9. invoker();
  10. }
  11. }

运行结果与之前相同。 原因是子类的输入参数扩大了,为Map类型,子类代替父类传递到调用者中,子类方法永远不会执行, 这样的业务逻辑是正确的 。如果想让子类的方法运行,就 必须 重写父类的方法。

子类可以重载父类的方法,但前提是输入参数必须宽于父类的类型覆盖范围。如果Father类的输入参数类型宽于Son类,则会出现父类存在的地方子类未必可以存在的情况。一旦把子类作为参数传入,调用者就很可能进入子类方法的范畴。

如果扩大父类的前置条件,使父类前置条件较大,子类前置条件较小,代码如下:

  1. public class Father {
  2. public Collection doSomething(Map map) {
  3. System.out.println("父类被执行");
  4. return map.values();
  5. }
  6. }
  7. public class Son extends Father {
  8. // 缩小输入参数
  9. public Collection doSomething(HashMap map) {
  10. System.out.println("子类被执行");
  11. return map.values();
  12. }
  13. }

父类前置条件大雨子类的情况下,业务场景代码:

  1. public class Client {
  2. public static void invoker() {
  3. // 父类存在的地方,子类就应该能够存在
  4. Father f = new Father();
  5. HahsMap map = new HashMap();
  6. f.doSomething(map);
  7. }
  8. public static void main(String[] args) {
  9. invoker();
  10. }
  11. }

运行结果:

  1. 父类被执行

引入里氏替换原则,修改业务场景:

  1. public class Client {
  2. public static void invoker() {
  3. // 父类存在的地方,子类就应该能够存在
  4. Son f = new Son();
  5. HahsMap map = new HashMap();
  6. f.doSomething(map);
  7. }
  8. public static void main(String[] args) {
  9. invoker();
  10. }
  11. }

运行结果:

  1. 子类被执行

出现了没有重写父类方法的前提下,子类方法被执行了的情况,会引起业务混乱。

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

里氏替换原则要求,子类重写或重载父类的方法时,返回类型的范围必须小于等于父类的返回值类型范围。


最佳实践

实践中采用里氏替换原则时:

  • 子类符合相关规范,否则会造成子类和父类之间关系难以调和;
  • 避免把子类单独作为一个业务使用,这会让代码间的耦合关系变得扑朔迷离,缺乏类替换的标准。