多態在 Java 和 C++ 編程語言中的實現比較

衆所周知,多態是面向對象編程語言的重要特性,它容許基類的指針或引用指向派生類的對象,而在具體訪問時實現方法的動態綁定。C++ 和 Java 做爲當前最爲流行的兩種面向對象編程語言,其內部對於多態的支持究竟是如何實現的呢,本文對此作了全面的介紹。 編程

注意到在本文中,指針和引用會互換使用,它們僅是一個抽象概念,表示和另外一個對象的鏈接關係,無須在乎其具體的實現。 app

Java 的實現方式

Java 對於方法調用動態綁定的實現主要依賴於方法表,但經過類引用調用和接口引用調用的實現則有所不一樣。整體而言,當某個方法被調用時,JVM 首先要查找相應的常量池,獲得方法的符號引用,並查找調用類的方法表以肯定該方法的直接引用,最後才真正調用該方法。如下分別對該過程當中涉及到的相關部分 作詳細介紹。 編程語言

JVM 的結構

典型的 Java 虛擬機的運行時結構以下圖所示 函數

圖 1.JVM 運行時結構 this

圖 1.JVM 運行時結構

此 結構中,咱們只探討和本文密切相關的方法區 (method area)。當程序運行須要某個類的定義時,載入子系統 (class loader subsystem) 裝入所需的 class 文件,並在內部創建該類的類型信息,這個類型信息就存貯在方法區。類型信息通常包括該類的方法代碼、類變量、成員變量的定義等等。能夠說,類型信息就是類 的 Java 文件在運行時的內部結構,包含了改類的全部在 Java 文件中定義的信息。 spa

注意到,該類型信息和 class 對象是不一樣的。class 對象是 JVM 在載入某個類後於堆 (heap) 中建立的表明該類的對象,能夠經過該 class 對象訪問到該類型信息。好比最典型的應用,在 Java 反射中應用 class 對象訪問到該類支持的全部方法,定義的成員變量等等。能夠想象,JVM 在類型信息和 class 對象中維護着它們彼此的引用以便互相訪問。二者的關係能夠類比於進程對象與真正的進程之間的關係。 設計

Java 的方法調用方式

Java 的方法調用有兩類,動態方法調用與靜態方法調用。靜態方法調用是指對於類的靜態方法的調用方式,是靜態綁定的;而動態方法調用須要有方法調用所做用的對 象,是動態綁定的。類調用 (invokestatic) 是在編譯時刻就已經肯定好具體調用方法的狀況,而實例調用 (invokevirtual) 則是在調用的時候才肯定具體的調用方法,這就是動態綁定,也是多態要解決的核心問題。 指針

JVM 的方法調用指令有四個,分別是 invokestatic,invokespecial,invokesvirtual 和 invokeinterface。前兩個是靜態綁定,後兩個是動態綁定的。本文也能夠說是對於 JVM 後兩種調用實現的考察。 code

常量池(constant pool)

常量池中保存的是一個 Java 類引用的一些常量信息,包含一些字符串常量及對於類的符號引用信息等。Java 代碼編譯生成的類文件中的常量池是靜態常量池,當類被載入到虛擬機內部的時候,在內存中產生類的常量池叫運行時常量池。 對象

常量池在邏輯上能夠分紅多個表,每一個表包含一類的常量信息,本文只探討對於 Java 調用相關的常量池表。

CONSTANT_Utf8_info

字符串常量表,該表包含該類所使用的全部字符串常量,好比代碼中的字符串引用、引用的類名、方法的名字、其餘引用的類與方法的字符串描述等等。其他常量池表中所涉及到的任何常量字符串都被索引至該表。

CONSTANT_Class_info

類信息表,包含任何被引用的類或接口的符號引用,每個條目主要包含一個索引,指向 CONSTANT_Utf8_info 表,表示該類或接口的全限定名。

CONSTANT_NameAndType_info

名字類型表,包含引用的任意方法或字段的名稱和描述符信息在字符串常量表中的索引。

CONSTANT_InterfaceMethodref_info

