运行这个程序后,我们便会发现Music.java的难点所在。Wind.play()方法将产生输出结果。这无疑是我们所期望的输出结果,但它看起来似乎又没有什么意义。请观察一个tune()方法:
public static void tune(Instrument i) {
i.play(Note.MIDDLE_C);
}
它接受一个Instrument引用。那么在这种情况下,编译器怎样才能知道这个Instrument引用指向的是Wind对象,而不是Brass对象或Stringed对象呢?实际上,编译器无法得知。为了深入理解这个问题,有必要研究一下绑定这个话题。
一、方法调用绑定
将一个方法调用同一个方法主体关联起来被称作绑定。若在程序执行前进行绑定(如果有的话,由编译器和连接程序实现),叫做前期绑定。你可能以前从来没有听过这个术语,因为它是面向过程的语言中不需要选择就默认的绑定方式。例如,C只有一种方法调用,那就是前期绑定。
上述程序之所以令人迷惑,主要是因为前期绑定。因为,当编译器只有一个Instrument引用时,它无法知道究竟调用哪个方法才对。
解决的办法就是后期绑定,它的含义就是在运行时根据对象的类型进行绑定。后期绑定也叫做动态绑定或运行时绑定。如果一种语言想实现后期绑定,就必须具有某种机制,以便在运行时能判断对象的类型,从而调用恰当的方法。也就是说,编译器一直不知道对象的类型,但是方法调用机制能找到正确的方法体,并加以调用。后期绑定机制随编程语言的不同而有所不同,但是只要想一下就会得知,不管怎样都必须在对象中安置某种“类型信息”。
java中除了static方法和final方法(private方法属于final方法)之外,其他所有的方法都是后期绑定。这意味着通常情况下,我们不必判定是否应该进行后期绑定--它会自动发生。
为什么要将某个方法声明为final呢?它可以防止其他人覆盖该方法。但更重要的一点或许是:这样做可以有效地“关闭”动态绑定,或者说,告诉编译器不需要对其进行动态绑定。这样,编译器就可以为final方法调用生成更有效的代码。然而,大多数情况下,这样做对程序的整体性能不会有什么改观。所以,最好根据设计来决定是否使用final,而不是出于试图提高性能的目的来使用final。
二、产生正确的行为
一旦知道java中所有方法都是通过动态绑定实现多态这个事实之后,我们就可以编写只与基类打交道的程序代码了,并且这些代码对所有的导出类都可以正确运行。或者换一种说法,发送消息给某个对象,让该对象去断定应该做什么事。
面向对象程序设计中,有一个经典的例子就是“几何形状”(shape)。因为它很直观,所以经常用到;但不幸的是,他可能使初学者认为面向对象程序设计仅适用于图形化程序设计,实际当然不是这样。
在“几何形状”这个例子中,有一个基类Shape,以及多个导出类--如Circle、Square、Triangle等。这个例子之所以好用,是因为我们可以说“圆是一种几何形状”,这种说法也很容易被理解。下面继承图展示它们之间的关系:
向上转型可以向下面这条语句这么简单:
Shape s = new Circle();
这里,创建了一个Circle对象,并把得到的引用立即赋值给Shape,这样做看似错误(将一种类型赋值给另一种类型);但实际上是没问题的,因为通过继承,Circle就是一种Shape。因此,编译器认可这条语句,也就不会产生错误信息。
假设你调用一个基类方法(它已在导出类中被覆盖):s.draw();你可能再次认为调用的是Shape的draw(),因为这毕竟是一个Shape引用,那么编译器是怎样知道去做其他的事情呢?由于后期绑定(多态),还是正确调用了Circle的draw()方法。
下面的例子稍微有所不同:
public class Shape {
public void draw() {
}
public void erase() {
}
}
public class Circle extends Shape {
public void draw() {
System.out.println("Circle的draw()");
}
public void erase() {
System.out.println("Circle的erase()");
}
}
public class Square extends Shape {
public void draw() {
System.out.println("Square的draw()");
}
public void erase() {
System.out.println("Square的erase()");
}
}
public class Triangle extends Shape {
public void draw() {
System.out.println("Triangle的draw()");
}
public void erase() {
System.out.println("Triangle的erase()");
}
}
import java.util.Random;
public class RandomShapeGenerator {
private Random r = new Random(47);
public Shape next() {
switch (r.nextInt(3)) {
default:
case 0:
return new Circle();
case 1:
return new Square();
case 2:
return new Triangle();
}
}
}
public class Shapes {
private static RandomShapeGenerator gen = new RandomShapeGenerator();
public static void main(String[] args) {
Shape[] s = new Shape[9];
for (int i = 0; i < s.length; i++) {
s[i] = gen.next();
}
for (Shape shape : s) {
shape.draw();
}
}
}
Shape基类为自它那里继承而来的所有导出类建立了一个公用接口--也就是说,所有形状都可以描绘和檫除。导出类通过覆盖这些定义,来为每种特殊类型的几何形状提供单独的行为。
RandomShapeGenerator是一种“工厂”(factory),在我们每次调用next()方法时,它可以为随机选择的Shape对象产生一个引用。请注意向上转型是在return语句里发生的。每个return语句取得一个指向某个Circle、Square或者Triangle的引用,并将其以Shape类型从next()方法中发送出去。所以无论我们在什么时候调用next()方法时,是绝对不可能知道具体类型到底是什么的,因为我们总是只能获得一个通用的Shape引用。
main()包含了一个Shape引用组成的数组,通过调用RandomShapeGenerator的next()来填入数据。此时,我们只知道自己拥有一些Shape,除此之外不会知道更具体的情况(编译器也不知道)。然而,当我们遍历这个数组,并为每个数组元素调用draw()方法时,与类型有关的特定行为会神奇般地正确发生,我们可以从运行该程序时所产生的输出结果中发现这一点。
随机选择几何形状是为了让大家理解:在编译时,编译器不需要获得任何特殊信息就能进行正确的调用。对draw()方法的所有调用都是通过动态绑定进行的。
三、可扩展性
现在,让我们返回到“乐器”(Instrument)示例。由于有多态机制,我们可根据自己的需求对系统添加任意多的新类型,而不需更改tune()方法。在一个设计良好的OOP程序中,大多数或者所有方法都会遵循tune()的模型,而且只与基类接口通信。这样的程序是可扩展的,因为可以从通用的基类继承出新的数据类型,从而新添加一些功能。那些操作基类接口的方法不需要任何改动就可以应用于新类。
考虑一下:对于“乐器”的例子,如果我们向基类中添加更多的方法,并加入一些新类,将会出现什么情况呢?请看下图:
事实上,不需要改动tune()方法,所有的新类能与原有类一起正确运行。即使tune()方法是单独存放在某个文件中,并且Instrument接口中添加了其他的新方法,tune()也不需要在编译就能正确运行。下面是上图的具体实现:
/**
* 乐器
*/
class Instrument {
void play(Note n) {
System.out.println("Instrument的play()" + n);
}
String what() {
return "Instrument";
}
// 调整
void adjust() {
System.out.println("Adjusting Instrument");
}
}
class Wind extends Instrument {
void play(Note n) {
System.out.println("Wind的play()" + n);
}
String what() {
return "Wind";
}
void adjust() {
System.out.println("Adjusting Wind");
}
}
/**
* 敲打乐器
*/
class Percussion extends Instrument {
void play(Note n) {
System.out.println("Percussion的play()" + n);
}
String what() {
return "Percussion";
}
void adjust() {
System.out.println("Adjusting Percussion");
}
}
/**
* 弦乐器
*/
class Stringed extends Instrument {
void play(Note n) {
System.out.println("Stringed的play()" + n);
}
String what() {
return "Stringed";
}
void adjust() {
System.out.println("Adjusting Stringed");
}
}
/**
* 管乐
*/
class Brass extends Wind {
void play(Note n) {
System.out.println("Brass的play()" + n);
}
void adjust() {
System.out.println("Adjusting Brass");
}
}
/**
* 木管乐器
*/
class Woodwind extends Wind {
void play(Note n) {
System.out.println("Woodwind的play()" + n);
}
String what() {
return "Woodwind";
}
}
public class Music3 {
public static void tune(Instrument i) {
i.play(Note.MIDDLE_C);
}
public static void tuneAll(Instrument[] e) {
for (Instrument instrument : e)
tune(instrument);
}
public static void main(String[] args) {
Instrument[] orchestra = { new Wind(), new Percussion(), new Stringed(), new Brass(), new Woodwind() };
tuneAll(orchestra);
}
}
新添加的方法what()返回一个带有类描述的String引用;另一个新添加的方法adjust()则是提供每种乐器的调音方法。
在main()中,当我们将某种引用置入orchestra数组中,就会自动向上转型到Instrument。
可以看到,tune()方法完全可以忽略它周围代码所发生的全部变化,依旧正常运行。这正是我们期望多态所具有的特性。我们所做的代码修改,不会对程序中其他不应受到影响的部分产生破坏。换句话说,多态是一项让程序员“将改变的事物与未变的事物分离开来”的重要技术。
四、缺陷:“覆盖”私有方法
我们试图像下面这样做也是无可厚非的:
public class PrivateOverride {
private void f() {
System.out.println("private f()");
}
public static void main(String[] args) {
PrivateOverride po = new Derived();
po.f();
}
}
class Derived extends PrivateOverride {
public void f() {
System.out.println("public f()");
}
}
我们所期望的输出是public f(),但是由于private方法被自动认为是final方法,而且对导出的类是屏蔽的。因此,在这种情况下,Derived类中的f()方法就是一个全新的方法;既然基类中的f()方法在子类Derived中不可见,因此甚至也不能被重载。
结论就是:只有非private方法才可以被覆盖;但是还需要密切注意覆盖private方法的现象,这时虽然编译器不会报错,但是也不会按照我们所期望的来执行。确切地说,在导出类中,对于基类中的private方法,最好采用不同的名字。
五、缺陷:域与静态方法
一旦你了解了多态机制,可能就会开始认为所有事物都可以多态地发生。然而,只有普通的方法调用可以是多态的。例如,如果你直接访问某个域,这个访问就将在编译期进行解析,就像下面的示例所演示的:
class Super {
public int field = 0;
public int getField() {
return field;
}
}
class Sub extends Super {
public int field = 1;
public int getField() {
return field;
}
public int getSuperField() {
return super.field;
}
}
public class FieldAccess {
public static void main(String[] args) {
Super sup = new Sub();
System.out.println("sup.field = " + sup.field + ", sup.getField() = " + sup.getField());
Sub sub = new Sub();
System.out.println("sub.field = " + sub.field + ", sub.getField() = " + sub.getField()
+ ", sub.getSuperField() = " + sub.getSuperField());
}
}
当Sub对象转型为Super引用时,任何域访问操作都将由编译器解析,因此不是多态的。在本例中,为Super.field和Sub.field分配了不同的存储空间。这样,Sub实际上包含两个称为field的域:它自己和它从Super处得到的。然而,在引用Sub中的field时所产生的默认域并非Super版本的field域。因此,为了得到Super.field,必须显式地指明super.field。
尽管这看起来好像会成为一个容易令人混淆的问题,但是在实践中,它实际上从来不会发生。首先,你通常会将所有的域都设置成private,因此不能直接访问它们,其副作用是只能调用方法来访问。另外,你可能不会对基类中的域和导出类中的域赋予相同的名字,因为这种做法容易令人混淆。
如果某个方法是静态的,它的行为就不具有多态性:
class StaticSuper {
public static String staticGet() {
return "Base staticGet()";
}
public String dynamicGet() {
return "Base dynamicGet()";
}
}
class StaticSub extends StaticSuper {
public static String staticGet() {
return "Derived staticGet()";
}
public String dynamicGet() {
return "Derived dynamicGet()";
}
}
public class StaticPolymorphism {
public static void main(String[] args) {
StaticSuper sup = new StaticSub();
System.out.println(sup.staticGet());
System.out.println(sup.dynamicGet());
}
}
静态方法是与类,而并非与单个对象相关联的。
如果本文对您有很大的帮助,还请点赞关注一下。