[5+1]里氏替换原则(一)

[5+1]里氏替换原则(一)

前言

面向对象的SOLID设计原则,外加一个迪米特法则,就是咱们常说的5+1设计原则。编程

↑ 五个,再加一个,就是5+1个。哈哈哈。

这六个设计原则的位置有点不上不下。
论原则性和理论指导意义,它们不如封装继承抽象或者高内聚低耦合,因此在写代码或者code review的时候,它们很难成为“应该这样作”或者“不该该这样作”的一个有说服力的理由。
论灵活性和实践操做指南,它们又不如设计模式或者架构模式,因此即便你能说出来某段代码违反了某项原则,经常也很难明确指出错在哪儿、要怎么改。设计模式

因此,这里来讨论讨论这六条设计原则的“为何”和“怎么作”。顺带,做为面向对象设计思想的一环,这里也想聊聊它们与抽象、高内聚低耦合、封装继承多态之间的关系。安全


里氏替换原则

是什么

里氏替换原则(Liskov Substitution principle)是一条针对对象继承关系提出的设计原则。它以芭芭拉·利斯科夫(Barbara Liskov)的名字命名。1987年,芭芭拉在一次名为“数据的抽象与层次”的演讲中首次提出这条原则;1994年,芭芭拉与另外一位女性计算机科学家周以真(Jeannette Marie Wing)合做发表论文,正式提出了这条面向对象设计原则。架构

↑ 芭芭拉和周以真

ps,之后再有人说女生不适合作IT,请把里氏替换原则甩Ta脸上:这是由两位女性提出来计算机理论。其中一位(芭芭拉)得到过图灵奖和冯诺依曼奖;另外一位(周以真)则是ACM和IEEE的会员。言归正传,芭芭拉和周以真是这样定义里氏替换原则的:app

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.
维基百科·里氏替换原则ide

简单翻译一下,意思是:已知x是类型T的一个对象、y是类型S的一个对象,且类型S是类型T的子类;令q(x)为true;那么,q(y)也应为true。翻译

数学语言虽然凝练、精准,可是抽象、费解。这个定义也是这样。因此在应用中,咱们会将原定义解读为:设计

“派生类(子类)对象能够在程序中代替其基类(超类)对象。”3d

↑ “狸”氏替换原则的经典用法(大误)

把两种定义综合一下,里氏替换原则大概就是这样的:子类只重写父类中抽象方法,而毫不重写父类中已有具体实现的方法。code


为何

细究起来,只有在咱们用父类的上下文——例如入参、出参——来调用子类的方法时,里氏替换原则才有指导意义。

↑ 拿父类的上下文调子类实例,“好大的官威啊”

例如,咱们有这样两个类:

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=一、b=0这组参数去调用父类;可是不能直接用它来调用子类。不然的话,因为除数为0,一调用子类方法就会抛出异常。

// 父类能够正常处理a=一、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和新的子类抽取公共父类,以及使用组合。这几种方式的类图以下所示:

[5+1]里氏替换原则(一)

相信你们看得出来:第一种方式的开发工做量最少。可是,第一种方式偏偏就违反了里氏替换原则:子类ServiceImplA重写了父类ServiceImpl中的非抽象方法prepare()和doMethod()。

若是使用第二种方式,咱们首先要新增一个父类ServiceTemplate,而后改写原有的ServiceImpl类;最后才能够开发新的类ServiceImplA。显然,与第一种方式相比,新增ServiceTemplate和修改ServiceImpl都须要付出额外的开发工做量。

若是不使用继承、而使用组合,开发工做量与第一种方式类似。可是,它会带来一个新的问题:ServiceImplA与ServiceImpl之间,再也不是“面向接口编程”,而是“面向具体类编程”了。这问题恐怕比违反历史替换原则还要严重些。
若是要“面向接口编程”,那么咱们须要为ServiceImpl增长一个辅助接口——也就是上图中的第四种方式,使用组合并面向接口编程。可是,第四种方式也须要付出额外的工做量。

质量与工做量(以及藏在工做量背后的工期和成本),这是一对矛盾。一味追求质量而忽视工做量,不只不符合项目管理的目标,甚至有违人的天性。人们把完美主义称为“龟毛”,把偷懒称为“第一动力”,这不是没有道理的。

↑ 偷懒是人类进步的电梯

