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

jopen 11年前發布 | 21K 次閱讀 Java C/C++開發 C/C++

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

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

Java 的實現方式

Java 對于方法調用動態綁定的實現主要依賴于方法表,但通過類引用調用和接口引用調用的實現則有所不同。總體而言,當某個方法被調用時,JVM 首先要查找相應的常量池,得到方法的符號引用,并查找調用類的方法表以確定該方法的直接引用,最后才真正調用該方法。以下分別對該過程中涉及到的相關部分 做詳細介紹。

JVM 的結構

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

圖 1.JVM 運行時結構
多態在 Java 和 C++ 編程語言中的實現比較

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

注意到,該類型信息和 class 對象是不同的。class 對象是 JVM 在載入某個類后于堆 (heap) 中創建的代表該類的對象,可以通過該 class 對象訪問到該類型信息。比如最典型的應用,在 Java 反射中應用 class 對象訪問到該類支持的所有方法,定義的成員變量等等。可以想象,JVM 在類型信息和 class 對象中維護著它們彼此的引用以便互相訪問。兩者的關系可以類比于進程對象與真正的進程之間的關系。

Java 的方法調用方式

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

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

常量池(constant pool)

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

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

CONSTANT_Utf8_info

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

CONSTANT_Class_info

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

CONSTANT_NameAndType_info

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

CONSTANT_InterfaceMethodref_info

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

CONSTANT_Methodref_info

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

圖 2. 常量池各表的關系
多態在 Java 和 C++ 編程語言中的實現比較

可 以看到,給定任意一個方法的索引,在常量池中找到對應的條目后,可以得到該方法的類索引(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 的方法表
多態在 Java 和 C++ 編程語言中的實現比較

可 以看到,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. 解析調用過程
多態在 Java 和 C++ 編程語言中的實現比較

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 的方法表
多態在 Java 和 C++ 編程語言中的實現比較

可 以看到,由于接口的介入,繼承自于接口 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 的對象模型
多態在 Java 和 C++ 編程語言中的實現比較

如下的調用代碼

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. 類靜態結構關系圖
多態在 Java 和 C++ 編程語言中的實現比較

Engineer 實例對象模型為:

圖 8.Engineer 對象模型
多態在 Java 和 C++ 編程語言中的實現比較

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

設有如下的代碼 ,

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

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

圖 7.Engineer 實例
多態在 Java 和 C++ 編程語言中的實現比較

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. 指針調整 - 擴展方法表
多態在 Java 和 C++ 編程語言中的實現比較

這種方法將指針所有調整的 offset 存儲于方法表的每個條目中,當調用方法表中的方法時,首先利用 offset 的值完成指針調整再做實際的調用。缺點顯而易見,增加了方法表的大小,而且并不是每個方法都需要做指針調整。

圖 9. 指針調整 -thunk 技術
多態在 Java 和 C++ 編程語言中的實現比較

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

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

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

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

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

來自:http://www.ibm.com/developerworks/cn/java/j-lo-polymorph/

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!