对于许多熟悉面向对象语言的人来说,继承是一个颇具争议的话题。一方面,它具有强大的代码复用能力,仅需几行代码即可构建出一个丰富功能的类。而另一方面,我们不时被提醒编程书籍中所强调的“最佳实践”:组合优于继承、Java限制单一继承、继承破坏封装等。果真如此吗?或者说以上仅仅是事实上的陈述,那么背后的真正的原因是什么呢?

在本文中,笔者将围绕以下五个问题展开对继承的深入探讨,去探究一些Java语言设计者背后那些关于继承的深刻洞见。

问题1:继承是什么?

问题2:继承中蕴含了什么思维模型?

问题3:继承的本质是什么?

问题4:继承与接口的本质区别是什么?

问题5:继承与组合的本质区别是什么?

01

继承是什么

在本节中,首先介绍一下Java语言中对于继承的定义。然后,看一下Java语言中对于继承的实现。最后,再看列举一些现实生活中与继承相近的类比案例。

1.继承的定义

继承指的是一种对象之间的关系,通过这种关系,一个类(称为子类或派生类)可以继承另一个类(称为父类或基类)的属性和方法。子类可以重用父类的代码,并且可以添加新的属性和方法。通过继承,我们可以实现代码重用、提高代码可读性、代码易于组织管理等优点。

从上面的定义中,可以看出继承的最主要目标是应该是代码复用,其他的优点都是基于复用这一点的扩展。

如封装一文中所讲,封装只是实现信息隐藏的方式之一。同样也可以说,代码复用是一种目标、结果或是原则,而继承只是实现代码复用的方式之一。

即便在Java语言中,代码复用也有很多其他实现方式。比如组合、抽象类,或者说委派也是一种代码复用。

这些仅仅是类这个粒度上的代码复用,往大了讲,包和程序是更大粒度的代码复用。往小了说,公共函数、全局变量等也是一种复用的方式。

因此,如果仅仅将继承的目标理解成主要是为了代码复用,这个观点有点狭隘。

2.Java中继承的实现

下面是Java中关于继承的一个简单Demo。

-------------子类继承父类--------------------
//父类Animal
class Animal {
String name;
public Animal(String name) {
this.name = name;
}
public void eat() {
System.out.println(name + " is eating.");
}
}
//子类Dog继承自Animal
class Dog extends Animal {
public Dog(String name) {
super(name);
}
public void bark() {
System.out.println(name + " is barking.");
}
}

关于这个实现本身,没有什么可讲的。另外一点值得关注的是,Java中只允许单一继承,不允许多重继承,这是语言层面的一个强制约束。

接下来,我们主要讨论一下Java继承实现的结构。可以发现,Java继承的结构非常简洁,是一颗树的结构。如下图所示。从子类出发,到达根节点,都是一条单链条。

面向对象比面向过程的优势_面向对象和面向过程比较_面向过程与面向对象的区别

实际上,从Java继承中引申出来的很多问题都与这种树的结构有关系,后面将会进一步探讨。

3.继承的类比案例

Java语言中将具有继承关系的两个类命名为父类和子类,这很容易让人联想到继承想模仿的就是现实中类似父亲、儿子的关系。那么,我们就先来简单讨论一下这种关系。

对于父子来说,存在两种可能的继承关系。一种是遗传继承,另一种是财富继承。

其中,遗传继承指的是子代可以得到父代的DNA基因。同时,在遗传过程中,某一段DNA也可能发生重写。如果将Java继承与其类比的话,类中的方法就相当于基因,Java语言中子类对于父类方法也可以重写。

另一种继承是财富继承,父代将其积累的财产、资产或权力传递给子代。显然,如果将Java继承与之对比,将类的方法相较于财富,那么无法“重写”。甚至,如果一个父亲有多个儿子,那么财富可能会分开,也可能全部继承给长子。

因此,财务继承似乎不如遗传继承那么贴切。然而,如果要寻找差异的话,其实还是存在不少。比如,遗传继承之后,儿子和父亲就是两个完全独立的个体,之后儿子可以随便干自己的事情。父亲也可以改良自己的基因(如果医学条件允许),不影响到儿子。

而在Java语言中,子类和父类的关系始终密切相关,子类的几乎任何操作都需要父类的参与。父类的任何调整,也都会影响到子类。

上面的类比不一定准确。当然,这同样也适用于任何其他的类比方式。然而,类比仍然有意义,它可以引领我们去深入思考,从更加全面的维度去审视一个事物。

接下来,我们再来简单看一下现实世界中的其他两类继承关系,一个是母子公司的继承,另一个是动植物分类中的继承。

先来看母子公司的继承,在现实世界中,母公司与子公司之间也存在着一种特殊形式的“继承”关系。子公司会“继承”或共享母公司的一些部门,子公司也会根据自身需求重新设立与母公司相同的部门,也可能增加新的部门。

