关于 Java 多态(Polymorphism)

之前看 Thinking in Java 的时候自认为理解了 Java 中的多态性和方法动态绑定,直到看到下面这个栗子:

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
42
43
44
45
46
47
class A {
public String show(D obj) {
return ("A and D");
}

public String show(A obj) {
return ("A and A");
}
}

class B extends A {
public String show(B obj) {
return ("B and B");
}

public String show(A obj) {
return ("B and A");
}
}

class C extends B { }

class D extends B { }

public static void main(String[] args) {
A a1 = new A();
A a2 = new B();
B b = new B();
C c = new C();
D d = new D();

System.out.println("1--" + a1.show(b));
System.out.println("2--" + a1.show(c));
System.out.println("3--" + a1.show(d));
System.out.println("4--" + a2.show(b));
System.out.println("5--" + a2.show(c));
System.out.println("6--" + a2.show(d));
}

/**
* 1--A and A
* 2--A and A
* 3--A and D
* 4--B and A
* 5--B and A
* 6--A and D
*/

最后的输出中,前三条是毫无疑问的,从第四条开始好像就不太符合我们直觉了,a2的实际类型是B, a2.show(b)调用的不应该是B.show(B)这个方法吗,为什么会调用B.show(A)方法呢?

这涉及到重载和重写在 JVM 中的实现,是 Java 多态性的基本体现。通过两个例子来分别说明:

重载(Overload) – 重载解析 (overload resolution)

在国内的文档中,这常被称为”静态分派”

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
// Bicycle 及其子类的源码在上面的栗子中可以找到
public class BicycleTest {
private void speed(Bicycle b){
System.out.println("Bicycle");
}

private void speed(MountainBike b){
System.out.println("MountainBike");
}

private void speed(RoadBike b){
System.out.println("RoadBike");
}

public static void main(String[] args) {
Bicycle bike01, bike02, bike03;
bike01 = new Bicycle();
bike02 = new MountainBike();
bike03 = new RoadBike();
BicycleTest test = new BicycleTest();
test.speed(bike01);
test.speed(bike02);
test.speed(bike03);
}
}

/**
* 输出:
* Bicycle
* Bicycle
* Bicycle
*/

从运行结果可以看到,不论传入的参数的实际类型是什么,最终调用的都是参数类型为Bicycle的重载方法。

首先厘清两个概念:静态类型实际类型

1
Bicycle bike02 = new MountainBike();

其中,Bicycle 也就是我们前面提到的引用变量类型,称为变量的静态类型,或外观类型,MountainBike称为变量的实际类型。变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

这种依赖静态类型来定位方法执行版本的动作称为静态分派,方法重载就是典型的静态分派,其选择方法的依据有两点:一是静态类型,二是方法参数(参数的类型也是依据静态类型)。

我们再看上面的例子,静态类型是确定的,因此使用哪个重载版本就完全取决于传入的参数类型是数量,而重载是通过参数的静态类型而不是实际类型作为判断依据的,因此在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了speed(Bicycle)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。

实际上,由于自动转型的存在,在方法重载中,方法的参数类型不一定“完全匹配”,很多情况下编译器只能确定一个“最优”的版本,重载方法是存在匹配优先级的,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void hi(char c){
System.out.println("char");
}
private void hi(int i){
System.out.println("int");
}

private void hi(Character C){
System.out.println("Character");
}
private void hi(Integer I){
System.out.println("Integer");
}

当我把hi(char c)注释后,调用的是hi(int),把hi(int)注释后,调用的是hi(Character)

自动转型的优先级:char -> int -> long -> float -> double -> Character -> Serializable/Comparable -> Object -> 之前类型的 可变长参数类型

没有包含short,因为那是向下转型,不是自动转型;Serializable/Comparable 这两个是 Character 实现的接口,优先级高于父类。如果同时出现两个参数分别为SerializableComparable<Character>的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动转型为哪种类型,会提示类型模糊,拒绝编译。程序必须在调用时显式地指定字面量的静态类型,如:hi((Serializable)'c'),才能编译通过。

重写(Override) – 动态分派

前面提到的,编译器是不知道实际类型的,因此重写的中方法确定发生在运行期。

运行阶段虚拟机的选择,也就是动态分派的过程。我们再看看文章开始举的例子,在执行a2.show(b)这句代码时,更准确地说,是在执行这句代码所对应的invokevirtual指令时,由于编译期已经根据静态类型(A) 和参数类型 (A) 决定目标方法的签名必须为show(A),此时(运行期)方法参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型是A还是Ba1 的实际类型是B,因此最终调用的是 Bshow(A) 方法。

在动态分派中,因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型,而静态分派,如前所述,由两个宗量进行选择,属于多分派类型。

动态分派的过程优化是通过虚拟方法表(Virtual method table)来进行的。

重载是编译期进行的,也被称为编译器多态/静态绑定/早期绑定;重写属于动态绑定/运行期多态/后期绑定

不能独立地看待多态,如果没有封装和继承的特性,就无法理解多态,其作为类关系“全景”中的一部分与其他特性协同工作。

References