接口方法引用表,包含引用的任何接口方法的描述信息,主要包括類信息索引和名字類型索引。

CONSTANT_Methodref_info

類方法引用表,包含引用的任何類型方法的描述信息,主要包括類信息索引和名字類型索引。

圖 2. 常量池各表的關係

圖 2. 常量池各表的關係

可 以看到,給定任意一個方法的索引,在常量池中找到對應的條目後,能夠獲得該方法的類索引(class_index)和名字類型索引 (name_and_type_index), 進而獲得該方法所屬的類型信息和名稱及描述符信息(參數,返回值等)。注意到全部的常量字符串都是存儲在 CONSTANT_Utf8_info 中供其餘表索引的。

方法表與方法調用

方法表是動態調用的核心,也是 Java 實現動態調用的主要方式。它被存儲於方法區中的類型信息,包含有該類型所定義的全部方法及指向這些方法代碼的指針,注意這些具體的方法代碼多是被覆寫的方法,也多是繼承自基類的方法。

若有類定義 Person, Girl, Boy,

清單 1

class Person { 
 public String toString(){ 
    return "I'm a person."; 
	 } 
 public void eat(){} 
 public void speak(){} 
	
 } 

 class Boy extends Person{ 
 public String toString(){ 
    return "I'm a boy"; 
	 } 
 public void speak(){} 
 public void fight(){} 
 } 

 class Girl extends Person{ 
 public String toString(){ 
    return "I'm a girl"; 
	 } 
 public void speak(){} 
 public void sing(){} 
 }

當這三個類被載入到 Java 虛擬機以後,方法區中就包含了各自的類的信息。Girl 和 Boy 在方法區中的方法表可表示以下:

圖 3.Boy 和 Girl 的方法表

圖 3.Boy 和 Girl 的方法表

可 以看到,Girl 和 Boy 的方法表包含繼承自 Object 的方法,繼承自直接父類 Person 的方法及各自新定義的方法。注意方法表條目指向的具體的方法地址,如 Girl 的繼承自 Object 的方法中,只有 toString() 指向本身的實現(Girl 的方法代碼),其他皆指向 Object 的方法代碼;其繼承自於 Person 的方法 eat() 和 speak() 分別指向 Person 的方法實現和自己的實現。

Person 或 Object 的任意一個方法,在它們的方法表和其子類 Girl 和 Boy 的方法表中的位置 (index) 是同樣的。這樣 JVM 在調用實例方法其實只須要指定調用方法表中的第幾個方法便可。

如調用以下:

清單 2

class Party{ 
…
 void happyHour(){ 
 Person girl = new Girl(); 
 girl.speak(); 
…
	 } 
 }

當編譯 Party 類的時候,生成girl.speak()的方法調用假設爲:

Invokevirtual #12

設該調用代碼對應着 girl.speak(); #12 是 Party 類的常量池的索引。JVM 執行該調用指令的過程以下所示:

圖 4. 解析調用過程

圖 4. 解析調用過程

JVM 首先查看 Party 的常量池索引爲 12 的條目(應爲 CONSTANT_Methodref_info 類型,可視爲方法調用的符號引用),進一步查看常量池(CONSTANT_Class_info,CONSTANT_NameAndType_info ,CONSTANT_Utf8_info)可得出要調用的方法是 Person 的 speak 方法(注意引用 girl 是其基類 Person 類型),查看 Person 的方法表,得出 speak 方法在該方法表中的偏移量 15(offset),這就是該方法調用的直接引用。

當 解析出方法調用的直接引用後(方法表偏移量 15),JVM 執行真正的方法調用:根據實例方法調用的參數 this 獲得具體的對象(即 girl 所指向的位於堆中的對象),據此獲得該對象對應的方法表 (Girl 的方法表 ),進而調用方法表中的某個偏移量所指向的方法(Girl 的 speak() 方法的實現)。

接口調用

由於 Java 類是能夠同時實現多個接口的,而當用接口引用調用某個方法的時候,狀況就有所不一樣了。Java 容許一個類實現多個接口,從某種意義上來講至關於多繼承,這樣一樣的方法在基類和派生類的方法表的位置就可能不同了。

