[5+1]里氏替换原则(一)
前言
面向对象的SOLID设计原则,外加一个迪米特法则,就是我们常说的5+1设计原则。
这六个设计原则的位置有点不上不下。
论原则性和理论指导意义,它们不如封装继承抽象或者高内聚低耦合,所以在写代码或者code review的时候,它们很难成为“应该这样做”或者“不应该这样做”的一个有说服力的理由。
论灵活性和实践操作指南,它们又不如设计模式或者架构模式,所以即使你能说出来某段代码违反了某项原则,常常也很难明确指出错在哪儿、要怎么改。
所以,这里来讨论讨论这六条设计原则的“为什么”和“怎么做”。顺带,作为面向对象设计思想的一环,这里也想聊聊它们与抽象、高内聚低耦合、封装继承多态之间的关系。
里氏替换原则
是什么
里氏替换原则(Liskov Substitution principle)是一条针对对象继承关系提出的设计原则。它以芭芭拉·利斯科夫(Barbara Liskov)的名字命名。1987年,芭芭拉在一次名为“数据的抽象与层次”的演讲中首次提出这条原则;1994年,芭芭拉与另一位女性计算机科学家周以真(Jeannette Marie Wing)合作发表论文,正式提出了这条面向对象设计原则。
ps,以后再有人说女生不适合做IT,请把里氏替换原则甩Ta脸上:这是由两位女性提出来计算机理论。其中一位(芭芭拉)获得过图灵奖和冯诺依曼奖;另一位(周以真)则是ACM和IEEE的会员。言归正传,芭芭拉和周以真是这样定义里氏替换原则的:
Let q(x) be a property provable about objects x of type T. Then q(y) should be true for objects y of type S where S is a subtype of T.
维基百科·里氏替换原则
简单翻译一下,意思是:已知x是类型T的一个对象、y是类型S的一个对象,且类型S是类型T的子类;令q(x)为true;那么,q(y)也应为true。
数学语言虽然凝练、精准,但是抽象、费解。这个定义也是这样。所以在应用中,我们会将原定义解读为:
“派生类(子类)对象可以在程序中代替其基类(超类)对象。”
把两种定义综合一下,里氏替换原则大概就是这样的:子类只重写父类中抽象方法,而绝不重写父类中已有具体实现的方法。
为什么
细究起来,只有在我们用父类的上下文——例如入参、出参——来调用子类的方法时,里氏替换原则才有指导意义。
例如,我们有这样两个类:
public class Calculator{
public int calculate(int a, int b){return a + b;}
}
public class ClaculatorSub extends Calculator{
@Override
public int calculate(int a, int b){return a / b;}
}
显然,我们可以用a=1、b=0这组参数去调用父类;但是不能直接用它来调用子类。否则的话,由于除数为0,一调用子类方法就会抛出异常。
// 父类可以正常处理a=1、b=0这组参数。
// 然而对子类来说,虽然编译期间不会报错,但是在运行期间会抛出异常。
Calculator calculator = new CalculatorSub();
c = calculator.calculate(1,0);
应对这种问题,我们就要以里氏替换原则为指导,好好地设计一下类继承关系了。
=================================
由于场景受限,里氏替换法则很少出现在我们的讨论之中。
最常见的原因是,很多人的代码中根本就不会出现父子类关系,更不会出现子类替换父类这种场景。很多人的代码中,一个接口下只有一个实现类;很多人的代码中,连接口都没有,直接使用public class。
用面向对象的技术,写面向过程的代码,就像开着歼20跑高速一样。什么“眼镜蛇”、“落叶飘”,根本无从谈起。
而在使用了继承的场景中,当需要用子类来替换父类时,大多数时候,我们都会保证只用父类的上下文去调用父类、只用子类的上下文去调用子类。这样一来,场景中就不会出现使用父类的上下文去调用子类方法的情况。因而,里氏替换原则也失去了它的用武之地。
=================================
那么,难道大名鼎鼎的里氏替换原则,到头来就只能用于纸上谈兵了吗?
倒也不是。虽然里氏替换原则的路走得有点窄,但是它却很适用于CS模式中版本兼容的场景。
在这个场景中,用户可以用低版本的客户端来调用最新版本的服务端。这跟“用父类的上下文来调用子类的方法”不是异曲同工的吗?
当然,版本兼容问题可以有很多种方案。不过,万变不离其宗,各种各样的方案中,都有“子类应当可以替代父类”这条基本原则的影子。泛化一点来说,“版本兼容”也并不仅仅是CS模式需要考虑的问题,而是所有需要处理存量数据的系统所必须考虑的问题——老版本客户端也可以被理解为一种“存量数据”。
这类问题的本质就是使用存量数据调用新功能的问题,也就是使用父类上下文调用子类方法的问题。显然的,里氏替换原则就是为这类问题量身定制的。
=================================
不仅如此,里氏替换原则还为“如何设计继承层次”提供了另一个标准。我们知道,只有“is-a”关系才应当使用继承结构。里氏替换原则提出了一个新的要求:子类不能重写父类已经实现了的具体方法。反过来说,如果子类必须重写父类方法才能实现自己的功能,那就说明,这两个类不构成继承关系。此时,我们就应该用其它结构改写这种父子结构。
显然,这是一个更可行的要求。对于什么样的关系是“is-a”、什么样的关系是“like-a”,我们没有一个硬性指标。但是,子类有没有修改父类的方法、被修改的父类方法有没有具体实现,这是一望而知、非此即彼的事情。因而,这个标准的可操作性非常高。
同时,这是一个更严格的要求。按照这个要求,所有的非抽象类都不能拥有子类。因为这种子类只能做三件事情:重写父类的方法,或者修改父类的属性,或者增加新的方法。
重写父类非抽象方法是里氏替换原则所禁止的行为。自然地,我们一般不会这样做。
如果不重写父类方法、只修改父类属性,则完全可以通过多实例来实现,没必要使用继承结构。考虑到继承会带来高耦合问题,还是能不用就不用吧。
增加新的方法会使得子类“突破”父类的抽象。“突破抽象声明”这种事情,很容易增加模块耦合度——原本调用方只需依赖父类,此时不得不依赖子类。
在这种场景下,我更倾向于使用组合,而非继承。例如这段代码:
public class Service{
public void doSth(){
// 略,父类方法
}
}
public class Service1 extends Service{
public void doOtherThing(){
// 略,子类扩展的新方法,用到了父类的方法
doSth();
}
}
public class Service2{
private Service s = new Service();
public void doOtherThing(){
// 通过组合来扩展子类功能
s.doSth();
}
}
public class Test{
public static void main(String... args){
// 使用继承来扩展
// 原代码:只调用父类方法,使用父类即可
// Service s = new Service();
// 需要使用子类方法,所以必须使用子类
Service1 s = new Service1();
s.doSth();
// 使用子类方法
s.doOtherThing();
// 使用组合来扩展
// 原代码:只调用父类方法,使用父类即可
Service s1 = new Service();
s.doSth();
// 需要使用新方法的地方,增加新的调用代码
Service2 s2 = new Service2();
// 使用子类方法
s2.doOtherThing();
}
}
对比Test类中的两段代码可以发现,在子类增加新方法的这种场景下,使用组合比使用继承更符合“开闭”原则。毕竟,在使用组合时,调用父类的代码没有做任何改动。而在使用继承时,调用父类的地方被改为了调用子类——而这种修改,就是典型的“使用父类上下文调用子类”的场景。在这种场景中,我们需要小心翼翼地遵循里氏替换原则、维护父子类关系,才能避免出现问题。
综上所述,严格遵循里氏替换原则就禁止(至少是不提倡)我们继承非抽象类。然而,如果禁止继承非抽象类,类的个数和层级结构都会变得非常复杂,因而,开发工作量也会变得非常大。所以,在实践中,我们往往会对里氏替换原则做一些“折中”处理。
怎么做
如果不继承非抽象类,类的继承结构会变得非常复杂。并且,在继承层次由简单变复杂的过程中,我们要付出的工作量也会增加。例如,我们原有这样一个服务类:
这个类只是简单地把接口定义的方法interfaceMthod拆分为四个步骤:valid()/prepare()/doMethod()和triggerEvent()。这四个方法都只需要提供给ServiceImp类自己调用,因此它们全都被声明为私有方法。
随着业务需求的发展,我们需要一个新的子类。与ServiceImpl相比,这个子类的prepare()和doMethod()逻辑有所不同,valid()和triggerEvent()则一模一样。我们有三种方式来实现这个新的子类:直接继承ServiceImpl、为ServiceImpl和新的子类抽取公共父类,以及使用组合。这几种方式的类图如下所示:
相信大家看得出来:第一种方式的开发工作量最少。但是,第一种方式恰恰就违反了里氏替换原则:子类ServiceImplA重写了父类ServiceImpl中的非抽象方法prepare()和doMethod()。
如果使用第二种方式,我们首先要新增一个父类ServiceTemplate,然后改写原有的ServiceImpl类;最后才可以开发新的类ServiceImplA。显然,与第一种方式相比,新增ServiceTemplate和修改ServiceImpl都需要付出额外的开发工作量。
如果不使用继承、而使用组合,开发工作量与第一种方式相似。但是,它会带来一个新的问题:ServiceImplA与ServiceImpl之间,不再是“面向接口编程”,而是“面向具体类编程”了。这问题恐怕比违反历史替换原则还要严重些。
如果要“面向接口编程”,那么我们需要为ServiceImpl增加一个辅助接口——也就是上图中的第四种方式,使用组合并面向接口编程。但是,第四种方式也需要付出额外的工作量。
质量与工作量(以及藏在工作量背后的工期和成本),这是一对矛盾。一味追求质量而忽视工作量,不仅不符合项目管理的目标,甚至有违人的天性。人们把完美主义称为“龟毛”,把偷懒称为“第一动力”,这不是没有道理的。
在这场由里氏替换原则引起的质量与工作量的取舍之间,选择哪一项都有道理。就我个人而言,我比较倾向于采用第一种方式。这种方式不仅工作量小,而且代码复用率高、重复率低。此外,这种方式还很好地遵循了开闭原则:在新增一个子类的同时,我们对父类只做了非常少的修改。
当然,质量要求也不能太低。虽然已经违反了里氏替换原则,但我们还是会要求子类不能重写父类的public方法,而只能重写诸如protected或者default方法——private方法是无法重写的,也就不用额外约束了。
这个要求是从使用场景中提炼出来的。大多数情况下,我们只在模板模式下会使用狭义的继承。这种场景中,父类会在public方法中定义若干个步骤。如果子类需要重写这个public方法,说明子类不需要按照父类定义的步骤、顺序来处理。这时,这两个类之间无法构成“is-a”关系,连继承关系都不应使用,更别提重写public方法了。
诚然,子类继承父类这种做法不仅仅出现在模板模式中。同样的,子类不重写父类的public方法这条约束也不仅限于模板模式。试想,如果连父类的主要方法,子类都要重新实现一遍,那么,这两个是否构成“is-a”的关系、是否真的适用继承结构呢?
=================================
除了把里氏替换原则中的“禁止子类重写父类的非抽象方法”转换为“禁止子类重写父类的public方法”这种折中处理之外,在实践中,我们还有这四条“里氏替换原则实践指南”:
- 禁止子类重写父类的非抽象方法。
- 子类可以增加自己的方法。
- 子类实现父类的方法时,对入参的要求比父类更宽松。
- 子类实现父类的方法时,对返回值的要求比父类更严格。
其中,只有第一条是直接源自里氏替换原则的“定理”,这里就不再赘述了。其它三条都是从里氏替换原则中衍生出来的“推论”。
=================================
子类可以增加自己的方法,其实跟里氏替换原则没有什么直接关系。二者之所以会关联在一起,我觉得,纯粹就是因为“法无禁令即可行”。当然,把话挑明也有好处。“法无禁令”是一个开区间,不仅会让人无所适从,而且可操作空间太大。对操作规范来说,闭区间比开区间更加可行、也更加安全。白名单比黑名单更安全,也是一样的道理。
=================================
子类实现父类方法时,入参约束更宽松、出参约束更严格,这两条推论讨论的主要是参数的业务涵义,即子类入参的内涵应当比父类更广、而出参的内涵则应当比父类更窄。例如,子类入参的取值范围应当比父类更大、而出参的范围则应当比父类小。在前面例举的那个Calculator类及其子类中,父类的入参取值范围是所有整数,而子类的入参的取值范围则是所有非零整数。显然,子类的取值范围比父类小。也正因为这个缘故,这两个类违反了里氏替换原则,因而在使用时会出现问题。
如果从技术的角度来理解第三、第四条约束的话,一般我们会他们和泛型结合起来分析。结合泛型以及边界条件来看,第三、第四条约束可以简单理解为:子类的入参和出参,都应该是父类入参和出参的子类。说起来有点绕,看一眼代码就清楚了。例如,我们有这样两个类:
abstract class ServiceTemplate {
public abstract O service(I i);
}
class Service1 extends ServiceTemplate {
@Override
public Output1 service(Input1 input1) {
return new Output1();
}
}
父类ServiceTemplate中,方法的入参出参,都是通过泛型来声明的。而这两个参数,则都通过extends参数,声明了泛型的上界。对入参来说,类型上界是Input;对出参来说则是Output。这样一来,子类Service1重写的父类方法中,方法入参和出参就必须是Input和Output的子类。在上面的例子中,子类Service1的方法入参和出参,分别是Input1和Output1。虽然没有没有列出它们的定义,但是显然,它们分别继承了Input和Output类。
根据“子类不能重写父类的非抽象方法”以及“子类可以增加自己的方法”,Input1和Output1所包含的信息量都比它们的父类更大。对入参Input1来说,这意味着业务内涵被缩小了。而对出参Output1来说,它的业务内涵则被扩大了。
因此,上面这两个类是符合第三、第四条约束的:子类的入参约束比父类更严格;而出参约束比父类更宽松。它们是符合那四条“里氏替换原则实践指南”的。
=================================
然而,吊诡的是,这两个类其实并不符合里氏替换原则。我们来看下面这段代码:
public class Test {
public static void main(String... args) {
// 父类的调用上下文
Input i = new Input();
// 使用父类ServiceTemplate的地方
ServiceTemplate s = new Service1();
// 下面这行会有一个warning
Output o = s.service(i);
System.out.println(o);
}
}
根据前面的分析,ServiceTemplate和Service1这两个类是符合里氏替换原则的。按里氏替换原则来分析,这段代码似乎并没有问题:使用父类ServiceTemplate的地方,都可以安全地替换为子类Service1。事实上,这段代码也的确可以通过编译——尽管会有一个warning。
然而,这个编译期的warning会在运行期转变成一个ClassCastException:父类并不能安全地替换为子类。有没有感觉像是钻进了一个莫比乌斯环——从环的正面出发,走着走着,就走到了自己的反面。
=================================
是里氏替换原则失灵了吗?我觉得不是。
一种解释是,ServiceTemplate中的service()是一个抽象方法。用原始的定义来理解的话,也就是对类型T的实例x来说,q(x)是无解的。这就使得里氏替换原则的前提不成立。前提不成立,自然结论也不成立。
尽管这个解释还算说得通,但是它却带来了另一个问题。如果接受了这个解释,就意味着我们不能继承抽象类、也不能实现抽象类中的抽象方法了。否则,这对父子类必定违反了里氏替换原则。
另一种解释是,子类Service1在把方法入参约束为时,实际上就违反了里氏替换原则。父类不能安全地转化为子类,这是里氏替换原则在Java在语言层面的一种实现。然而Service1在约束了入参的上界时,实际上已经偷偷摸摸的越过了雷池:它的入参已经悄悄地把父类Input转换为子类Input1了。Service1的那段代码,本质上等价于:
class Service1 extends ServiceTemplate {
@Override
public Output1 service(Input input) {
// 约定泛型上界,等价于做了个强制类型转换
Input1 actualParam = (Input1)input1;
return new Output1();
}
}
所以,从这个解释出发,我们只需要处理好泛型边界带来的类型转换问题即可。例如,我们可以这样:
public class Test {
public static void main1(String... args) {
// 注意下面这一行,从new Input()改成了new Input1()
Input i = new Input1();
ServiceTemplate s = new Service1();
Output o = s.service(i);
System.out.println(o);
}
}
=================================
网上很多文章把这个问题被归入泛型上界与下界的讨论中,也就是所谓“入参限定下界,出参限定上界”。例如上面那段调用代码,就可以这样处理:
public static void main(String... args) {
// 注意下面这两行
Input1 i = new Input1();
ServiceTemplate super Input1, ? extends Output> s =
new Service1();
Output o = s.service(i);
System.out.println(o);
}
在上面的代码中,“? super Input1”为入参限定了下界,即要求入参必须是Input1的某个父类;而“? extends Output”则为出参限定了上界,即要求出参必须是Output的某个子类。这样也可以解决问题。然而,这样写的话,入参i必须声明为Input1类型——亦即必须声明为入参的下界,而不能按“?super Input1”所表示的那样,可以使用一个Input1的父类,如Input类。如果我们非要声明“Input i = new Input1();”的话,Java在编译期就会报错(是error不是warning):
service(capture super Input1) in ServcieTemplate cannot be applied
to (Input)
绕到这里,和里氏替换原则的关系已经有点远了。
关于泛型及其边界的使用,我们以后再聊。总之,对里氏替换原则来说,在实践中,我一般只要求子类不重写父类的public方法,而不要求不重写非抽象方法。此外,对子类方法入参和出参的约束,主要在于业务内涵上。如果要结合泛型边界来定义约束,务必小心:这很可能是一个莫比乌斯环。
往期索引
《面向对象是什么》
从具体的语言和实现中抽离出来,面向对象思想究竟是什么?公众号:景昕的花园面向对象是什么
《抽象》
抽象这个东西,说起来很抽象,其实很简单。
花园的景昕,公众号:景昕的花园抽象
《高内聚与低耦合》
《细说几种内聚》
《细说几种耦合》
"高内聚"与"低耦合"是软件设计和开发中经常出现的一对概念。它们既是做好设计的途径,也是评价设计好坏的标准。
花园的景昕,公众号:景昕的花园高内聚与低耦合
《封装》
《继承》
《多态》
——“面向对象的三大特性是什么?”——“封装、继承、多态。”
[5+1]单一职责原则
单一职责原则非常好理解:一个类应当只承担一种职责。因为只承担一种职责,所以,一个类应该只有一个发生变化的原因。花园的景昕,公众号:景昕的花园[5+1]单一职责原则
[5+1]开闭原则(一)
[5+1]开闭原则(二)
什么是扩展?就Java而言,实现接口(implements SomeInterface)、继承父类(extends SuperClass),甚至重载方法(Overload),都可以称作是“扩展”。什么是修改?在Java中,严格来说,凡是会导致一个类重新编译、生成不同的class文件的操作,都是对这个类做的修改。实践中我们会放宽一点,只有改变了业务逻辑的修改,才会归入开闭原则所说的“修改”之中。花园的景昕,公众号:景昕的花园[5+1]开闭原则(一)