对象本身的理念是提供一种便捷的工具。对象可以根据定义的概念来封装数据和功能,从而展现给人们对应的问题空间的概念,而不是强迫程序员操作机器底层。在编程语言里,这些基础概念通过关键字 class 得以呈现。

然而,当我们大费周折才创建了一个类之后,如果不得不再创建一个与之前功能极为相近的类,这种滋味一定不太好受。如果我们能够复制现有的类,并且在该复制类的基础上再做一些增补的话,那就太妙了。实际上,这就是继承给我们带来的好处,除了一点:如果最初的类(叫作“基类”“超类”或“父类”)发生了变化,那么被修改的“复制”类(叫作“派生类”“继承类”或“子类”)同样会发生变化(见图1-3)。
image.png图1-3
图1-3中的箭头从子类指向其基类。之后你将看到,子类通常会有多个。

一个类呈现的内容不只是对象能做什么、不能做什么,它还可以关联其他的类。两个类可以拥有相同的行为和特征,但一个类可以比另一个类拥有更多的特征,以及处理更多的消息(或者用不同的方式处理消息)。继承通过基类和子类的概念来表述这种相似性,即基类拥有的所有特征和行为都可以与子类共享。也就是说,你可以通过基类呈现核心思想,从基类所派生出的众多子类则为其核心思想提供了不同的实现方式。

举个例子。一个垃圾收集器需要对垃圾进行分类。我们创建的基类是“垃圾”,具体的每一件垃圾都有各自不同的重量、价值,并且可以被切碎、溶解或者分解等。于是,更为具体的垃圾子类就出现了,并且带有额外的特征(比如,一个瓶子有颜色,一块金属有磁性等)和行为(比如你可以压扁一个铝罐)。此外,有些行为还可以产生不同的效果(比如纸质垃圾的价值取决于它的类型和状态)。通过继承,我们创建了一种“类型层次”(type hierarchy)以表述那些需要根据具体类型来解决的问题。

还有一个常见的例子是形状,你可能在计算机辅助设计系统或模拟游戏中碰过到。具体来说,基类就是“形状”(Shape),而每一个具体的形状都具有大小、颜色、位置等信息,并且可以被绘制(draw())、清除(erase())、移动(move())、着色(getColor或setColor)等。接下来,基类Shape可以派生出特定类型的形状,比如圆形(Circle)、矩形(Square)、三角形(Triangle)等,每一个具体形状都可以拥有额外的行为和特征,比如某些形状可以被翻转(见图1-4)。有些行为背后的逻辑是不同的,比如计算不同形状的面积的方法就各不相同。所以,类型层次既体现了不同类之间的相似性,又展现了它们之间的差异。
image.png图1-4
问题和解决方案都使用相同的表达方式是非常有用的,因为这样就不再需要一个中间模型将问题翻译为解决方案。在面向对象领域,类型层次是该模型的一个重要特征,它让你可以方便地从现实世界中的系统转换到代码世界的系统。不过现实情况是,有些人由于习惯了复杂的解决方案,因此对于面向对象的简约性反而会有些不适应。

继承已有的类将产生新类。这个新的子类不但会继承其基类的所有成员( private 成员是隐藏且不可访问的),而且更重要的是,子类也会继承基类的接口。也就是说,所有基类对象能够接收的消息,子类对象也一样能够接收。我们可以通过一个类所接收的消息来确定其类型,所以从这一点来说,子类和基类拥有相同的类型。引用之前的例子,就是“圆形是一个形状”。所以,掌握这种通过继承表现出来的类型相同的特性,是理解面向对象编程的基础方法之一。

既然基类和子类拥有相同的基础接口,就必然存在接口的具体实现。这意味着,当一个对象接收到特定的消息时,就会执行对应的代码。如果你继承了一个类并且不做任何修改的话,这个基类的方法就会原封不动地被子类所继承。也就是说,子类的对象不但和基类具有相同的类型,而且不出所料的是,它们的行为也是相同的。

有两种方法可以区分子类和基类。第一种方法非常简单直接:为子类添加新的方法(见图1-5)。因为这些方法并非来自基类,所以背后的逻辑可能是,基类的行为和你的预期不符,于是你添加了新的方法以满足自己的需求。有时候,继承的这种基础用法能够完美地解决你面临的问题。不过,你需要慎重考虑是否基类也需要这些新的方法(还有一个替代方案是考虑使用“组合”)。在面向对象编程领域里,这种对设计进行发现和迭代的情况非常普遍。
image.png图1-5
虽然有时候继承意味着需要为子类添加新的方法 [Java尤其如此,其用于继承的关键字就是“扩展”(extends)],但这不是必需的。还有一种让新类产生差异化的方法更为重要,即修改基类已有方法的行为,我们称之为“重写”该方法(见图1-6)。
image.png图1-6
如果想要重写一个方法,你可以在子类中对其进行重新定义。也就是说,你的预期是“我想通过相同的接口调用该方法,但是我希望它可以在新的类中实现不同的效果”。

is-a 关系与 is-like-a 关系

继承机制存在一个有待商榷的问题:只应该重写基类中定义的方法吗?(并且不能添加基类中不存在的新方法)如果是,就意味着子类和基类的类型是完全相同的,因为它们的接口一模一样。结果就是,你可以直接用子类的对象代替基类的对象。这种纯替换关系通常叫作“替换原则”5。从某种意义上说,这是一种理想的继承方式。这种情况下基类和子类之间的关系通常叫作 “is-a” 关系,意思是 “A是B” ,比如“圆形是一个形状”。甚至有一种测试是否是继承关系的方法是,判断你的类之间是否满足这种 “is-a” 关系。

5也叫作“里氏替换原则”(Liskov Substitution Principle),这一理论最初由 Barbara Liskov 提出。

有时候,你会为子类的接口添加新的内容,从而扩展了原有的接口。在这种情况下,子类的对象依然可以代替基类的对象,但是这种代替方案并不完美,因为不能通过基类的接口获取子类的新方法。我将这种关系描述为 “is-like-a” 关系(这是我自创的词),意思是 “A像B” ,即子类在拥有基类接口的同时,也拥有一些新的接口,所以不能说两者是完全等同的。以空调为例,假设你的房间里已经安装了空调,也就是拥有能够降低温度的接口。现在发挥一下想象力,万一空调坏了,你还可以用热泵作为替代品,因为热泵既可以制冷也可以制热(见图1-7)。在这种情况下,热泵“就像是”空调,只不过热泵能做的事情更多而已。此外,由于设计房间的温度控制系统时,功能仅限于制冷,所以系统和新对象交互时也只有制冷的功能。虽然新对象的接口有所扩展,但现有系统也只能识别原有的接口。
image.png图1-7
观察图1-7你就能知道,基类“制冷系统”通用性并不高,最好可以将其改名为“温度调节系统”,使其同时包含制热功能。这样一来,之前提及的替换原则就可以派上用场了。不过话说回来,这张图也反映了真实世界中的设计方式。

当你充分理解了替换原则之后,可能会认为这种纯替换方式才是唯一正确的方式。如果你的设计能够应用纯替换原则,那就太棒了。然而实际情况是,你会发现经常需要为子类的接口添加新方法。只要稍加观察,就很容易分辨出这两种情况的应用场合。