清單 3

interface IDance{ 
   void dance(); 
 } 

 class Person { 
 public String toString(){ 
   return "I'm a person."; 
	 } 
 public void eat(){} 
 public void speak(){} 
	
 } 

 class Dancer extends Person 
 implements IDance { 
 public String toString(){ 
   return "I'm a dancer."; 
	 } 
 public void dance(){} 
 } 

 class Snake implements IDance{ 
 public String toString(){ 
   return "A snake."; 
	 } 
 public void dance(){ 
 //snake dance 
	 } 
 }

圖 5.Dancer 的方法表

圖 5.Dancer 的方法表

可 以看到,因爲接口的介入,繼承自於接口 IDance 的方法 dance()在類 Dancer 和 Snake 的方法表中的位置已經不同了,顯然咱們沒法經過給出方法表的偏移量來正確調用 Dancer 和 Snake 的這個方法。這也是 Java 中調用接口方法有其專有的調用指令(invokeinterface)的緣由。

Java 對於接口方法的調用是採用搜索方法表的方式,對以下的方法調用

invokeinterface #13

JVM 首先查看常量池,肯定方法調用的符號引用(名稱、返回值等等),而後利用 this 指向的實例獲得該實例的方法表,進而搜索方法表來找到合適的方法地址。

由於每次接口調用都要搜索方法表,因此從效率上來講,接口方法的調用老是慢於類方法的調用的。

C++ 的實現方式

從 上文能夠看到,Java 對於多態的實現依賴於方法表,但比較特殊的是,對於接口的支持是很是不一樣的,每次調用都要搜索方法表。實際上,在 C++ 中,單繼承時對於多態的實現很是相似於 Java,但因爲支持多重繼承,這會碰到和 Java 支持接口動態調用一樣的問題,C++ 的解決方案是利用對象的多個方法表指針,不幸的是,這會引入額外的指針調整的複雜性。

單繼承

單繼承時,C++ 對於多態的實現本質上與 Java 是同樣的,也是基於方法表。但 C++ 在編譯時就能夠確認要調用的方法在方法表中的位置,而沒有 JVM 在方法調用時查詢常量池的過程。

C++ 編譯時,編譯器會自動作不少工做,其中之一就是在須要時在對象插入一個變量 vptr 指向類的方法表。如 Person,、Girl 的類定義與上文中 Java 相似,若

清單 4

class Person{ 
	 . . . 
 public : 
    Person (){} 
    virtual ~Person (){}; 
    virtual void speak (){}; 
    virtual void eat (){}; 
 }; 

class Girl : public Person{ 
	 . . . 
   public : 
   Girl(){} 
   virtual ~Girl(){}; 
   virtual void speak(){}; 
   virtual void sing(){}; 
 };

則 Person 與 Girl 實例的內存對象模型爲:

圖 6.Person 與 Girl 的對象模型

圖 6.Person 與 Girl 的對象模型

以下的調用代碼

Person *p = new Girl(); 
 p->speak(); 
 p->eat();

經編譯器編譯後調用代碼爲:

p->vptr[1](p); 
 p->vptr[2](p);

這樣在運行時,會天然的過渡到對 Girl 的相應函數的調用。

能夠 看到方法表中沒有各自的構造函數,這是由於 C++ 的方法表中僅含有用 virtual 修飾的方法,非 virtual 的方法是靜態綁定的,沒有必要佔用方法表的空間。這與 Java 是不一樣的,Java 的方法表含有類所支持的全部的方法,能夠說,Java 類的全部方法都是」virtual」(動態綁定)的。

多重繼承

多重繼承下,狀況就徹底不一 樣了,由於兩個不一樣的類,其繼承自與同一個基類的方法,在各自的方法表中的位置可能不一樣(和 Java 中的接口狀況相似),但 Java 在運行時有 JVM 的支持,C++ 在這裏引入了多個指向方法表的指針來解決這個問題,由此帶來了調整指針位置的額外複雜性。

