继承

作者: 雪花遇到温泉 | 来源:发表于2020-02-25 22:25 被阅读0次

——“面向对象的三大特性是什么?”
——“封装、继承、多态。”

这大概是最容易回答的面试题了。但是,封装、继承、多态到底是什么?它们在面向对象中起到了什么样的作用呢?

继承

继承(inherit)关系是对象之间的一种层级结构关系。在这个层级结构中,低层级的对象可以获得、也可以修改高层级对象所定义的一些行为和数据。其中,低层级对象“获得”高层级对象的行为和数据的这个动作,也被称为“继承”(做动词用)。

↑ 小x继承老x,则小x和老x之间就构成了继承关系。

当然,出于封装的考虑,高层级对象也可以把一部分行为“封”起来,不让低层级对象去继承。这并不破坏两类对象之间的继承关系。就好比说,老王的钱终将是小王的钱,但老王的媳妇永远只是老王的媳妇。

Java中有狭义和广义两种继承。狭义的继承就是通过extends关键字实现的类与类之间的继承关系;而广义上讲,通过implements关键字达成的类与接口之间的实现关系也是一种继承:实现类获得了接口中的方法定义,并修改了方法的具体实现。

除了继承之外,对象之间还有一种关系叫组合关系。组合关系是更为普遍、也更为简单的一种关系:类A把类B当做自己的数据(或者叫属性、字段)来使用,我们就说类A组合了类B,或者说是类A依赖于类B。对于只支持单继承的语言来说,继承会形成一个树形结构的类关系网络,而组合则会构建出一个网状结构。

↑ 继承的树状结构与组合的网状结构

例如,上面这张图的左侧,是一套标准的接口(SomeService类)-模板(SomeServiceAsSkeleton类)-具体实现(SomeServiceDefaultImpl类和SomeService4BizAImp类)、外加一个分发器(SomeServiceAsDispatcher类)的类。这些类按照继承关系组成了一棵树形结构。

而这张图的右侧,是一套与BusinesA有组合关系的类。这这些类按照组合关系组成了一个网状结构。当然,考虑到代码的分层结构(例如上图就是一种business-service/helper-dao的分层结构),在组合关系比较简单的情况下,网状结构也有可能退化成树形结构。但是,就如上图中的ServiceA和HelperA所表达的那样:在组合关系中,它们可以相互依赖;但是在继承关系中,不允许出现相互继承。


继承与面向对象

如果说封装是面向对象的基础,那么继承就是面向对象的核心。有了封装,我们才能构建对象;有了继承,我们才能构建起完整的对象体系。

面向对象思想是一种模拟现实的编程思想。它模拟现实的方式,则是模拟现实世界中的分类体系来构建一套对象体系,用以描述对象是什么、对象能做什么,进而借助这些对象及其能力和关系来实现所需功能。面向对象解决方法的思路、能力以及优势,都是建立在它的对象体系上的。没有这套对象体系,面向对象什么都做不了。

↑ 就如夺冠靠的不是一个球员,而是一整套战术体系一样,面向对象思想靠的不是单个对象,而是一整套对象体系。

继承就是面向对象用来构建对象体系的方案。在现实世界的分类体系中,任何一个事物都可以用它的上层类别来描述、并拥有上层类别所描述的特性。例如,我是个Java程序员,也是一个程序员、一个IT从业者;《有彩虹的风景》是一幅浪漫主义油画,也是一副画作、一件艺术品……类似的,在面向对象的对象体系中,子类也都可以安全的转为父类。在对象体系中,每一个子类都是一个父类,都拥有父类的数据和能力:ArrayList是一个AbstractList,拥有AbstractList的方法实现;LinkedHashMap是一个HashMap,也是一个AbstractMap,拥有HashMap和AbstractMap的方法实现……

↑ 左边是现实生活中的分类体系;右边是面向对象中的继承体系。你就说像不像吧。