再来看,动植物分类中的继承。以动物分类为例,动物通常被分为界()、门(Class)、纲(Order)、目()、科(Genus)和种()。以鸟为例,它属于纲。它下面又包含诸多的分类,一直最下面具体的麻雀、鹦鹉等。这些同属于鸟类,是因为它们共享鸟类的共有特征。

现在让我们简单总结一下。在上一节中,我们提到Java继承实现的结构是一种树的结构。在上面例子中,不论是遗传继承、母子公司继承还是动植物分类中的继承,都是一种树的结构。因此,它们在结构上的相似性,让我们可以进行一些类比。

此外,在结构之外,我们也可以进一步观察这种树结构中各层之间的关系,比如遗传继承里仅仅是一种信息传递的关系,母子公司继承中更多是一种权力关系,而动植物分类继承中则是一种家族相似性的关系。对于关系性质的理解,对我们在设计Java类是否要继承,以及类中的属性和行为设计,都会有不少的启发。

02

继承中蕴含了什么思维模型

在本节中,我们进一步对继承的定义进行剖析,并进而推导得出一个继承中蕴含的思维模型。我们也将主要基于这个思维模型来进一步探讨继承与接口以及组合的一些本质区别。

1.对继承定义的进一步剖析

在继承的定义中,除了复用这个关键字之外,还有另一个关键词,就是关系。

因为,继承的实现整体是一棵树。因此,一个子类与它上面链条中的所有类都存在关系,这里我们仅讨论一下父类和子类的关系。

关系的类型取决于观察的维度。因此父类和子类存在两种关系:一是如果将父类看作主体,子类看作客体,从父类去看子类的话,是一种泛化关系;二是如果将子类看作主体,父类看作客体,从子类去看父类的话,则是一种特化关系。在UML中,我们发现它将继承的关系视为一种泛化关系。

那么,这种关系的分类有什么用处呢?其实它里面蕴含着两种截然不同的思考方式。

2.两种不同的思考方式

第一种方式是自上而下的思考方式,也可以称之为综合法。这种方式下,先有一个宏观的概念,然后再逐步细化这个概念。以树为例,先有一个树根,即一个共同的愿景,然后通过不断的差异化,生长出各种分支。

这种思考方式在现实生活中的应用案例很多,例如,先有一个汽车的概念,然后各个品牌可以根据这个概念生产各自类型的汽车;再比如软件研发领域的瀑布式研发方法以及面向过程的分析设计方法(关于面向过程和面向对象,将在后续文章中详细阐述)等。

第二种方式是自下而上的思考方式,也可以称之为分析法。这种方式下,先有一个个具体的事物,然后再从这些具体事物中寻找共同点,或者说进行分类,将属于同一类别的最后统一在一起。同样以树为例,先有一些散落的树枝,通过不断的抽象和分类,将多余的去掉,最后生成一颗大树。

这种思考方式在现实生活中同样有很多案例。例如,关于动植物分类学或是其他任何一种学科的分类,都是先有一个个具体的事物,最后才形成最终的分类。再比如敏捷研发方式,不事先制定一个宏观的计划,而是先做起来,逐步朝着顶点前进。还有,面向对象的分析设计方法,同样也属于此类。

这里,我们也简单提一下这两种思考方式在哲学中的起源。自上而下的思考方式(综合法)和自下而上的思考方式(分析法)分别对应着柏拉图和亚里士多德的哲学理念。

其中,柏拉图倾向于通过提出理念、观念或原则,并从中推导出具体事物的特点和属性来进行思考。这种综合法强调整体性、抽象性和理想化,在探索世界本质时更关注普遍规律和形式。

相比之下,亚里士多德更注重对现实世界的观察与分析,他采用归纳演绎等方法,通过对个别事物特征的详细研究来得出普遍性结论。这种分析法强调经验主义、逻辑推理以及实证数据在知识获取中的重要性。

方法本身很难对比优劣,不同的人所擅长的思考方式可能也存在很大差异。这两种思考方式运用到Java的继承中。如果使用自上而下的思考,那么我们可能需要事先就将抽象性很强的父类或根类规划好,在后面的需求演进中,根据需求补充相应的子类即可。

而如果使用自下而上思考方式的话,更可能是按照需求先编写一个实现的类。然后在后面需求的演进,和不断的迭代中,重构出相应的父类,让程序变得更加容易修改和演进。

但是面向过程与面向对象的区别,如果从现实角度出发去讨论的话,自下而上是更为可行的方式,越复杂的系统越是如此。原因也比较简单,事情在最开头时往往是我们对其理解最为薄弱的时刻,指望一开始就完美设计出一个根类是不太现实的,只能随着时间的推移,对业务理解逐步深入后,才更加有把握,这与敏捷和瀑布方法的不同是类似的。

