1.6.1 “是一个”与“像是一个”关系
纯粹替代,通常称之为替代原则。
判断是否继承,就是要确定是否可以用is-a来描述类之间的关系,并使之具有实际意义。
有时必须在导出类型中添加新的接口元素,这样也就扩展了接口。
这个新的类型仍然可以替代基类,但是这种替代并不完美,因为基类无法访问新添加的方法。
这种情况我们可以描述为is-like-a(像是一个)关系。
新类型具有旧类型的接口,但是它还包含其他方法,所以不能说它们完全相同。
以空调为例
当你看到替代原则时,很容易会认为这种方式(纯粹替代)是唯一可行的方式,而且事实上,用这种方式设计是很好的。
但是你会时常发现,同样显然的是你必须在导出类的接日中添加新方法。
只要仔细审视, 两种方法的使用场合应该是相当明显的。
1.7 伴随多态的可互换对象
在处理类型的层次结构时,经常想把一个对象不当作它所属的特定类型来对待, 而是将其当作其基类的对象来对待。
这使得人们可以编写出不依赖于特定类型的代码。
在“几何形”的例子中,方法操作的都是泛化 (generic)的形状,而不关心它们是圆形、正方形、三角形还是其他什么尚未定义的形状。
这样的代码是不会受添加新类型影响的,而且添加新类型是扩展一个面向对象程序以便处理新情况的最常用方式。
通过导出新的子类型而轻松扩展设计的能力是对改动进行封装的基本方式之一。
这种能力可以极大地改善我们的设计,同时也降低软件维护的代价。
面向对象程序设计的最重要的妙诀:编译器不可能产生传统意义上的函数调用。
面向对象程序设计语言使用了后期绑定的概念。当向对象发送消息时,被调用的代码直到运行时才能确定。
编译器确保被调用方法的存在,并对调用参数和返回值执行类型检查(无法提供此类保证的语言被称为是弱类型的),但是并不知道将被执行的确切代码。
当向一个对象发送消息时,该对象就能够知畏道对这条消息应该做些什么。
而在Java中,动态绑定是默认行为,不需要添加额外的关键字来实现多态。
再来看看几何形状的例子。它忽略类型的具体细节,仅仅和基类交互。
这段代码和具体类型信息是分离的(decoupled),这样做使代码编写更为简单,也更易于理解。
正因为如此,可以称这个程序是可扩展的。
把将导出类看做是它的基类的过程称为向上转型 (upcasting)。
一个面向对象程序肯定会在某处包含向上转型,因为这正是将自己从必须知道确切类型中解放出来的关键。
正是因为多态才使得事情总是能够被正确处理。
编译器和运行系统会处理相关的细节,你需要马上知道的只是事情会发生,更重要的是怎样通过它来设计。
1.6.1 “是一个”与“像是一个”关系
对于继承可能会引发某种争论:继承应该只覆盖基类的方法(而并不添加在基类中没有的新方法)吗?
如果这样做,就意味浩导出类和基类是完全相同的类型,因为它们具有完全相同的接口。
结果可以用一个导出类对象来完全替代一个基类对象。
这可以被视为纯粹替代,通常称之为替代原则。
在某种意义上,这是一种处理继承的理想方式。
我们经常将这种情况下的基类与导出类之间的关系称为 **is-a **(是一个)关系,因为可以说一个圆形就是一个几何形状。
判断是否继承,就是要确定是否可以用is-a来描述类之间的关系,并使之具有实际意义。
有时必须在导出类型中添加新的接口元素,这样也就扩展了接口。
这个新的类型仍然可以替代基类,但是这种替代并不完美,因为基类无法访问新添加的方法。
这种情况我们可以描述为is-like-a(像是一个)关系。
新类型具有旧类型的接口,但是它还包含其他方法,所以不能说它们完全相同。
以空调为例,假设房子里已经布线安装好了所有的冷气设备的控制器,也就是说,房子具备了让你控制冷气设备的接口。
想像一下,如果空调坏了,你用一个既能制冷又能制热的热力泵替换了它,那么这个热力泵就 is-like-a 空调,但是它可以做更多的事。
因为房子的控制系统被设计为只能控制冷气设备,所以它只能和新对象中的制冷部分进行通信。
尽管新对象的接口已经被扩展了,但是现有系统除了原来接口之外,对其他东西一无所知。
当然,在看过这个设计之后,很显然会发现,制冷系统这个基类不够一般化,应该将其更名为“温度控制系统”,使其可以包括制热功能,这样我们就可以套用替代原则了。
这张图说明了在真实世界中进行设计时可能会发生的事情。
当你看到替代原则时,很容易会认为这种方式(纯粹替代)是唯一可行的方式,而且事实上,用这种方式设计是很好的。
但是你会时常发现,同样显然的是你必须在导出类的接日中添加新方法。
只要仔细审视, 两种方法的使用场合应该是相当明显的。
1.7 伴随多态的可互换对象
在处理类型的层次结构时,经常想把一个对象不当作它所属的特定类型来对待, 而是将其当作其基类的对象来对待。
这使得人们可以编写出不依赖于特定类型的代码。
在“几何形”的例子中,方法操作的都是泛化 (generic)的形状,而不关心它们是圆形、正方形、三角形还是其他什么尚未定义的形状。
所有的几何形状都可以被绘制、擦除和移动,所以这些方法都是直接对一个几何形对象发送消息;它们不用担心对象将如何处理消息。
这样的代码是不会受添加新类型影响的,而且添加新类型是扩展一个面向对象程序以便处理新情况的最常用方式。
例如,可以从“几何形”中导出一个新的子类型“五角形,而并不需 要修改处理泛化几何形状的方法。
通过导出新的子类型而轻松扩展设计的能力是对改动进行封装的基本方式之一。
这种能力可以极大地改善我们的设计,同时也降低软件维护的代价。
但是,在试图将导出类型的对象当作其泛化基类型对象来看待时(把圆形看作是几何形,把自行车看作是交通工具,把鸿鹄看作是鸟等等),仍然存在一个问题。
如果某个方法要让泛化几何形状绘制自己、让泛化交通工具行驶,或者让泛化的鸟类移动,那么编译器在编译时是不可能知道应该执行哪一段代码的。
这就是关键所在:当发送这样的消息时,程序员并不想知道哪一段代码将被执行;绘图方法可以被等同地应用于圆形、正方形、三角形,而对象会依据自身的具体类型来执行恰当的代码。
如果不需要知道哪一段代码会被执行,那么当添加新的子类型时,不需要更改调用它的方法,它就能够执行不同的代码。
因此,编译器无法精确地了解哪一段代码将会被执行,那么它该怎么办呢?
例如,在下面的图中,BirdController对象仅仅处理泛化的Bird对象,而不了解它们的确切类型。
从BirdContreller的角度看,这么做非常方便,因为不需要编写特别的代码来判定要处理的Bird对象的确切类型或其行为。
当move()方法被调用时,即便忽赂Bird的具体类型,也会产生正确的行为(Goose(鹅)走、飞或游泳,Penguin(企鹅)走或游泳),那么,这是如何发生的呢?
这个问题的答案,也是面向对象程序设计的最重要的妙诀:编译器不可能产生传统意义上的函数调用。
一个非面向对象编程的编译器产生的函数调用会引起所谓的前期绑定,这个术语你可能以前从未听说过,可能从未想过函数调用的其他方式。
这么做意味着编译器将产生对一个具体函数名字的调用,而运行时将这个调用解析到将要被执行的代码的绝对地址。
然而在OOP中,程序直到运行时才能够确定代码的地址,所以当消息发送到一个泛化对象时,必须采用其他的机制。
为了解决这个问题,面向对象程序设计语言使用了后期绑定的概念。当向对象发送消息时,被调用的代码直到运行时才能确定。
编译器确保被调用方法的存在,并对调用参数和返回值执行类型检查(无法提供此类保证的语言被称为是弱类型的),但是并不知道将被执行的确切代码。
为了执行后期绑定,Java使用一小段特殊的代码来替代绝对地址调用。
这段代码使用在对象中存储的信息来计算方法体的地址(这个过程将在第8章中详述)。
这样,根据这一小段代码的内容,每一个对象都可以具有不同的行为表现。
当向一个对象发送消息时,该对象就能够知畏道对这条消息应该做些什么。
在某些语言中,必须明确地声明希望某个方法具备后期绑定属性所带来的灵活性 (C++是使用virtual关键字来实现的)。
在这些语言中,方法在默认情况下不是动态绑定的。
而在Java中,动态绑定是默认行为,不需要添加额外的关键字来实现多态。
再来看看几何形状的例子。整个类族(其中所有的类都基于相同的一致接口)在本章前面已有图示。
为了说明多态,我们要编写一段代码,它忽略类型的具体细节,仅仅和基类交互。
这段代码和具体类型信息是分离的(decoupled),这样做使代码编写更为简单,也更易于理解。
而且,如果通过继承机制添加一个新类型,例如Hexagon(六边形),所编写的代码对Shape(几何形)的新类型的处理与对已有类型的处理会同样出色。
正因为如此,可以称这个程序是可扩展的。
如果用Java来编写一个方法(后面很快你就会学习如何编写):
void doSomething (Shape shape) {
shape.eras();
// …
shape.draw();
}
这个方法可以与任何Shape对话,因此它是独立于任何它要绘制和擦除的对象的具体类型的。
如果程序中其他部分用到了doSomething()方法:
Circle circle = new Circle();
Triangle triangle = new Triangle();
Line line = new Line();
doSomething(circle);
doSomething(triangle);
doSomething(line);
对doSomething()的调用会自动地正确处理,而不管对象的确切类型。
这是一个相当令人惊奇的诀窍。看看下面这行代码:
doSomething(circle);
当Circle被传入到预期接收Shape的方法中,究竟会发生什么。
由于Circle可以被doSomething()看作是Shape,
也就是说,如doSomething()可以发送给Shape的任何消息,Circle:都可以接收,那么,这么做是完全安全且合乎逻辑的。
把将导出类看做是它的基类的过程称为向上转型 (upcasting)。
转型(cast)这个名称的灵感来自于模型铸造的塑模动作;而向上(up)这个词来源干继承图的典型布局方式:
通常基类在顶部,而导出类在其下部散开。
因此,转型为一个基类就是在继承图中向上移动,即“向上转型”。
一个面向对象程序肯定会在某处包含向上转型,因为这正是将自己从必须知道确切类型中解放出来的关键。
让我们再看看doSomething()中的代码:
shape erase();
// …
shape.draw();
注意这些代码并不是说“如果是Circle,请这样做,如果是Square,请那样做……”。
如果编写了那种检查Shape所有实际可能类型的代码,那么这段代码肯定是杂乱不堪的,而且在每次添加了Shape的新类型之后都要去修改这段代码。
这里所要表达的意思仅仅是“你是一个Shape,我知道你可以erase()和draw()你自己,那么去做吧,但是要注意细节的正确性。”
doSomething()的代码给人印象深刻之处在于,不知何故,它总是做了该做的。
调用Circle 的draw()方法所执行的代码与调用Square或Line的draw()方法所执行的代码是不同的,
而且当draw()消息被发送给一个匿名的Shape时,也会基于该Shape的实际类型产生正确的行为。
这相当神奇,因为就像在前面提到的,当Java编译器在编译doSomething()的代码时,并不能确切知道doSomething()要处理的确切类型。
所以通常会期望它的编译结果是调用基类Shape.erase()和draw()版本,而不是具体的Circle、Square或Line的相应版本。
正是因为多态才使得事情总是能够被正确处理。
编译器和运行系统会处理相关的细节,你需要马上知道的只是事情会发生,更重要的是怎样通过它来设计。
当向一个对象发送消息时,即使涉及向上转型,该对象也知道要执行什么样的正确行为。