我们可以设想一下,如果没有继承,面向对象思想要怎样构建这个对象体系呢?一个可选的方案是组合。但是,与继承相比,组合是一种更弱、更松散的关联关系。在现实中,老王就可以因为资产贬值而把它卖掉,也可以因为下属犯错而把他炒掉;但是老王不能因为小王闯祸了就甩手不管,因为他们之间的关系非常强、非常紧密。继承比组合更适合于描述这种类之间的“强关系”——也就是我们常说的“is-a”关系。而且,继承关系组成的树状结构比组合关系形成的网状结构也更简单明了、更易于管理和降低复杂度。如果我们不使用继承、而完全使用组合的话,就很容易陷入类爆炸以及类关系网的泥潭中,光是梳理清楚调用关系就已经晕头转向了。

不过,这几个方面的因素只能说是“组合有而继承好”;真正“组合无而继承有”的、让面向对象非使用继承不可的,是继承所带来的抽象能力。


继承与抽象

继承与抽象是同一枚硬币的两面:子类继承父类,那么我们就可以说,父类是子类的一个抽象。我们以下面这张图为例:这是组合责任链模式和模板模式以实现业务功能的一个类图。

↑ 左责链,右模板,接口在当中。

先看这张图的上半部分,我们首先可以看到:ProductServiceAsChain和ProductServiceAsSkeleton这两个类都实现了ProductService接口。显然的,ProductService接口是这两个类的一个抽象:它定义了这两个子类“做什么”——从方法名上看,这个接口是要确定使用哪种产品;同时把它们“怎样做“的细节隐藏起来了。

然后我们看图的左半部分,ProductServiceAsFindFirst继承了ProductServiceAsChain,因此我们可以说,ProductServiceAsChain是ProductServiceAsFindFirst的一个抽象:它定义了这一套类要“做什么”——通过遍历serviceList来确定使用哪种产品;而具体“怎样做”才能确定产品,ProductServiceAsChain虽然提供了一个默认实现,但还是隐藏了子类的实现细节。从上图中就可以看到,ProductServiceAsFindFirst通过重写ProductServiceAsChain的方法,提供了另一种方法实现。

最后我们看图的右半部分。ProductService4P1~ProductService4P1都继承了ProductServiceAsSkeleton,所以,ProductServiceAsSkeleton就是这些子类的抽象:它定义了这些子类要“做什么”——先判断应不应该由自己处理,然后再填充产品的具体数据。但是,具体“怎么做”能确定是否由自己处理、“怎么做”才能填充上各产品字段,这些实现细节被ProductServiceAsSkeleton隐藏起来了,只有到具体的子类中才能看到。

从继承的这个角度,可以看到抽象的一个显著特点:它也是分层次的。越是高层级的抽象,其中的细节就越少,对业务的概括能力就越高,可维护和可扩展性也就越好;越是低层级的抽象就越“反其道而行之”:细节信息就越多、业务概括能力越低、可维护和可扩展性也就越差。这也是为什么我们鼓励“面向接口编程”的一个原因。一般来说,接口都是顶层抽象。在它的基础上编程,维护和扩展所受到的限制也就越少。

↑ 这有点像职业发展之路:职位/职级越高,离代码的具体实现、系统的细节逻辑就越远。

与“面向对象的三大特性是什么”这个面试题一起出现的,还有“这三个特性中最核心的是什么”。理解了继承与面向对象、与抽象的关系之后,应该就能理解为什么第二道题的答案是“继承”了。


继承与高内聚低耦合

如前所述,继承是一种非常强的关系。因而,借由继承关系构建的模块,其内聚性也非常的高:严格遵守继承规范的情况下,子类和父类只有共同协作才能实现业务功能,二者相辅相成、缺一不可。

例如,我们有这样的一套工厂类:

↑ Messager的一套工厂类

在上面这套类中,AbstractFactory定义了一套工厂类的模板:首先调用instance()方法创建一个Messager实例,然后通过builderBaseMessager()方法针对Messager的父类属性赋值,最后调用buildDetails()方法对子类属性赋值。其中,instance()和buildeDetails()是两个抽象方法。两个子类分别实现了这两个方法,用以创建不同的对象实例并进行赋值。

在这套工厂类中,如果我们只有AbstractFactory,显然是无法获取正确的实例的:它并不知道我们需要哪个Messager实例,也不知道怎样为这个子类实例赋值。如果只有子类、并且不调用父类方法,我们会遇到类似的问题:虽然能够创建实例,并且能够给它赋值,但是最终得到的实例会缺少一部分数据。

只有父类和子类协作:父类为子类提供基本的框架、子类填补上父类留下的空白,才能得到完整的、正确的结果。而这正是内聚性最高的“功能内聚”。

