凡事皆有利弊,面向对象设计语言通过提供继承、多态等机制使得项目代码更具有复用性、可扩展性等优点,但是这些机制也存在一定的使用风险,比如继承的滥用、多态实现的不确定性等问题都有可能会引起重大线上事故。
一、里氏替换原则概念
里氏替换原则由Barbara Liskov发表是在1994年一篇学术论文《A behavioral notion of subtyping》。这篇论文主要讨论了继承机制下基类与子类的关系,文中认为子类型关系(继承)应保持子类型对象的行为特征与基类的行为特征相同,而仅非结构特征,即子类对象应该能够替代基类对象并保持程序的正确性。这就是历史替换原则,用更加非学术的语言定义可以简述为:所有引用基类的地方必须能够显示替换为其子类对象。
里氏替换原则讲述的是在使用继承来处理类之间关系时遵循的约束,若不遵从该原则就可能会使得程序出现异常或错误。在定义中,我们需要先来理解下"(可)替换"这个词。(可)替换一方面是指编译不会报错,这就意味着子类必须拥有基类的所有方法;另外一方面是指运行不会报错。前面说了里氏替换原则要求子类的行为特征与基类相同,因此子类的方法的业务含义必须与基类保持一致或兼容。满足了这两点,我才认为子类对于基类时可替换的,即满足里氏替换原则。
【总结】里氏替换原则对继承使用提出了两个约束:
- 子类必须拥有基类的所有方法
- 子类继承的方法业务含义必须与基类保持一致或兼容
第1个约束我们十分容易理解,不满足编译自然会报错。第二个约束中我们下节展开叙述。
二、子基类行为特征一致
前面他们提到里氏替换原则要求子类继承并实现基类方法必须保持一致或兼容(学术话语即使行为特征兼容),本节将会通过示例来阐述兼容含义与意义。
假设你所在的公司接到一个汽车制造业务相关需求,需求的其中一部分是实现汽车的加油方法内容。初期设计方案如下:
如上类图所示,类GasolineCar继承了类Car,Car中定义了表示给汽车补充燃油的方法。此时Client方法内容为:
public class Client {
public static void main(String[] args) {
Car car = new GasolineCar(); // 获取汽车实例
addRawPowerToCar(car); // 汽车补充燃料
}
private static void addRawPowerToCar(Car car) {
System.out.println("1. 前去中石化加油站...");
System.out.println("2. 拿起95油枪...");
car.refuel();
}
}
public class GasolineCar extends Car {
@Override
public void refuel() {
System.out.println("补充汽油燃料...");
}
}
如上所示系统运行一切正常。此时如果业务需要添加一款新能源电车,此时你可能会考虑到继承Car,即可享受继承带来的复用性减少重复开发的人力,但似乎一切还为时尚早,我们先看下类图的改动:
类图中ElectricCar继承Car接口的refuel方法,内部实现应为:
public class ElectricCar extends Car{
@Override
public void refuel() {
System.out.println("快速充电...");
}
}
这里就存在问题,Car的refuel方法业务语义是"补充燃料(可燃烧)“,然而ElectricCar实现了refuel方法的语义改变为了"补充电力”,这就是前面说的子类实现的方法业务含义与基类方法不一致或不兼容。这导致问题是Client中的addRawPowerToCar方法将会出现歧义或异常,ElectricCar的实例也可以作为该方法的入参,但是行为确实不符合业务预期的,即电车怎么能够去中石化加油站拿起油枪进行快速充电呢,这就是可能存在的问题。因此ElectricCar继承Car就是不符合里氏替换原则。
那我们针对这个需求如何改动呢?首先我们知道新能源电车(ElectricCar)是不需要加油的,不需要也不能重写refuel方法。因此我们可以基类Car中补充能源的方法。类图及核心代码如下:
public class GasolineCar extends Car {
@Override
public void refuel() {
System.out.println("补充汽油燃料...");
}
@Override
public void charge() {
throw new UnsupportedOperationException("GasolineCar 不支持充电");
}
}
public class ElectricCar extends Car{
@Override
public void refuel() {
throw new UnsupportedOperationException("ElectricCar 不支持添加燃油");
}
@Override
public void charge() {
System.out.println("加油充电...");
}
}
如上所示,Car增加charge方法表示补充电车,ElectricCar 继承Car时必须重写refuel方法,通过内部抛出异常的方式告知ElectricCar 不允许调用该方法,上游业务侧代码应该做好充分的考虑。相对于上面不符合里氏替换原则的代码,还有一个好处。如果后续增加混动汽车(HybridCar)也可以继承Car方法并分别重写refuel方法和charge,将补充燃料和补充电能业务功能区分开。因此这也表明了符合里氏替换原则的代码也有利于代码符合开闭原则的要求。
我们再继续思考下,第二版的方案虽然能够满足开闭原则及里氏替换原则,那符合单一职责原则吗?我认为是不符合的,从代码运行时的角度上将,确实ElectricCar#refuel()、GasolineCar#charge()会抛出异常,但是如果之后公司业务下线燃油车,ElectricCar是否也需要跟着变动,这就不符合单一职责原则了。不仅如此,如果业务之后再添加其他能源的车,如太阳能、煤炭能、氢能等等(ps,我只是举个栗子),那基类Car及其子类是否都需要改动,并且某种能源类型会有多少方法会抛出异常,非常影响代码的整洁性。因此更优的方案考虑类图如下:(代码省略…)
三、补充说明
在学习里氏替换原则时,翻了下网上关于该原则的讲述,对于其中的举例和理解我个人表示不是十分赞同。这一类教程会通过"正常形非长方形"、“鸵鸟非鸟”、"几维鸟非鸟"来说明里氏替换原则的意义。个人不认为这些是里氏替换原则要解决的问题,原则要解决的是子类实现的基类方法要和基类保持一致,即行为特征一致而非结构特征。换句话说,里氏替换原则是通过方法具备的业务含义(行为特征)来进一步完善继承机制,在之前继承是被片面的认为属性继承。
“正常形非长方形”,教程(本文不引用)中认为正方形具备"宽==高"的特点,而在长方形的业务代码中没有要求这个约束。假设此时业务代码中某方法,需要判断长方形面积是否为20,否则抛出异常。这样的方法对于正方形而言,是肯定报错的。但是这就是违背了里氏替换原则吗?试想长方形就一定不报错吗?正常形替换长方形传入判断面积,抛异常难道不符合业务逻辑?这个案例完全没有理解里氏替换原则的内涵,甚至行为方法都根本不涉及。
另外一点,这个案例还犯有另外一个错,也是学习设计模式的人经常混淆的问题。学习设计模式示例代码一定要区分业务代码以及设计模式代码。业务代码在本系列文章中使用Client类代替,在实际开发项目中业务代码是各种各样的。而设计模式代码就是我们通过精心设计的尽可能满足设计原则的代码。设计模式代码需要尽可能满足一切业务代码的要求,包括可扩展性、维护性、复用性等等。“长方形判断面积”这块代码就是属于业务代码,正方形继承于长方形,并不影响业务代码吧。
“鸵鸟非鸟”,教程中认为鸵鸟不会飞,因此在使用到bird.fly的业务代码中,不能让鸵鸟类继承于鸟类。首先里氏替换原则不是在讲类之前是否应该有继承关系,而且如何更好的使用继承关系且不使得业务代码困惑。鸵鸟类不能飞如本文第二章节所述电车不能加油一样,"鸵鸟不能飞"没有违背里氏替换原则,而电车在refuel方法中充电才是违背原则(业务代码会困惑、危险)。从继承机制上来说,继承的本质是为了代码复用以及扩展。子类是肯定允许有个性化内容的,谁规定鸟类就必须会飞?谁规定车Car就必须得加油?那你告诉我代码怎么改?本文最后的设计方案,不仅满足里氏替换原则,并且特斯拉电车也是继承或实现了Car基本类。【重点:里氏替换原则重点不是在讨论类之间应不应该继承的问题】
"几维鸟非鸟"案例中,教程认为几维鸟行动缓慢,行动速度为0。这将导致业务代码中计算鸟类跑动一段路程所需的时间时,因为速度在分母上,几维鸟实例替换基类就会导致除0异常,不能替换。首先这个案例也是否定了子类应有的个性化内容,谁规定了鸟的速度不能为0?替换抛除0异常是业务代码考虑不全的问题,这问题跟几维鸟继承鸟根本没有关系。因为业务代码的问题或设计影响其依赖的设计模式代码完全是本末倒置。
【总结】
- 里氏替换主要描述的是子类继承自基类的方法含义要和基类定义的业务含义一致
- 类之间是否需要继承关系,主要考虑是否需要通过继承来获得代码复用和扩展的能力。这于里氏替换原则没有关系,先有继承关系,再考虑里氏替换原则。
- 学习设计模式,区分业务代码和设计模式代码,并且前者依赖后者实现业务功能,后者需满足前者的变化并且具备扩展性、复用性、可读性等等。业务代码的设计不会影响到设计模式代码,而设计模式代码的设计会影响到业务代码的使用,切莫本末倒置。