(三十)分派調用:靜態分派和動態分派
分派調用
其實分派分為兩種,即動態分派和靜態分派。我們在了解分派的時候,通常把它們與重寫和重載結合到一起。
重載(overload)與靜態分派
我們先看一個題:
public class Main { static abstract class Father { } static class Son extends Father { } static class Daughter extends Father { } public void getSex(Daughter daughter) { System.out.println("i am a girl"); } public void getSex(Son son) { System.out.println("i am a boy"); } public void getSex(Father son) { System.out.println("i am a father"); } public static void main(String[] args) { Father son = new Son(); Father daughter= new Daughter(); Main main = new Main(); main.getSex(son); main.getSex(daughter); } }
其實這個栗子就體現了重載。
要是我們在代碼裏改一下:
main.getSex((Son)son);
就會輸出i am a boy
其實這裏也體現出了java的靜態分派,我們都可以看到main對象已經確認了,那麽main在運行main.getSex(son);
時選擇方法的時候,到底是選擇getSex(Son son)
還是getSex(Father son)
我們在代碼中son的引用類型是Father
,但是它的實際類型卻是Son
。
我們再來看看生成的字節碼:
字節碼裏面0-23我們直接跳過,因為0-23對用的代碼是
Father son = new Son(); Father daughter = new Daughter(); Main main = new Main();
這裏的字節碼的作用是創建內存空間,然後把son 、daughter 和main 實例放到第1、2、3個實例變量表Slot中,這裏其實還有第0個實例,是this指針,放到的第0個slot中,這個超出了本文要講解的內容,故跳過。
我們從24看起,aload_x是把剛剛創建的實例放到操作數棧中,然後才能對其操作。後面第26行可以看到:
invokevirtual #50 //Method getSex:(LMain$Father;)
這裏相信大家都可以看出,字節碼中已經確定了方法的接收者是main和方法的版本是getSex(Father father)
,所以我們在運行代碼的時候,會輸出i am a father
。
其實java編譯器在重載的時候是通過參數的靜態類型而不是實際類型來確定使用哪個重載的版本的。所以這裏在字節碼中,選擇了getSex(Father father)
作為調用目標並把這個方法的符號引用寫到main方法的幾個invokevirtual
指令的參數裏面。
所以依賴靜態類型來定位方法執行的版本的分派動作成為靜態分派。靜態分派的典型應用是方法重載,而且靜態分派發生在編譯期間,因此,靜態分派的動作是由編譯器發出的。
另外,編譯器能確定出方法的重載版本,但在很多的時候,這個版本並不一定是唯一的,比如我把上面的代碼改一下:
public class Main { static abstract class Father { } static class Son extends Father { } public void getSex(Son son) { System.out.println("i am a boy"); } public void getSex(Father son) { System.out.println("i am a father"); } public static void main(String[] args) { Son son = new Son(); Main main = new Main(); main.getSex(son); } }
然後再輸出:
QQ截圖20160724221829.png這是很正常的執行結果,要是我們把getSex(Son son)註釋掉,然後再運行試試:
發現,編譯器並找不到getSex(Son son)
這個方法,只有作出適當的妥協,把son向上轉型為Father,然後選擇了getSex(Father son)
方法。
要是我們再把getSex(Father son)
註釋掉,會發現:
這裏又選擇了妥協並向上繼續轉型成Object。
綜上所述:靜態分派是選擇的最合適的一個方法版本來重載,然而這個版本並不是唯一確定的。我們在寫代碼的時候,要盡量避免這種情況發生,雖然這似乎能顯示出你知識的很淵博,但這並不是一個明智的選擇。
重寫(override)與動態分派
看完了靜態分派,我們再來看看動態分派。動態分派經常與重寫緊密聯系在一起,那麽我們就先來看一個重寫的栗子:
public class Main { static class Father { public void say(){ System.out.println("i am fasther"); } } static class Son extends Father { @Override public void say() { System.out.println("i am son"); } } static class Daughter extends Father { @Override public void say() { System.out.println("i am daughter "); } } public static void main(String[] args) { Father son = new Son(); Father daughter = new Daughter(); son.say(); daughter.say(); } }
output:
i am son
i am daughter
相信大家都知道輸出結果是什麽,三個類都有say()方法,但是虛擬機是怎樣知道調用哪個方法的呢? 別急,我們還是按照慣例,看看字節碼:
現在相信大家大概都能看懂裏面字節碼是怎樣回事了吧?
我們發現第17行和第21行對應的java代碼應該是:
son.say();
daughter.say()
從字節碼來看,這兩行代碼是一樣的。調用了同一個類的同一個方法,都是Father.say()
,那為什麽他們最後的輸出卻不一樣??
這裏的原因其實要從invokevirtual
的多態查找開始說起,invokevirtual
指令運行時的解析過程大概如下:
- 找到操作數棧的棧頂元素所指向的對象的實際類型,記作C
- 如果在類型C中找到與描述符和簡單名稱都相符的方法,則進行訪問權限校驗。通過則放回這個方法的直接引用,否則返回
illegalaccesserror
。 - 否則,則按照繼承關系從下住上依次對C的父類進行步驟2的查找。
- 如果始終沒有找到合適的方法,則跑出
AbstractMethodError
異常。
由於invokevirtual
指令在執行的第一步就對運行的時候的接收者的實際類型進行查找,所以上面兩次調用的invokevirtual
指令都能成功找到實際類型的say()方法,然後把類方法的符號引用解析到不同的直接引用上面,這也是重寫的體現。
然後這種運行期根據實際類型來判斷方法的執行版本的分派過程叫作動態分派。
(三十)分派調用:靜態分派和動態分派