伴随多态的可互换对象

来源:互联网 发布:石家庄编程学校排名 编辑:程序博客网 时间:2024/04/30 06:30
在处理类型的层次结构时,你经常想把一个对象不要当作它所属的特定类型来对待,而是将
其当作其基类的对象来对待。这使得你可以编写出不依赖于特定类型的代码。在 shape 的例
子中,方法都是用来操作泛化(generic)形状的,不管它们是圆形、正方形、三角形还是其
他什么尚未定义的形状。所有的几何形状都可以被绘制、被擦除、被移动,所以这些方法都
是直接对一个 shape 对象发送消息,并不用担心这个对象如何处理该消息。


这样的代码是不会受添加新类型的影响的,而且添加新类型是扩展一个面向对象程序已处理
新情况的最常用方式。例如,你可以从 shape 中导出一个新的子类型 pentagon(无边形),
而并不需要修改处理泛化几何形状的方法。通过导出新的子类型而轻松扩展设计的能力是封
装改动的基本方式之一。这种能力可以极大地改善我们的设计,同时也降低了软件维护的代
价。


但是,在试图将导出类型的对象当作他们的泛化基类对象来看待时(把圆形看作是几何形状,
把自行车看作是交通工具,把鸬鹚看作是鸟等等),仍然存在一个问题。如果某个方法是要
泛化几何形状绘制自己,泛化交通工具前进,或者是泛化的鸟类移动,那么编译器在编译时
是不可能知道应该执行哪一段代码的。这就是关键所在:当发送这样的消息时,程序员并不
想知道哪一段代码将被执行;绘图(draw)方法可以被等同地应用于圆形、正方形、三角形
之上, 而对象会依据自身的具体类型来执行恰当的代码。如果你不需要知道哪一段代码会
被执行,那么当你添加新的子类型时,不需要更改方法调用的代码,就能够执行不同的代码。
因此,编译器无法精确地了解哪一段代码将会被执行,那么它该怎么办呢?例如,在下面的
图中,BirdController 对象仅仅处理泛化的Bird 对象,而不了解它们的确切类型。从
BirdController 的角度看,这么做非常方便,因为不需要编写特别的代码来判定要处理的 Bird
对象的确切类型或是 Bird 对象的行为。当 move()方法被调用时,即便忽略 Bird 的具体类型,
也会产生正确的行为(鹅跑、飞或游泳,企鹅跑或游泳),那么,这又是如何发生的呢?

这个问题的答案,也是面向对象程序设计的最重要的妙诀:编译器不可能产生传统意义上的
函数调用(function call)。一个非面向对象(non-OOP)编译器产生的函数调用会引起所谓
的“前期绑定(early binding)”,这个术语你可能以前从未听说过,因为你从未想过函数调
用的其他方式。这么做意味着编译器将产生对一个具体函数名字的调用,而链接器(linker)
将这个调用解析到将要被执行代码的绝对地址(absolute address)。在 OOP 中,程序直到运
行时刻才能够确定代码的地址,所以当消息发送到一个泛化对象时,必须采用其他的机制。
 
为了解决这个问题,面向对象程序设计语言使用了“后期绑定(late binding)”的概念。当
你向对象发送消息时,被调用的代码直到运行时刻才能被确定。编译器确保被调用方法存在,
并对调用参数(argument)和返回值(return value)执行类型检查(无法提供此类保证的语
言被称为是弱类型的(weakly typed)),但是并不知道将会被执行的确切代码。


为了执行后期绑定,Java 使用一小段特殊的代码来替代绝对地址调用。这段代码使用在对象
中存储的信息来计算方法体的地址(这个过程将在第 7 章中详述)。这样,根据这一小段代
码的内容,每一个对象都可以具有不同的行为表现。当你向一个对象发送消息时,该对象就
能够知道对这条消息应该做些什么。


在某些语言中,你必须明确地声明希望某个方法具备后期绑定属性所带来的灵活性(C++是
使用 virtual 关键字来实现的)。在这些语言中,方法在缺省情况下不是动态绑定的。而在 Java
中,动态绑定是缺省行为,你不需要添加额外的关键字来实现多态(polymorphism)。


在来看看几何形状的例子。整个类族(其中所有的类都基于相同一致的接口)在本章前面已
有图示。为了说明多态,我们要编写一段代码,它忽略类型的具体细节,仅仅和基类交互。
这段代码和类型特定信息是分离的(decoupled),这样做使代码编写更为简单,也更易于理
解。而且,如果通过继承机制添加一个新类型,例如 Hexagon,你编写的代码对 Shape 的新
类型的处理与对已有类型的处理会同样出色。正因为如此,可以称这个程序是可扩展的
(extensible)。


如果用 Java 来编写一个方法(后面很快你就会学到如何编写):


void doStuff(Shape s) { 
  s.erase();
// ...
  s.draw(); 
}


这个方法可以与任何 Shape 交谈,因此它是独立于任何它要绘制和擦除的对象的具体类型
的。如果程序中其他部分用到了 doStuff()方法:


Circle c = new Circle(); 
Triangle t = new Triangle(); 
Line l = new Line(); 
doStuff(c); 
doStuff(t); 
doStuff(l); 


对 doStuff()的调用会被自动地正确处理,而不管对象的确切类型。


这是一个相当令人惊奇的诀窍。看看下面这行代码:


doStuff(c); 
 
如果被传入到预期接收 Shape 的方法中,究竟会发生什么呢?由于 Circle 可以被 doStuff()
看作是 Shape,也就是说,doStuff()可以发送给 Shape 的任何消息,Circle 都可以接收,那么,
这么做是完全安全且合乎逻辑的。


我们把将导出类看作是它的基类的过程称为“向上转型(upcasting)”。“转型(cast)”
这个名称的灵感来自于模型铸造的塑模动作,而“向上(up)”这个词来源于继承图的典型
布局方式:通常基类在顶部,而导出类在其下部散开。因此,转型为一个基类就是在继承图
中向上移动,即“向上转型(upcasting)”。


一个面向对象程序肯定会在某处包含向上转型,因为这正是你如何将自己从必须知道确切类
型中解放出来的关键。让我们再看看在 doStuff()中的代码:


s.erase(); 
// ...
s.draw();


注意这些代码并不是说“如果你是 Circle,请这样做;如果你是 Square,请那些做;……”。
如果你编写了那种检查 Shape 实际上所有可能类型的代码,那么这段代码肯定是杂乱不堪
的,而且你需要在每次添加了新类型的 Shape 之后去修改这段代码。这里你所要表达的意思
仅仅是“你是一个 Shape,我知道你可以 erase()和 draw()你自己,那么去做吧,但是要注意
细节的正确性。”


doStuff()的代码给人印象深刻之处在于,不知何故,总是做了该做的。调用 Circle 的 draw()
方法所执行的代码与调用 Square 或 Line 的 draw()方法所执行的代码是不同的,但是当 draw()
消息被发送给一个匿名的(anonymous)的 Shape 时,也会基于该 Shape 的实际类型产生正
确的行为。这相当神奇,因为就象在前面提到的,当 Java 编译器在编译 doStuff()的代码时,
并不能确切知道 doStuff()要处理的确切类型。所以通常你会期望它的编译结果是调用基类
Shape 的 erase()和 draw()版本,而不是具体的 Circle、Square 或是 Line 的版本。正是因为多
态才使得事情总是能够被正确处理。编译器和运行系统会处理相关的细节,你需要马上知道
的只是事情会发生,更重要的是怎样通过它来设计。当你向一个对象发送消息时,即使涉及

向上转型,该对象也知道要执行什么样的正确行为。


原创粉丝点击