Functional cohesion is when parts of a module are grouped because they all contribute to a single well-defined task of the module .
功能内聚是指一个模块内所有组件共同完成一个功能、缺一不可。
花园的景昕,公众号:景昕的花园《细说几种内聚》

但是在开发实践中,大多数的父类就可以“独当一面”完成功能了;子类所做的并不是“完成未竟的事业”,而是“偷梁换柱”地用另一种方式完成业务功能:似乎父子类之间也并非没你不行啊。

这是我们在遵守继承规范和提高开发效率这一对“原则性”和“灵活性”之间进行取舍的结果。使用继承的规范相当的“严苛”。严格遵守规范会增加开发的工作量、会提高类结构的复杂度、会增加类的个数,最终会降低开发效率、影响项目排期。

与超高的聚合性类似,继承关系的类之间耦合度也非常强。人们甚至造了一个专有名词来描述父子类之间的这种超强耦合:子类耦合(subclass coupling)。

Describes the relationship between a child and its parent. The child is connected to its parent, but the parent is not connected to the child.
子类耦合描述的是子类与父类之间的关系:子类链接到父类,但是父类并没有链接到子类。
花园的景昕,公众号:景昕的花园《细说几种耦合》

子类耦合在《细说几种耦合》一文中已有讨论,所以这里就不多赘述了。


继承与其它

“is-a”与“like-a”

由于继承会带来超高的耦合性,所以很多时候我们都建议尽量少用继承——尤其是狭义的继承,即extends。我们听得最多的,就是只有“is-a”关系才可以使用继承;如果只是“like-a”关系,那么应该使用组合。

但是,怎样算“is-a”、怎样算“like-a”呢?在我看来,如果两个类在“做什么”方面如出一辙、只是在“怎么做”上大同小异,那么它们就是“is-a”的关系;如果他们在“做什么”上大相径庭、而只是在“怎么做”上有些异曲同工,那么它们就是“like-a”的关系。

例如,在下图中,在我们的系统已有一个类ApplyServiceImpl可以对身份证号做基本校验了。随着业务发展,我们有两个新的类(暂时化名为NewServiceA和NewServiceA,否则看到类名就能猜到答案了)需要复用并扩展ApplyServiceImpl的身份证校验逻辑:在基本校验之外,它们都需要增加ID5认证。那么,这两个新的类与原先的那个类之间,是“is-a”还是"like-a“呢?

↑ is-a or like-a, that's a question.

虽然隐去了类名,但是从两个新类所提供的方法上,我们还是可以看出些端倪来。NewServiceA和ApplyServiceImpl的主方法是一样的:apply(User):Apply,这说明它们所做的是同一件事情,只是在做这件事情的过程中有一些差异。而NewServiceB和它俩做的几乎完全是两码事,只是在个别细节上恰好“英雄所见略同”。所以,这三个类之间的关系可以说是一目了然的, 最后的类结构则是这样的:

↑ is-a用继承,like-a用组合

面向对象思想中还有一句名言,可以用做“like-a”与“is-a”的参考:“如果它看上去像只(like-a)鸭子,听上去像只(like-a)鸭子,飞起来也像只(like-a)鸭子,那么它就是只(is-a)鸭子”。也就是说,只有“全方位”的“like-a”的时候,才可以转为“is-a”关系;否则,还是慎用“is-a”这种强关系。

↑ 比如程序员的好朋友小黄鸭,看上去、听上去都“like-a”鸭子,但是飞起来“not like-a”鸭子,所以它“not is-a”鸭子。

类与接口

细心看了上文的话,就会发现,在ApplyServiceImpl-ApplyService4ProductBImpl-BankCardServiceImpl最后的类图中,多了一个新增的接口:IdCardService。

但是,我们为什么一定要新增一个接口呢?按前文所述,ApplyServiceImpl同样是ApplyService4ProductBImpl的一个抽象,为什么BankCardServiceImpl一定要使用新的接口IdCardService、而不直接使用类BankCardServiceImpl呢?换句话说,为什么一定要面向接口编程呢?

细心看了上文的话,就会发现,其实前面已经回答过这个问题了。