如有以下關係的三個類,Engineer 繼承自 Person 和 Employee

圖 7. 類靜態結構關係圖


圖 7. 類靜態結構關係圖

Engineer 實例對象模型爲:

圖 8.Engineer 對象模型


圖 8.Engineer 對象模型

能夠看到 Engineer 實例有兩個指向方法表的指針,這是與 Java 大不相同的。

設有以下的代碼 ,

清單 5

Engineer *p = new Engineer(); 
 Person * p1 = (Person *)p; 
 Empolyee *p2 = (Employee *)p;

則各指針在運行時分別指向各自的子對象,以下所示:

圖 7.Engineer 實例

圖 7.Engineer 實例

C++ 中對象的指針老是指向對象的起始處,如上述代碼中,p 是 Engineer 對象的起始地址,而 p1 指向 p 轉型成 Person 子對象的指針,能夠看到實際上,二者是相等的;但 Employee 子對象的指針 p2 則於 p 和 p1 不一樣,實際上

p2 = p + sizeof(Person); 
 p1->eat(); 
 p2->work();

則編譯後生成的調用代碼爲:

*(p1->vptr1[i]) (p1) 
 *(p2->vptr2[j]) (p2)

某些狀況下,甚至須要將 this 指針調整到整個對象的起始處,如:

delete p2;

析構函數的 this 指針要被調整到 p 所指向的位置,不然則會出現內存泄漏。設析構函數在方法表中的位置爲 0,則編譯後爲:

*(p2->vptr2[0]) (p)

對 於指針的調整,編譯器沒有足夠的知識在編譯時刻完成這個任務。如上例中,對於 p2 所指向的對象,該對象類型多是 Employee 或任何該類的子類 ( 其它的子類如 Teacher 等 ),編譯器沒法確切的知道 p2 和整個對象的初始地址的距離 (offset), 這樣的調整隻能發生在運行時刻。

通常有兩種方法來調整指針,以下圖:

圖 8. 指針調整 - 擴展方法表

圖 8. 指針調整 - 擴展方法表

這種方法將指針全部調整的 offset 存儲於方法表的每一個條目中,當調用方法表中的方法時,首先利用 offset 的值完成指針調整再作實際的調用。缺點顯而易見,增長了方法表的大小,並且並非每一個方法都須要作指針調整。

圖 9. 指針調整 -thunk 技術

圖 9. 指針調整 -thunk 技術

這就是所謂的 thunk 技術,方法表的每一個條目指向一小段彙編代碼,這段代碼來保證作指針調整和調用正確的方法,至關於加了一層抽象。

多態在 Java 和 C++ 中的實現比較

上文分別對於多態在 Java 和 C++ 中的實現作了比較詳細的介紹,下面對這兩種語言的多態實現的異同作個小結:

  • 單繼承狀況下,二者實如今本質上相同,都是使用方法表,經過方法表的偏移量來調用具體的方法。
  • Java 的方法表中包含 Java 類所定義的全部實例方法,而 C++ 的方法表則只包含須要動態綁定的方法 (virtual 修飾的方法 )。這樣,在 Java 下全部的實例方法都要經過方法表調用,而 C++ 中的非虛方法則是靜態綁定的。
  • 任意 Java 對象只 「指向」一個方法表,而 C++ 在多重繼承下則可能指向多個方法表,編譯器保證這多個方法表的正確初始化。
  • 多層繼承中 C++ 面臨的主要問題是 this 指針的調整,設計更精巧更復雜;而 Java 在接口調用時徹底採用搜索的方式,實現更直觀,但調用效率比實例方法調用要慢許多。

可 以看到,二者之間既有類似之處,也有不一樣的地方。對於單繼承的實現本質上是同樣的,但也有細微的差異(如方法表);差異最大的是對於多重繼承(多重接口) 的支持。實際上,因爲 C++ 是靜態編譯型語言,它沒法像 Java 那樣,在運行時刻動態的「查找」所要調用的方法。

相關文章
相關標籤/搜索