引言
在面向對象編程 (OOP) 領域,有三個很重要的概念:封裝 (encapsulation)、繼承 (inheritance)、多態 (polymorphism)。本文主要在編程語言層面探討多態如何實現,由于筆者比較熟悉的語言只有兩種,所以主要探討多態在 C++ 和 Java 上的實現。C++有四種編程范式,本文主要關注其面向對象的編程范式。Java被認為是“最規范的”的面向對象語言,希望可以通過兩者的對比,給讀者一些啟示。
由于,兩種語言在設計理念上的不同,可能會導致敘述時無法使用某個語言特有的術語。所以,本文所有的術語基于面向對象編程的層面,而不是某種語言的層面。例如,“引用” 和 “指針” 不加以區別,“方法” 和 “函數” 不加以區別, “多繼承” 和 “多接口” 不加以區別。
靜態分派 vs. 動態分派
相信有面向對象編程經驗的朋友都應該對這兩概念不陌生,這兩種分派機制,正是多態的體現。最直觀的感受是,“方法重載”(method overload) 是靜態分派的一種,“方法重寫(覆蓋)” (method override) 則是動態分派的一種。靜態分派的最直接的解釋是在重載的時候是通過參數的靜態類型而不是實際類型作為判斷依據的。而動派分派則不然,它最直接的解釋是子類可以覆蓋超類中的方法,通過超類的引用可以調用子類的方法。下面是 Java 版的重載和重寫例子:
// overload example
class OverloadExample {
public int foo(int k) {
return k;
}
public int foo(int i, int j) {
return i + j;
}
}
// override example
class OverrideExampleBase {
public int foo(int i, int j) {
return i + j;
}
}
class OverrideExampleDerived extends OverrideExampleBase {
public int foo(int i, int j) {
return i * j;
}
}
// possible consumer code
class Driver {
public static void main() {
OverloadExample e1 = new OverloadExample();
e1.foo(10); // result 10
e1.foo(10, 20); // result 30
OverrideExampleBase e2 = new OverrideExampleBase();
OverrideExampleBase e3 = new OverrideExampleDerived();
e2.foo(10, 20); // result 30
e3.foo(10, 20); // result 200
}
}
體會上面的代碼,這兩者的區別是,靜態分派的代碼,方法調用在編譯期間已經可以確定了。而動態分派代碼的方法調用則需要在運行時才可以確定。
繼承 + 多態
稍微了解兩種分派機制后我們可以看到,靜態分派在實現上比較簡單,主要是檢查參數列表,選擇相匹配的就好了。本文主要討論的是動態分派機制在語言層面的實現。而一般來說,動態分派都需要和繼承有關系,如果沒有繼承關系,那么動派分派也就沒有什么意義。C++ 和 Java 兩種語言,其中 C++ 沒有定義專門的 interface
和 abstract
等關鍵詞,并且在語法上支持“多繼承”。Java 定義了這些關鍵字,不過一個類不能同時繼承自兩個超類,但是一個類可以實現多個接口。下文,將視實現“多接口”和“多繼承”為同一回事 (戰略上相同,戰術上不同)。
單繼承
在單繼承的情況下,兩種語言的實現在宏觀上基本一致,都是通過計算被調用函數在函數表中的偏移量 (offset) 來進行函數調用的。
Java 單繼承
開始之前先回顧一下一些基礎知識,首先是 JVM 的運行時數據區 (runtime data area),見下圖,每個區域的作用如下所述:
- 運行常量池 (runtime constant pool): 線程共享,存放程序需要的各種常量
- 方法區 (method area): 線程共享,存放的是類型信息,是對象創建的模板
- 堆內存 (heap): 線程共享,存放運行時產生的對象
- 虛擬機棧 (vm stack): 線程獨有,描述 Java 方法執行的內存模型
- 本地方法棧 (native method stack): 線程獨有,和虛擬機棧類似,但是這里是為虛擬機使用到的 Native 方法服務
- 程序計數寄存器 (program counter register): 線程獨有,指向當前線程所執行的字節碼的行號
其次,類是怎么被加載進入虛擬機的。根據 “Java 虛擬機規范”,類的加載可以分為一下幾個步驟:
在本文討論的范圍內,我們需要知道的是 ”解析階段 (resolution)”,這個階段主要干的事情就是把符號引用 (symbolic reference)解析稱為直接引用 (direct reference),即某個標識符在內存中的地址。
符號引用與虛擬機實現的布局無關,引用的目標并不一定要已經加載到內存中。各種虛擬機實現的內存布局可以各不相同,但是它們能接受的
符號引用必須是一致的,因為符號引用的字面量形式明確定義在Java虛擬機規范的Class文件格式中。直接引用可以是指向目標的指針,相對偏移量或是一個能間接定位到目標的句柄。如果有了直接引用,那引用的目標必定已經在內存中存在。
有了上面的知識,考慮如下程序:
class Base {
public String toString() {
return "override toString in Base";
}
public void foo() {
System.out.println("foo method in Base");
}
}
class Derived1 extends Base {
public void foo() {
System.out.println("foo method in Derived1");
}
}
class Derived2 extends Base {
public void foo() {
System.out.println("foo method in Derived2");
}
}
// possible consumer code
class Driver {
public static void main() {
Base ref = new Base();
Base ref1 = new Derived1();
Base ref2 = new Derived2();
ref.equals(ref1); // false, equals() method inherits from Object
ref1.toString(); // "override toString in Base"
ref2.toString(); // "override toString in Base"
ref1.foo(); // "foo method in Derived1"
ref2.foo(); // "foo method in Derived2"
}
}
如果上述 Driver
類的 main
方法被執行,相應的對象在內存中的模型應該如下圖所示 (對象模型內的方法表,永遠會將超類的方法按照原順序復制,然后再接著類新定義的方法)。考慮ref2.toString()
這一行代碼是怎么執行的,JVM 執行引擎是怎么定位到這個方法并且執行它的。這里很容易就會想當然,一種 錯誤 的想法是:JVM 根據堆上的對象實例,查找其方法表,找到 toString
方法,然后執行其字節碼。這里的問題是 ref2
前面聲明的類型是 Base
而不是其對象的類型 Derived2
,而在編譯期間,編譯器沒有辦法知道這個引用變量后面會指向一個子類的對象,所以在編譯期間沒法確定調用的方法。這也正是本文探討的關鍵點,什么機制可以讓虛擬機在運行時,正確的找到需要調用的方法。
Java 中通過超類指針訪問子類方法的操作,由字節碼指令invokevirtual
定義,JVM 采取的解決方案是這個樣子的:
- 通過超類的運行時常量池,確定目標方法的符號引用;
- 解析符號引用,得到目標方法的直接引用 (方法入口的地址)
- 計算該地址在超類方法表中的偏移量
- 通過上述的偏移量,調用引用變量指向的實際對象的這個偏移量指向的方法
C++ 單繼承
C++ 的實現原理上基本一致,但是由于 C++ 的運行環境沒有 Java 那么強大,所以更多的工作要依靠編譯器來完成。
同樣用一個例子來解釋一下,考慮如下代碼:
class A {
int i;
virtual void foo();
virtual void bar();
};
class B : public A {
int j;
virtual void foo();
virtual void bbb();
};
int main() {
A *pA = new B();
pA->foo();
return 0;
}
當代碼在編譯時,編譯器會為每一個類創建一個虛函數表,當一個類的對象被創建出來時,每個對象都有自己的虛函數表。故名思議,只有被定義為虛函數的函數,才會在這個表里。對象被實例化的不同過程中,這個表一直被維護,使其能夠正確的指向正確的函數。當上述的 main
代碼被執行時,pA
指針指向的對象的虛函數表變化如下圖所示。
如此,當使用 pA
指針指向子類對象時,因為虛函數表的存在,多態得意順利的體現。
多繼承(實現)
Java 的多實現和單繼承的前面兩步基本一致,但是不能通過偏移量來確定子類中的目標函數的地址了。因為在多現實的情況下,不能確保偏移量是一致的。Java 解決這個問題的方案非常的暴力,那就是搜索整個方法表,找到簽名一致的方法,然后調用之。
所以,我們可以得到一個結論,在 Java 中,調用 implement
實現的接口會比調用 extends
繼承的方法需要的時間長,因為前者需要搜索這個方法表,后者可以直接通過偏移量定位。
C++,很遺憾,也是因為沒有 Java 這個強大的運行時環境,所以實現會比較復雜。這里先暫時不考慮多繼承中的“鉆石問題”。考慮如下代碼:
這里,類 D
同時是 類 B
和 類 C
的子類,并且分別覆蓋了兩個超類其中的一個函數,添加了一個虛函數。毫無疑問,根據單繼承時的知識,我們可以知道類 B
和 類 C
的對象的內存模型應該如下圖所示:
有了這里兩個超類的對象模型,我們自然想知道類 D
的對象的模型應該是怎么樣的。單繼承的時候,有一個虛函數表,那么多繼承也很好辦,有多少個超類就有多少個虛函數表就好了。所以,這里意味著類 D
的對象應該有兩個虛函數表指針。我們應該知道,在 C++ 里一個對象的指針應該指向這個對象在內存中的首地址 (比如,數組的名字就是指向數組第一個元素的指針)。出于這點考慮,類 D
定義的變量和函數,應該和第一個聲明的超類虛函數和可繼承變量放在一起,這樣才可以實現函數覆蓋。
上述代碼中的的三個指針分別指向的位置,如下圖所示:
這里的指針位置,馬上就帶出了一個很嚴重的問題。如果在某種情況下,我想通過指針釋放動態分配的內存,執行了下面的代碼:
delete pC;
很顯然,pC
指針并沒有指向對象的首地址,這樣的 delete
語句只能釋放到一部分的對象內存,從而造成內存泄漏問題 (memory leak)。但是在這個問題之前,pC
是怎么指向上圖所示的地方的,通過代碼可以看到它指向的是一個 D
類對象,很顯然這個位置是合理的。但是編譯器是怎么做到的?實現這個很簡單,通過一個臨時指針變量就可以做到。編譯器隱性地執行了如下的代碼:
D *tmp = new D();
C *pC = tmp? tmp + sizeof(B) : 0;
這種操作,我們稱為“指針調整”,上述的方法能夠解決 pC
的創建問題,但是還是沒有解決釋放內存的問題。通常來說,目前解決的方案大致有兩種,一種是為虛函數表的每一個條目增加一個偏移量信息,標注從對象首地址到函數地址的偏移量。但是這種做法,對效率有比較大的影響,基本上不管那個虛表是否需要做指針調整,我們都要維護這個偏移量信息,因為在編譯期間并不知道到底那些需要進行指針調整。
另一種操作,被稱為“thun·k”技術。這種技術的作用是:虛函數表中的條目仍然繼續放一個虛函數實體地址,但是如果調用這個虛函數需要進行調整的話,該條目中的地址就指向一個thunk而不是一個虛函數實體的地址。這個方法的實現是為每個虛表條目都增加一小段匯編代碼,如果需要調整,匯編代碼會把指針調整到相應的位置,如果不需要,則不動。這個方法,沒有實際上增加虛函數表的內存消耗,而是相當于添加多了一個抽象層。聽起來比第一種方法好,但是顯然,實現的難度更大一些。
虛擬繼承
最后,來看一下著名的多繼承里面的“鉆石問題”及其解決方案“虛擬繼承”。假設,有如下圖所示的類結構以及代碼 (這是一會導致“鉆石問題”錯誤的例子):
重點關注一下 main
方法,試圖通過 “爺爺類” 的指針 pA
指向其 “孫子” 類的對象實例,然后調用 “爺爺類” 的方法。這里的問題在于,當 D
類對象在初始化時,需要先初始化其父類對象 (即 B
類和 C
類),同理,初始化這兩個類的時候,需要線初始化 A
類對象。但是由于這里并沒有制定虛擬繼承。所以,B
類在初始化的時候初始化了一個 A
類對象 (給個名字叫做 “objA1”),C
類在初始化的時候也初始化了一個 A
類對象 (給個名字叫做 “objA2”),這就有了兩個 A
類對象,當我們在 main
里面執行 pA->aaa()
的時候,出現了歧義,不知道應該執行哪一個 A
類對象 (objA1 or objA2) 的方法。這個就稱為“鉆石問題”。
這時,對象 D
的內存布局如下圖所示,紅色標注的就是沖突的兩個方法 (A1為A類型的一個對象,A2為A類型的另一個對象) :
解決方法,當然就是聲明虛繼承了。虛擬繼承之后,初始化 B
和 C
的時候不會創建兩個 A
的對象,而是只會創建一個,然后這兩個子類共享一個超類。如下圖所示,不存在 A1 和 A2 只有一個 A: