再談多態――向上映射及VMT/DMT
作者:Nicrosoft(nicrosoft@sunistudio.com) 2001.10.9
個人主頁:http://www.sunistudio.com/nicrosoft/
東日文檔:http://www.sunistudio.com/asp/sunidoc.asp
在《淺談多態――概念描述》一文中,提到多態的本質就是“將子類類型的指針賦值給父類類型的指針”。那么,為什麼這種賦值是允許的,或者說是安全的呢?反過來行不行?虛函數的動態綁定是如何實現的呢?這些問題都將在本文得到解答。
假設有如下代碼(Object Pascal語言描述):
T1 = class
PRivate
member1 : integer;
public
function func1 : Integer; virtual;
function func2 : Integer; virtual;
function func3 : Integer; virtual;
end;
T2 = class(T1)
private
member2 : integer;
public
function func1 : Integer; override;
function func2 : Integer; override;
end;
最終結果是,T1類的實例的內存分布圖如下(僅說明原理,并不表示編譯器一定也是如此實現):
___________________ ________________
| vptr |-------> | T1.func1 |
| member1 | | T1.func2 |
~~~~~~~~~~~~~~~~~~~ | T1.func3 |
~~~~~~~~~~~~~~~~
其中,vptr是編譯器自動加入的一個成員指針(稱為虛指針)。只有存在虛函數或動態函數或純虛函數的類才會被編譯器加入這個成員指針,該指針指向一個稱為“虛函數表”(Object Pascal中成為“虛方法表”――VMT)的內存區域。虛函數表中,保存了每一個虛函數的入口地址。
T2類的實例的內存分布圖如下:
___________________ ________________
| vptr |-------> | T2.func1 |
| member1 | | T2.func2 |
| member2 | | T1.func3 |
~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~
從圖中我們可以知道,子類對象所占的空間大于父類對象所占空間。因此,當發生將子類類型的指針賦值給父類類型的指針的賦值時(即所謂的“向上映射”),也就是父類類型的指針指向了子類類型的對象所占的內存空間,那么,很顯然,可以保證父類類型指針的可訪問范圍都是有效,所以這種“向上映射”是絕對安全的(所謂“向上”是指類層次的上下關系,父類在上,子類在下)。這種賦值是得到編譯器認可的。
也可以很容易得出結論,“向下映射”則未必安全(除非程序員真正知道指針所指對象的實際類型)。因此,這種賦值是不被編譯器允許的,當然,程序員可以通過類似 T1(Obj) 的形式進行強制類型轉換,但這種強制類型轉換很不安全(可以發生在任何類和類之間),Object Pascal推薦使用 as 算符進行類型之間的轉換,如: (Obj as T1),使用 as 算符,編譯器會檢查對象類型和目標類型是否相容。如果相容,轉換被允許,否則編譯出錯。
接著,我們看看虛函數的動態綁定是如何實現的。先看如下代碼:
procedure Test;
var O : T1;
begin
O := T2.Create;
O.func1;
O.func3;
O.Free;
end;
看著上面的內存布局圖,當執行 O := T2.Create; 后,一個 T1 類型的指針指向 T2 實體。執行 O.func1 時,編譯器通過 vptr 找到虛函數表,在虛函數表中定位到了 T2.func1(由于 T1.func1 被“覆蓋”了,因此虛函數表中找不到 T1.func1),于是,T2.func1 被調用,這就是動態綁定!但由于 T2 沒有重寫 func3,因此 O.func3 將調用 T1.func3,這一點在虛函數表中也可以很明顯看出來。
好了,說到這里,我想動態綁定已經說的非常清楚了,說明一點,本文雖然以 Object Pascal代碼為例,但其原理對于 C++也同樣有效。C++與Object Pascal(甚至不同C++編譯器之間)的區別僅在于類成員及vptr在內存中分布的位置而已。
那么,最后再談一下 Object Pascal 獨有的 DMT(動態方法表)吧。在VMT中,我們看到,子類的虛函數表完全繼承了父類的虛函數表,只是將被覆蓋了的虛函數的地址改變了。每個子類都有一份自己的虛函數表,可以想象,隨著類層次的擴展,如果類層次非常深,或者子類的數量非常多的話,虛函數表將稱為占用內存量非常大的東西(即所謂的“類爆炸”)。為了防止這種情況, Object Pascal 引入了 DMT。對于程序員來說,區別僅在于使用“dynamic”關鍵字代替“virtual”關鍵字,所實現的功能也完全一樣。
如果把本文開頭的那段代碼重寫如下(用 dynamic 代替 virtual):
T1 = class
private
member1 : integer;
public
function func1 : Integer; dynamic;
function func2 : Integer; dynamic;
function func3 : Integer; dynamic;
end;
T2 = class(T1)
private
member2 : integer;
public
function func1 : Integer; override;
function func2 : Integer; override;
end;
那么,T1 的內存分布圖沒有改變,而 T2 實例的就不一樣了:
___________________ ________________
| dptr |-------> | T2.func1 |
| member1 | | T2.func2 |
| member2 | ~~~~~~~~~~~~~~~~
~~~~~~~~~~~~~~~~~~~
可以看到,在 T2 的動態方法表中,沒有被覆蓋的 T1.func3 消失了。因此:
procedure Test;
var O : T1;
begin
O := T2.Create;
O.func3;
O.Free;
end;
O.func3 這一句代碼將被編譯器做更多的處理:找到 T1 類的 func3 函數的入口地址,然后再調用。
比較一下 VMT 和 DMT 的區別:
VMT 中的虛函數非常齊全,因此對每個虛函數的入口地址只需要簡單的 [vptr + n] 的運算即可得到,但是 VMT 容易消耗內存(有冗余)。而 DMT 比較節省空間,但要定位到沒有被覆蓋的函數的入口地址時,將非常耗費時間。
一般情況下,幾乎每個子類都要覆蓋的函數/方法,就將它聲明為 virtual;如果類層次很深,或子類很多,但某個函數/方法只被很少的子類覆蓋,就將它聲明為 dynamic。當然,具體就需要自己把握來選擇了。
新聞熱點
疑難解答