“对于面向对象的程序设计语言,多型性是第三种最基本的特征(前两种是数据抽象和继承。”
“多形性”(Polymorphism)从另一个角度将接口从具体的实施细节中分离出来,亦即实现了“是什么”与“怎样做”两个模块的分离。利用多形性的概念,代码的组织以及可读性均能获得改善。此外,还能创建“易于扩展”的程序。无论在项目的创建过程中,还是在需要加入新特性的时候,它们都可以方便地“成长”。
通过合并各种特征与行为,封装技术可创建出新的数据类型。通过对具体实施细节的隐藏,可将接口与实施细节分离,使所有细节成为“private”(私有)。这种组织方式使那些有程序化编程背景人感觉颇为舒适。
但多形性却涉及对“类型”的分解。通过上一章的学习,大家已知道通过继承可将一个对象当作它自己的类型或者它自己的基础类型对待。这种能力是十分重要的,因为多个类型(从相同的基础类型中衍生出来)可被当作同一种类型对待。而且只需一段代码,即可对所有不同的类型进行同样的处理。利用具有多形性的方法调用,一种类型可将自己与另一种相似的类型区分开,只要它们都是从相同的基础类型中衍生出来的。这种区分是通过各种方法在行为上的差异实现的,可通过基础类实现对那些方法的调用。
在这一章中,大家要由浅入深地学习有关多形性的问题(也叫作动态绑定、推迟绑定或者运行期绑定)。同时举一些简单的例子,其中所有无关的部分都已剥除,只保留与多形性有关的代码。
7.1 上溯造型
在第6 章,大家已知道可将一个对象作为它自己的类型使用,或者作为它的基础类型的一个对象使用。取得一个对象句柄,并将其作为基础类型句柄使用的行为就叫作“上溯造型”——因为继承树的画法是基础类位于最上方。
class Note {
private int value;
private Note(int val) { value = val; }
public static final Note
middleC = new Note(0),
cSharp = new Note(1),
cFlat = new Note(2);
} // Etc.
class Instrument {
public void play(Note n) {
System.out.println("Instrument.play()");
}
}
// Wind objects are instruments
// because they have the same interface:
class Wind extends Instrument {
// Redefine interface method:
public void play(Note n) {
System.out.println("Wind.play()");
}
}
public class Music {
public static void tune(Instrument i) {
// ...
i.play(Note.middleC);
}
public static void main(String[] args) {
Wind flute = new Wind();
tune(flute); // Upcasting
}
} ///:
其中,方法 Music.tune()接收一个Instrument 句柄,同时也接收从Instrument 衍生出来的所有东西。当一个Wind 句柄传递给 tune()的时候,就会出现这种情况。此时没有造型的必要。这样做是可以接受的;
Instrument 里的接口必须存在于 Wind 中,因为Wind 是从Instrument 里继承得到的。从 Wind 向Instrument的上溯造型可能“缩小”那个接口,但不可能把它变得比 Instrument 的完整接口还要小。
7.2 深入理解
7.2.1 方法调用的绑定
将一个方法调用同一个方法主体连接到一起就称为“绑定”(Binding)。若在程序运行以前执行绑定(由编译器和链接程序,如果有的话),就叫作“早期绑定”。大家以前或许从未听说过这个术语,因为它在任何程序化语言里都是不可能的。C 编译器只有一种方法调用,那就是“早期绑定”。
上述程序最令人迷惑不解的地方全与早期绑定有关,因为在只有一个 Instrument 句柄的前提下,编译器不知道具体该调用哪个方法。
解决的方法就是“后期绑定”,它意味着绑定在运行期间进行,以对象的类型为基础。后期绑定也叫作“动态绑定”或“运行期绑定”。若一种语言实现了后期绑定,同时必须提供一些机制,可在运行期间判断对象的类型,并分别调用适当的方法。也就是说,编译器此时依然不知道对象的类型,但方法调用机制能自己去调查,找到正确的方法主体。不同的语言对后期绑定的实现方法是有所区别的。但我们至少可以这样认为:它们都要在对象中安插某些特殊类型的信息。
Java 中绑定的所有方法都采用后期绑定技术,除非一个方法已被声明成final。这意味着我们通常不必决定是否应进行后期绑定——它是自动发生的。
为什么要把一个方法声明成final 呢?正如上一章指出的那样,它能防止其他人覆盖那个方法。但也许更重要的一点是,它可有效地“关闭”动态绑定,或者告诉编译器不需要进行动态绑定。这样一来,编译器就可为final 方法调用生成效率更高的代码。
7.2.2 产生正确的行为
知道Java 里绑定的所有方法都通过后期绑定具有多形性以后,就可以相应地编写自己的代码,令其与基础类沟通。此时,所有的衍生类都保证能用相同的代码正常地工作。或者换用另一种方法,我们可以“将一条消息发给一个对象,让对象自行判断要做什么事情。”
在面向对象的程序设计中,有一个经典的“形状”例子。由于它很容易用可视化的形式表现出来,所以经常都用它说明问题。但很不幸的是,它可能误导初学者认为 OOP 只是为图形化编程设计的,这种认识当然是错误的。
形状例子有一个基础类,名为 Shape;另外还有大量衍生类型:Circle(圆形),Square(方形),Triangle(三角形)等等。大家之所以喜欢这个例子,因为很容易理解“圆属于形状的一种类型”等概念。
下面这幅继承图向我们展示了它们的关系:
上溯造型可用下面这个语句简单地表现出来:
Shape s = new Circle();
在这里,我们创建了Circle 对象,并将结果句柄立即赋给一个Shape。这表面看起来似乎属于错误操作(将一种类型分配给另一个),但实际是完全可行的——因为按照继承关系,Circle 属于Shape 的一种。因此编译器认可上述语句,不会向我们提示一条出错消息。
当我们调用其中一个基础类方法时(已在衍生类里覆盖):
s.draw();
同样地,大家也许认为会调用 Shape 的 draw(),因为这毕竟是一个 Shape 句柄。那么编译器怎样才能知道该做其他任何事情呢?但此时实际调用的是 Circle.draw() ,因为后期绑定已经介入(多形性)。
下面这个例子从一个稍微不同的角度说明了问题:
class Shape {
void draw() {}
void erase() {}
}
class Circle extends Shape {
void draw() {
System.out.println("Circle.draw()");
}
void erase() {
System.out.println("Circle.erase()");
}
}
class Square extends Shape {
void draw() {
System.out.println("Square.draw()");
}
void erase() {
System.out.println("Square.erase()");
}
}
class Triangle extends Shape {