Java 中語法上實現多態的方式分為兩種:1. 重載、2. 重寫,重載又稱之為編譯時的多態,重寫則是運行時的多態。
那么底層究竟時如何實現多態的呢,通過閱讀『深入理解 Java 虛擬機』這本書(后文所指的書,如無特殊說明,指的都是這本書),對多態的實現過程有了一定的認識。以下內容是對學習內容的記錄,以備今后回顧。
寫着寫着突然發現內容有點多,分為上和下,上主要記錄重載的知識點,下則是重寫的相關知識點。
重載
重載就是根據方法的參數類型、參數個數、參數順序的不同,來實現同名方法的不同調用,重載是通過靜態分派來實現的,那么什么是靜態分派呢,先展示一下書中的示例代碼:
public class StaticDispatch {
static abstract class Human {
}
static class Man extends Human {
}
static class Woman extends Human {
}
public void sayHello(Human guy) {
System.out.println("hello,guy!");
}
public void sayHello(Man guy) {
System.out.println("hello,gentleman!");
}
public void sayHello(Woman guy) {
System.out.println("hello,lady!");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch sr = new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
}
}
//輸出:
//hello,guy!
//hello,guy!
在 IDEA 中可以看到未被調用的方法名為灰色,這就可以知道示例代碼在編譯期間就已經確定了會調用的方法。在了解靜態分派前,需要先熟悉一下靜態類型和實際類型這兩個概念。
靜態類型和實際類型
Human man = new Man();
Human
稱為變量的靜態類型(Static Type),或者叫做外觀類型(Apparent Type),Man
稱為變量的實際類型(Actual Type)。
書中有這樣一段話:
靜態類型和實際類型在程序中都可以發生變化,區別是靜態類型的變化僅僅在使用時發生,變量本身的靜態類型不會被改變,並且最終的靜態類型是在編譯期可知的;而實際類型變化的結果在運行期才可確定,編譯器在編譯程序的時候並不知道一個對象的實際類型是什么。
書中還舉個例子:
//實際類型變化
Human man = new Man();
man = new Woman();
// 個人理解:man 的原本的實際類型是 Man,當第 3 行執行時,man 的實際類型就變成了 Woman
//靜態類型變化
sr.sayHello((Man) man);
// 個人理解:接着上一步,man 的靜態類型是 Human,此時顯式轉換為 Man,作為 sayHello(Man guy)方法的參數
sr.sayHello((Woman) man);
// 個人理解:前面將 man 的靜態類型轉換為 Man,但是第 8 行方法中的 man 靜態類型還是從 Human 轉換成 Woman
// 最終,man 的靜態類型還是聲明時的 Human
對於書中的那段話,理解起來還是有點繞,以下是我的個人理解:
- 首先靜態類型和實際類型都是針對變量而言的,描述的是變量的屬性,並且這兩個屬性會發生變化;
- 靜態類型指的是聲明該變量時的類型,而實際類型指的是給該變量賦值時賦值號右邊的變量類型;
- 靜態類型的變化僅僅在使用時發生,這里要注意兩點:1)僅僅的意思是要么變量的靜態類型不變,要么就是在使用該變量的時候發生了變化;2)最終該變量的靜態類型是不會改變的,還是原來聲明時的類型。
StaticDispatch
類的 main 方法中,sayHello 方法的兩次調用傳入的參數靜態類型是一致的,但是實際類型不通,結果調用的是同一個方法。從這一點可以看出,編譯器是根據參數的靜態類型來確定調用的方法的,靜態類型在代碼寫完之后,就是已知的了,所以說重載在代碼運行前就已經確定了。
截取 main 方法的字節碼:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
// 0xbb 創建一個對象,並將其引用值壓入棧頂
0: new #7 // class jvmlearn/StaticDispatch$Man
// 0x5c 復制棧頂數值並將復制值壓入棧頂
3: dup
// 0xb7 調用超類構造方法,實例初始化方法,私有方法
// 這個指令會用掉當前棧頂的值,所以前面復制了一份
4: invokespecial #8 // Method jvmlearn/StaticDispatch$Man."<init>":()V
// 0x4c 將棧頂引用型數值存入第二個本地變量
// 可以看下面的局部變量表
7: astore_1
8: new #9 // class jvmlearn/StaticDispatch$Woman
11: dup
12: invokespecial #10 // Method jvmlearn/StaticDispatch$Woman."<init>":()V
15: astore_2
16: new #11 // class jvmlearn/StaticDispatch
19: dup
20: invokespecial #12 // Method "<init>":()V
23: astore_3
// 0x2d 將第四個引用類型本地變量推送至棧頂
24: aload_3
25: aload_1
// 0xb6 調用實例方法
// 這里可以直接看到參數是 Human 類型,34 行的代碼也一樣
26: invokevirtual #13 // Method sayHello:(Ljvmlearn/StaticDispatch$Human;)V
29: aload_3
30: aload_2
31: invokevirtual #13 // Method sayHello:(Ljvmlearn/StaticDispatch$Human;)V
34: return
LineNumberTable:// 行號表
line 30: 0
line 31: 8
line 32: 16
line 33: 24
line 34: 29
line 35: 34
LocalVariableTable:// 局部變量表,存了 main 方法的參數和局部變量,靜態方法第一個局部變量不是 this,也沒有 this
Start Length Slot Name Signature
0 35 0 args [Ljava/lang/String;
8 27 1 man Ljvmlearn/StaticDispatch$Human;
16 19 2 woman Ljvmlearn/StaticDispatch$Human;
24 11 3 sr Ljvmlearn/StaticDispatch;
現在回到靜態分派的定義,所有依賴靜態類型來定位方法執行版本的分派動作稱為靜態分派,靜態分派的典型應用就是方法的重載。
特點
靜態分派發生在編譯階段,是由編譯器來確定使用哪個重載的方法,但這個重載的方法並不是唯一確定的。實際上編譯器只是查找出當前重載的所有方法里面最合適的那一個。產生這種情況的原因,摘取書上的解釋:
字面量不需要定義,所以字面量沒有顯式的的靜態類型,它的靜態類型只能通過語言上的規則去理解和推斷。
下面是書上給出的關於重載的這個特點的示例代碼:
public class Overload {
public static void sayHello(Object arg) {
System.out.println("hello Object");
}
public static void sayHello(int arg) {
System.out.println("hello int");
}
public static void sayHello(long arg) {
System.out.println("hello long");
}
public static void sayHello(Character arg) {
System.out.println("hello Character");
}
public static void sayHello(char arg) {
System.out.println("hello char");
}
public static void sayHello(char... arg) {
System.out.println("hello char ...");
}
public static void sayHello(Serializable arg) {
System.out.println("hello Serializable");
}
public static void main(String[] args) {
sayHello('a');
}
}
在 IDEA 中可以看到,調用的是 sayHello(char arg)方法。如果將該方法注釋掉,編譯器並不會報錯,可以看到接下來調用的方法是 sayHello(int arg)。
可以進一步測試,不斷的注釋當前調用的方法,就能發現編譯器查找重載方法的規則,即自底向上的進行自動類型轉換,自底向上進行查找。
參考
- 『深入理解 Java 虛擬機』:第二版,8.3.2 分派:1. 靜態分派 P-247