在这场由里氏替换原则引发的质量与工做量的取舍之间,选择哪一项都有道理。就我我的而言,我比较倾向于采用第一种方式。这种方式不只工做量小,并且代码复用率高、重复率低。此外,这种方式还很好地遵循了开闭原则:在新增一个子类的同时,咱们对父类只作了很是少的修改。

固然,质量要求也不能过低。虽然已经违反了里氏替换原则,但咱们仍是会要求子类不能重写父类的public方法,而只能重写诸如protected或者default方法——private方法是没法重写的,也就不用额外约束了。

这个要求是从使用场景中提炼出来的。大多数状况下,咱们只在模板模式下会使用狭义的继承。这种场景中,父类会在public方法中定义若干个步骤。若是子类须要重写这个public方法,说明子类不须要按照父类定义的步骤、顺序来处理。这时,这两个类之间没法构成“is-a”关系,连继承关系都不该使用,更别提重写public方法了。

↑ 模板模式的典型类图

诚然,子类继承父类这种作法不只仅出如今模板模式中。一样的,子类不重写父类的public方法这条约束也不只限于模板模式。试想,若是连父类的主要方法,子类都要从新实现一遍,那么,这两个是否构成“is-a”的关系、是否真的适用继承结构呢?

↑ “to be or not to be, that is a question”

=================================

除了把里氏替换原则中的“禁止子类重写父类的非抽象方法”转换为“禁止子类重写父类的public方法”这种折中处理以外,在实践中,咱们还有这四条“里氏替换原则实践指南”:

  1. 禁止子类重写父类的非抽象方法。
  2. 子类能够增长本身的方法。
  3. 子类实现父类的方法时,对入参的要求比父类更宽松。
  4. 子类实现父类的方法时,对返回值的要求比父类更严格。

其中,只有第一条是直接源自里氏替换原则的“定理”,这里就再也不赘述了。其它三条都是从里氏替换原则中衍生出来的“推论”。

=================================

子类能够增长本身的方法,其实跟里氏替换原则没有什么直接关系。两者之因此会关联在一块儿,我以为,纯粹就是由于“法无禁令便可行”。固然,把话挑明也有好处。“法无禁令”是一个开区间,不只会让人无所适从,并且可操做空间太大。对操做规范来讲,闭区间比开区间更加可行、也更加安全。白名单比黑名单更安全,也是同样的道理。

=================================

子类实现父类方法时,入参约束更宽松、出参约束更严格,这两条推论讨论的主要是参数的业务涵义,即子类入参的内涵应当比父类更广、而出参的内涵则应当比父类更窄。例如,子类入参的取值范围应当比父类更大、而出参的范围则应当比父类小。在前面例举的那个Calculator类及其子类中,父类的入参取值范围是全部整数,而子类的入参的取值范围则是全部非零整数。显然,子类的取值范围比父类小。也正由于这个缘故,这两个类违反了里氏替换原则,于是在使用时会出现问题。

若是从技术的角度来理解第3、第四条约束的话,通常咱们会他们和泛型结合起来分析。结合泛型以及边界条件来看,第3、第四条约束能够简单理解为:子类的入参和出参,都应该是父类入参和出参的子类。提及来有点绕,看一眼代码就清楚了。例如,咱们有这样两个类:

abstract class ServiceTemplate<I extends Input,O extends  Output> {
    public abstract O service(I i);
}

class Service1 extends ServiceTemplate<Input1,Output1> {
    @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来讲,它的业务内涵则被扩大了。

↑ 同是子类,内涵咋就不同呢。

所以,上面这两个类是符合第3、第四条约束的:子类的入参约束比父类更严格;而出参约束比父类更宽松。它们是符合那四条“里氏替换原则实践指南”的。

=================================

然而,吊诡的是,这两个类其实并不符合里氏替换原则。咱们来看下面这段代码:

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在把方法入参约束为<I extends Input>时,实际上就违反了里氏替换原则。父类不能安全地转化为子类,这是里氏替换原则在Java在语言层面的一种实现。然而Service1在约束了入参的上界时,实际上已经偷偷摸摸的越过了雷池:它的入参已经悄悄地把父类Input转换为子类Input1了。Service1的那段代码,本质上等价于:

class Service1 extends ServiceTemplate<Input,Output1> {
    @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]开闭原则(一)

[5+1]里氏替换原则(一)