1.5 继承:重用接口
对象的思想本身是一种很方便的工具。它允许我们将数据和功能通过概念封装在一起,使得我们能描述合适的问题空间思想,而不是被强制使用底层机器的用语。通过使用class关键字,这些概念被表示为程序设计语言中的基本单元。
然而,克服许多困难去创造一个类,并随后强制性地创造一个有类似功能的全新的类,似乎并不是一种很好的方法。如果能选取已存在的类,克隆它,然后对这个克隆增加和修改,则是再好不过的事。这是继承(inheritance)带来的好处,例外的情况是,如果原来的类(称为基类、超类或父类)被修改,则这个修改过的“克隆”(称为派生类、继承类或子类)也会表现出这些改变。
(在上面的UML图中,箭头从派生类指向基类。正如你将会看到的,可以有多个派生类。)
类型不仅仅描述一组对象上的约束,而且还描述与其他类型之间的关系。两个类型可以有共同的特性和行为,但是一个类型可以包括比另一个类型更多的特性,也可以处理更多的消息(或对消息进行不同的处理)。继承表示了在基类型和派生类型之间的这种相似性。一个基类型具有所有由它派生出来的类型所共有的特性和行为。程序员创建一个基类型以描述关于系统中的一些对象的思想核心。由这个基类型,我们可以派生出其他类型来表述实现该核心的不同途径。
例如,垃圾再生机器要对垃圾进行分类。这里基类型是“垃圾”,每件垃圾有重量、价值等,并且可以被破碎、被融化或被分解。这样,可以派生出更特殊的垃圾类型,它们可以有另外的特性(瓶子有颜色)或行为(铝可以被压碎,钢可以带有磁性)。另外,有些行为可以不同(纸的价值取决于它的种类和情况)。使用继承,我们可以建立类型的层次结构,在该层次结构中用其类型术语来表述我们需要解决的问题。
第二个例子是经典的“形体”范例,可以用于计算机辅助设计系统或游戏模拟。在这里,基类型是“形体”,每个形体有大小、颜色、位置等。每个形体能被绘制、擦除、移动、着色等。由此,可以派生出特殊类型的形体:圆形、正方形、三角形等,它们中的每一个都有另外的特性和行为。例如,某些形体可以翻转。有些行为可以不同,形体面积的计算。类型层次结构既体现了形体之间的相似性,又体现了它们之间的区别。
用与问题相同的术语描述问题的解是非常有益的,因为这样我们就无须在问题的描述和解的描述之间使用许多的中间模型。使用对象,类的层次结构就是最初的模型,所以能直接从实际世界中的系统描述进入代码中的系统描述。实际上,使用面向对象设计,人们的困难之一是从开始到结束过于简单。一个已经习惯于寻找复杂解的训练有素的头脑,往往会被问题本来的简单性所难住。
当我们从已经存在的类型来继承时,我们就创造了一个新类型。这个新类型不仅包含那个已经存在的类型的所有成员(虽然私有成员已被隐藏且不可访问),但更重要的是,它复制了这个基类的接口。也就是说,所有能够发送给这个基类对象的消息,也能够发送给这个派生类的对象。因为我们能够根据发送给一个类的消息知道这个类的类型,所以这意味着这个派生类与这个基类是相同类型的。在前面的例子中,“圆形是一个形体”。这种通过继承实现类型等价性,是理解面向对象程序设计含义的基本途径之一。
由于基类和派生类有相同接口,因此伴随着接口必然有一些实现。也就是说,当对象接收到一个特定的消息后必定执行一些代码。如果只是简单地继承一个类,而不做其他任何事情,来自基类接口的方法也就直接进入了派生类。这就意味着,派生类的对象不仅有相同的类型,而且有相同的行为,这一点并不是特别有意义的。
有两种方法能使新派生类区别于原始基类。第一种相当直接,简单地向派生类添加全新的函数。这些新函数不是基类接口的一部分。这意味着,这个基类不能做我们希望它做的事情,所以必须添加函数。继承的这种简单和原始的运用有时就是问题的完美解。但是,我们会进一步看到,基类可能也需要这些添加的函数。在面向对象程序设计中,这种设计的发现和迭代过程会经常发生。
虽然继承有时意味着向接口添加新函数,但这未必真的需要。使新类有别于基类的第二个和更重要的方法是,改变已经存在的基类函数的行为,这称为重载(overriding)这个函数。
要覆盖函数,可以直接在派生类中创建新定义。相当于说:“我正在使用同一个接口函数,但是我希望它为我的新类型做不同的事情。”
1.5.1 is-a关系和is-Iike-a关系
对于继承有一些争论。继承应当只覆盖基类(并且不添加基类中没有的新成员函数)吗?这就意味着派生类与基类是完全相同的类型,因为它们有相同的接口。结果是,我们可以用派生类的对象代替基类的对象。这被认为是纯代替(pure substitution),常常被称做代替原则(substitution principle)。在某种意义上,这是对待继承的理想方法。我们常把基类和派生类之间的关系看做是一个“is-a(是)”关系,因为我们可以说“圆形是一个形体”。对继承的一种测试方法就是看我们是否可以说这些类有“is-a”关系,而且还有意义。
有时需要向一个派生类型添加新的接口元素,这样就扩展了接口并创建了新类型。这个新类型仍然可以代替这个基类,但这个代替不是完美的,因为这些新函数不能从基类访问。这可以描述为“is-like-a(像)”关系;新类型有老类型的接口,但还包含其他函数,所以不能说它们完全相同。以一台空调为例。假设你的房子与制冷的全部控制连线;也就是说,它有一个允许你控制冷却的接口。设想这台空调坏了,用一台热泵代替它,这台热泵既可以制冷又可以制热,这台热泵就像一台空调,但它能做更多的事情。因为你的房子的控制系统仅仅是针对制冷功能设计的,所以它仅限于与新对象的制冷部分通信。新对象的接口已经被扩展,而这个已经存在的系统只知道原来的接口,并不知道扩展的部分。
很显然,基类“制冷系统”是不充分的,应当改为“温度控制系统”,使它也能包含加热功能。在这一点上,代替原则可用。上图是一个例子,它既可以发生在设计过程中,也可以发生在现实世界中。
当我们考虑代替原则,很容易将这种方法(纯代替)看做是做事情的惟一方法。实际上,如果我们的设计能够采用这种方法,效果也很好。但是,我们还发现有时必须向派生类的接口添加新函数。通过考察,我们发现两种情况都很常见。