首先,抽象“也是分层次的。……一般来说,接口都是顶层抽象。在它的基础上编程,维护和扩展所受到的限制也就越少”。而类虽然也有一定的抽象能力,但是其中已经包含了相当多的细节,不如接口那么富有“生命力”。

例如,使用Map<Enum, String>接口时,我们既可以用HashMap<Enum,String>,也可以用EnumMap<Enum>、ConcurrentHashMap<Enum, String>。但如果一开始就使用了HashMap,那么后续想要变更实现类时,可能就举步维艰了。我们有位同事曾经在代码中用HashMap来封装数据、并把它在Controller、Service、Dao中层层传递和扩散开来。然而,在code review中我们发现这个map存在并发风险,需要替换成ConcurrentHashMap。此时我们才发现,这一整套代码都跟HashMap绑定了,就像船只被章鱼海怪死死缠住一样,进退维谷,左右为难。

↑ 感受恐惧吧

其次,面向对象思想通过继承“构建一套对象体系,用以描述对象是什么、对象能做什么”。具体到类和接口上来说:类同时描述了它是什么和它能做什么;而接口仅仅描述了它能做什么。在代码中,类A调用类B时,其实A并不关心B是什么,而只关心它能做什么——如果你养宠物的目标只是捉住老鼠,那就不要管养的是黑猫白猫、甚至是狼是狗了。反过来想想,如果类A在调用类B时,不光要求它“能做什么”,还限定了它必须“是什么”,那么在后续的功能扩展中就难免受到限制。设想一下,如果你养宠物时,不仅要求能捉住老鼠,还限定它必须是猫,那么当遇上吃猫鼠时,就“勿谓言之不预”了。

↑ 谨以此文悼念英勇的白猫班长

对于“类同时描述了它是什么和它能做什么;而接口仅仅描述了它能做什么”,我们还可以这样理解。在继承结构中,类是给它的子类看的:子类需要从父类定义中知道我们“是什么”、我们“能做什么”;并在逐层细化、重写与扩展中找到我“是什么”、我“能做什么”的定位。接口的定义是给外部调用者看的:只需要告诉它们自己“能做什么”,至于自己“是什么”,“不足为外人道也”。

↑ 他的父母告诉他:你是氪星人;他的内心告诉他:我是地球人。他只告诉人们“我能拯救世界”,却从不提起“我是克拉克·肯特”。

相关文章

  • 继承 继承

    属性拷贝 继承不单单能通过原型链实现,也能通过其他方式实现,属性拷贝就是其中一种方法。 通过属性拷贝也能实现继承子...

  • 继承(单继承,多继承)

    将共性的内容放在父类中,子类只需要关注自己特有的内容 python中所有的内容都是对象,所有的对象都直接或间接继承...

  • js继承方式

    类式继承 构造函数继承 组合继承 类式继承 + 构造函数继承 原型式继承 寄生式继承 寄生组合式继承 寄生式继承 ...

  • Python-学习之路-08 OOP -02

    单继承和多继承 单继承:每个类只能继承一个类 多继承:每个类可以继承多个类 单继承的多继承的优缺点 菱形继承/钻石...

  • 原型相关(二)

    1.继承 继承方式:接口继承(只继承方法签名)实现继承(继承实际的方法)ECMAScript只支持实现继承,并且主...

  • 继承

    继承的引入和概述 继承案例和继承的好处 继承的弊端 Java中继承的特点 继承的注意实现和什么时候使用继承 继承中...

  • Java面向对象三大特性之继承

    继承 一、继承的特点 Java只支持单继承单继承 多继承 单继承、多继承优缺点①单继承优点:提高了代码的复用性,让...

  • 7、面向对象的程序设计3(《JS高级》笔记)

    三、继承 许多OO语言都支持两种继承方式:接口继承和实现继承。接口继承只继承方法签名,而实现继承则继承实际方法。由...

  • 【重学前端】JavaScript中的继承

    JavaScript中继承主要分为六种:类式继承(原型链继承)、构造函数继承、组合继承、原型式继承、寄生式继承、寄...

  • js之继承

    文章主讲 JS 继承,包括原型链继承、构造函数继承、组合继承、寄生组合继承、原型式继承、 ES6 继承,以及 多继...

网友评论

      本文标题:继承

      本文链接:https://www.haomeiwen.com/subject/jlgqchtx.html