03

继承的本质是什么

接下来,我们同样来探讨一下继承的本质有哪些观点,希望可以引发读者对继承的更多思考。

1.观点1:继承的本质是实现复用

该观点认为,继承的本质为了实现代码的复用,在创建子类时只需要增加新的属性或方法即可,省略父类中已有实现。

2.观点2:继承的本质是体现层次关系

该观点认为,继承的本质为了体现现实世界中概念之间的层次关系,因为面向对象中的类与现实世界中的概念实际上一一映射的关系。

编程是一种对于现实的表达,那么现实中的关系,同样需要在编程中有所体现,那么继承就实现了这种现实的需求。

3.观点3:继承的本质是实现多态

该观点认为,继承的本质为了实现多态,在程序运行时可以动态决定与哪个子类进行绑定。

但是实际上,多态不一定通过继承来实现面向过程与面向对象的区别,接口、抽象类、指针等同样也可以实现。

4.观点4:继承的本质有两层内涵

上述三个观点中,可以看到它们仅从父类和子类的角度去思考问题。而观点4也是一个比较整体性或综合性的观点。

首先,我们将继承关系联结起来的所有类,看成是一个“家族”。这里需要说明一下,由于java中所有类都继承了类,因此本质上说所有类都共属于一个家族。

这个范围有点太大,对于分析问题没有意义。这里我们仅仅将具有共同属性和行为特征的类看做一个“家族”。

然后,我们再考虑这个家族如何与外部类交互的事情。一是这个家族作为一个客体的话,显然一种好的做法是,主体只与家族中的根类打交道;二是这个家族作为主体的话,一种更好的做法则是尽量交由更末层的子类去与外部沟通。

实际情形肯定不是想象中那么简单,但是这里提醒我们一点,一个继承关系中的家族,家族的定位是什么,里面存在多少层,每一层的职责有哪些,是由它所处的环境以及未来可能的变化来共同决定的。

5.观点5:继承的本质是为了降低复杂性

该观点认为,继承的本质为了降低结构和交互方面的复杂性。这与Java中只支持单一继承也有关系。这样一来,结构和交互都会变得更加简单。

实际上,如果从复杂性角度来看,封装和多态也同样有此功能。

04

继承与接口的本质区别是什么

对于接口来说,它允许实现多个接口,一定程度解决了Java限制多重继承的问题。此外,接口更加简单,只提供接口定义即可。

还有,关于接口和继承的对比中,认为接口体现的是LIKE-A的关系,而继承体现的是一种IS-A的关系。但是看看Java提供的一些原生接口,比如、、、、等接口,好像和LIKE也没有任何关系。

实际上,它们两个的本质区别更多在于思考方式上的区别。接口更多属于自上而下的思考方式。在最开始,通过接口仅仅是提供一种双方之间的契约,具体实现留待日后即可。通过这种方式,既明确了双方的职责边界,又不影响工作的开展。因此,接口追求的是简单清晰,没有任何多余的实现(Java后续版本中也支持增加方法实现,但是不建议这么去做)。

而如上所述,继承则更多属于自下而上的思考方式。继承关系中的根类也是带具体实现的,因此它是在实现过程中,出于程序维护性、可修改性的考量,在不断的磨合和深思熟虑后演进出来的。

以现实生活中的案例来说,接口类似于双方之间签订的合同,在具体行动前先明确双方责任和界限。

而继承则类似于整理电脑文件夹,随着时间推移,文件可能变得混乱不堪。通过将相似的文件分类放置在一起,并经过多次迭代整理后,最终达到有序组织的目的。因此,继承背后所隐含的一个核心概念是分类,而分类本质上是寻找事物间的相似性。只有存在多个事物时,我们才能发现它们之间的相似性并进行有效分类。

其实,分类不仅是继承时的核心概念,也是面向对象中对类的设计过程中的一个核心概念。关于这一部分的深入讲解,可以关注笔者的其他文章。

05

继承与组合的本质区别是什么

关于这一条,其实不仅是针对组合,对于关联、接口实现、抽象实现等关系同样适用。本质上其实都继承实现结构上的一大缺陷。

上文中,我们提到继承的实现结构是一颗树。对于树来说,实际上是一个不稳定的结构。比如说,树中的某个节点需要变更,其影响的范围是该节点下面的所有节点。而且树本身是一个正三字形结构,越上面节点越少,越下面节点越多。因此,如果上面的节点出现问题,影响的是下面一大片。

而对于组合、关联、接口实现、抽象实现这些关系来说,它们并不是处在一个树的结构当中,即便某个节点出现问题,那么影响范围可能仅限于相邻的两层,而不会蔓延至整体。

因此,Java中不建议继承的层次过高,原因也在于此。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注