本文介绍了 Java 的多态性,并深入分析 Java 中的动态绑定方法机制。
1. 方法调用绑定
我们首先来看下面这个例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 package com.yyj;enum Tone { LOW, MIDDLE, HIGH; } class Instrument { public void play (Tone t) { System.out.println("Instrument.play() " + t); } } class Piano extends Instrument { @Override public void play (Tone t) { System.out.println("Piano.play() " + t); } } class Guitar extends Instrument { @Override public void play (Tone t) { System.out.println("Guitar.play() " + t); } } public class Music { public static void tune (Instrument i, Tone t) { i.play(t); } public static void main (String[] args) { Piano p = new Piano (); Guitar g = new Guitar (); tune(p, Tone.MIDDLE); tune(g, Tone.HIGH); } }
在 main()
方法中,我们将 Piano
引用传递给了 tune()
,且不需要任何强制类型转换。这是因为 Instrument
中的接口必定存在于 Piano
中,因为 Piano
继承了 Instrument
。从 Piano
向上转型到 Instrument
可以“缩小”该接口,但不会小于 Instrument
的完整接口。
那么编译器怎么可能知道这个 Instrument
引用在这里指的是 Piano
,而不是 Guitar
?为了更深入地了解这个问题,有必要研究一下绑定(binding)这个问题。
将一个方法调用和一个方法体关联起来的动作称为绑定 。在程序运行之前执行绑定(如果存在编译器和链接器的话,由它们来实现),称为前期绑定。你之前可能没有听说过这个术语,因为在面向过程语言中默认就是前期绑定的。例如,在 C 语言中只有一种方法调用,那就是前期绑定。
解决这个问题的方案称为后期绑定 ,这意味着绑定发生在运行时 ,并基于对象的类型。后期绑定也称为动态绑定或运行时绑定,当一种语言实现后期绑定时,必须有某种机制在运行时来确定对象的类型,并调用恰当的方法。也就是说,编译器仍然不知道对象的类型,但方法调用机制能找到并调用正确的方法体。
Java 中的所有方法绑定都是后期绑定,除非方法是 static
或 final
的(private
方法隐式为 final
)。这意味着通常不需要你来决定是否要执行后期绑定,因为它会自动发生。
2. 尝试重写Private方法
看一下下面这段代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 package com.yyj;public class PrivateOverride { private void f () { System.out.println("Private f()" ); } public static void main (String[] args) { PrivateOverride p = new Derived (); p.f(); } } class Derived extends PrivateOverride { public void f () { System.out.println("Public f()" ); } }
可能会很自然地认为输出应该为 Public f()
,但 private
方法自动就是 final
的,并且对子类也是隐藏的,所以 Derived
的 f()
在这里是一个全新的方法,它甚至没有重载,因为 f()
的基类版本在 Derived
中是不可见的。
这样的结果就是,只有非 private
的方法可以被重写,但要注意重写 private
方法的假象,它不会产生编译器警告,但也不会执行你可能期望的操作,如果使用了 @Override
注解,那么这个问题就会被检测出来。
3. 字段访问与静态方法的多态
现在你可能会开始认为一切都可以多态地发生,但是,只有普通的方法调用可以是多态的。例如,如果直接访问一个字段,则该访问会在编译时解析:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 package com.yyj;class Super { public int x = 0 ; public int getX () { return x; } } class Sub extends Super { public int x = 1 ; @Override public int getX () { return x; } public int getSuperX () { return super .x; } } public class GetField { public static void main (String[] args) { Super sup = new Sub (); System.out.println("sup.x = " + sup.x + ", sup.getX() = " + sup.getX()); Sub sub = new Sub (); System.out.println("sub.x = " + sub.x + ", sub.getX() = " + sub.getX() + ", sub.getSuperX() = " + sub.getSuperX()); } }
当 Sub
对象向上转型为 Super
引用时,任何字段访问都会被编译器解析,因此不是多态的。在此示例中,Super.x
和 Sub.x
被分配了不同的存储空间,因此,Sub
实际上包含两个被称为 x
的字段:它自己的字段和它从 Super
继承的字段。然而,当你在 Sub
中引用 x
时,Super
版本并不是默认的那个,要获得 Super
的字段必须明确地使用 super.x
。
现在我们再来看一下静态方法,如果一个方法是静态的,那它的行为就不会是多态的,因为静态方法与类相关联,而不是与单个对象相关联:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 package com.yyj;class StaticSuper { public static void staticPrint () { System.out.println("Super staticPrint()" ); } public void dynamicPrint () { System.out.println("Super dynamicPrint()" ); } } class StaticSub extends StaticSuper { public static void staticPrint () { System.out.println("Sub staticPrint()" ); } @Override public void dynamicPrint () { System.out.println("Sub dynamicPrint()" ); } } public class StaticPolymorphism { public static void main (String[] args) { StaticSuper sup = new StaticSub (); StaticSub.staticPrint(); sup.dynamicPrint(); StaticSuper.staticPrint(); } }
4. 构造器内部的多态方法行为
构造器调用的层次结构带来了一个难题,对于正在构造的对象,如果在构造器中调用它的动态绑定方法,会发生什么?
在普通方法内部,动态绑定调用是在运行时解析的,这是因为对象不知道它是属于该方法所在的类还是其子类。如果在构造器内调用动态绑定方法,就会用到该方法被重写后的定义 。但是,这个调用的效果可能相当出乎意料,因为这个被重写的方法是在对象(即子类对象)完全构造之前被调用的,因为是从外到内(即从基类到子类)执行构造器的,这可能会带来一些难以发现的错误。如下面这段代码所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 package com.yyj;class A { void f () { System.out.println("A.f()" ); } A() { System.out.println("A() before A.f()" ); f(); System.out.println("A() after A.f()" ); } } class B extends A { private int x = 1 ; B(int x) { this .x = x; System.out.println("B(), x = " + x); } @Override void f () { System.out.println("B.f(), x = " + x); } } public class PolyConstructors { public static void main (String[] args) { new B (5 ); } }
A.f()
是为重写而设计的,这个重写发生在 B
中,但是在 A
的构造器调用了这个方法,而这个调用实际上是对 B.f()
的调用。输出显示,当 A
的构造器调用 f()
时,B.x
的值甚至不是默认的初始值1,而是0。
因此类的完整初始化过程如下:
在发生任何其他事情之前,为对象分配的存储空间会先被初始化为二进制零。
如前面所述的那样调用基类的构造器,此时被重写的 f()
方法会被调用(是的,这发生在 B
构造器被调用之前),由于第1步的缘故,此时会发现 B.x
值为零。
按声明的顺序来初始化成员。
执行子类构造器的主体代码。
这样做有一个好处:一切至少都会初始化为零(或对于特定数据类型来说,是任何与零等价的值),而不仅仅是被视为垃圾。这包括通过组合嵌入在类中的对象引用,这些引用默认为 null
。因此,如果忘记初始化该引用,在运行时就会出现异常。
因此,编写构造器时有一个很好的准则:用尽可能少的操作使对象逬入正常状态,如果可以避免的话,请不要调用此类中的任何其他方法。只有基类中的 final
方法可以在构造器中安全调用(这也适用于 private
方法,它们默认就是 final
的)这些方法不能被重写,因此不会产生这种令人惊讶的问题。
5. 用继承与组合进行设计
一旦学习了多态,似乎一切就都应该被继承,因为多态是一个如此巧妙的工具,但其实这会给你的设计増加负担。更好的方法是先选择组合,尤其是在不清楚到底使用哪种方法时。组合不会强制我们的程序设计使用继承层次结构,组合也更加灵活,因为在使用组合时可以动态选择类型(以及随之而来的行为),而继承则要求在编译时就知道确切的类型。以下示例说明了这一点:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 package com.yyj;class People { public void act () {} } class HappyPeople extends People { @Override public void act () { System.out.println("Happy People" ); } } class SadPeople extends People { @Override public void act () { System.out.println("Sad People" ); } } public class ComInherit { private People p = new HappyPeople (); public void change () { if (p instanceof HappyPeople) p = new SadPeople (); else p = new HappyPeople (); } public void act () { p.act(); } public static void main (String[] args) { ComInherit c = new ComInherit (); c.act(); c.change(); c.act(); } }
6. 向下转型与反射
因为向上转型(在继承层次结构中向上移动)会丢失特定类型的信息,所以我们自然就可以通过向下转型 (downcast)来重新获取类型信息,即在继承层次结构中向下移动。
我们知道向上转型总是安全的,因为基类不可能有比子类更多的接口。因此,通过基类接口发送的每条消息都能保证被子类接受。
然而对于向下转型来说,我们并不知道这些,在 Java 中,每个转型都会被检查。因此,即使看起来只是在执行一个普通的带括号的强制转型,但在运行时会检查此强制转型,以确保它实际上是你期望的类型,如果不是,则会抛出一个 ClassCastException
错误。这种在运行时检查类型的行为是 Java 反射(reflection)的一部分。以下示例里展示了一些基本的反射行为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 package com.yyj;class Useful { public void f () { System.out.println("Useful.f()" ); } } class MoreUseful extends Useful { @Override public void f () { System.out.println("MoreUseful.f()" ); } public void g () { System.out.println("MoreUseful.g()" ); } } public class Reflect { public static void main (String[] args) { Useful[] x = { new Useful (), new MoreUseful () }; x[0 ].f(); x[1 ].f(); ((MoreUseful)x[1 ]).g(); try { ((MoreUseful)x[0 ]).g(); } catch (ClassCastException e) { System.out.println("Exception: " + e.getMessage()); } } }
如果想访问 MoreUseful
对象的扩展接口,可以尝试向下转型,如果类型正确就会成功。否则会得到一个 ClassCastException
异常。其实你无须为此异常编写任何特殊代码,因为它表示一个程序员犯的错误,它可能发生在程序中的任何位置。