三 虛函數表VTABLE
動態聯編過程跟我們猜測的大致相同。編譯器在執 行過程中遇到virtual關鍵字的時候,將自動安裝動態聯編需要的機制,首先為這 些包含virtual函數的類(注意不是類的實例)--即使是祖先類包含虛函數而本身 沒有--建立一張虛擬函數表VTABLE。在這些虛擬函數表中,編譯器將依次按照函 數聲明次序放置類的特定虛函數的地址。同時在每個帶有虛函數的類中放置一個 稱之為vpointer的指針,簡稱vptr,這個指針指向這個類的VTABLE。
關於 虛擬函數表,有幾點必須聲明清楚:
1. 每一個類別只能有一個虛擬函數 表,如果該類沒有虛擬函數,則不存在虛擬函數表。
2. C++編譯時候編譯 器會在含有虛函數的類中加上一個指向虛擬函數表的指針vptr。
3. 從一 個類別誕生的每一個對象,將獲取該類別中的vptr指針,這個指針同樣指向類的 VTABLE。
因此類、對象、VTABLE的層次結構可以用下圖表示。其中X類和Y 類的對象的指針 都指向了X,Y的虛擬函數表,同時X,Y類自身也包含了指向虛擬函 數的指針。
為了方 便問題說明,我們將2.cpp例子進行擴展,擴展程序如下。//4.cpp
15. #include <iostream.h >
16. class shape{
17. public:
18. virtual void draw(){cout<<"shape::draw ()"<<endl;}
19. virtual void area() {cout<<"shape::area()"<<endl;}
20. void fun(){draw();area();}
21. };
22. class circle:public shape {
23. public:
24. void draw() {cout<<"circle::draw()"<<endl;}
25. void adjust(){cout<<"circle::adjust()"<<endl;}
26. };
27. main(){
28. shape oneshape;
29. oneshape.fun();
30.
31. circle circleshape;
32. shape& baseshape=circleshape;
33. baseshape.fun();
34. }
編譯器在編譯上面這段代碼的時候將為這shape和circle兩個對象 分別建立一個VTABLE表,這些表依次填充派生類對象和基類對象中聲明的所有的 虛函數地址。如果派生類本身沒有重新定義基類的虛函數,那麼填充的就是基類 的虛函數地址。這樣一旦如果函數調用一個派生類不存在的方法時候能夠自動調 用基類方法。然後編譯器在每個類中放置一個vptr,一般置於對象的起始位置, 繼而在對象的構造函數中將vptr初始化為本類的VTABLE的地址。整個結果布局如 下。
圖一
圖一中的rectangle的VTABLE中的area() 和triangle的VTABLE的adjust() 都是填充的基類的虛函數地址。 C++ 編譯程序時候按下面的步驟進行工 作:
①為各類建立虛擬函數表,如果沒有虛函數則不建立。
②暫時 不連接虛函數,而是將各個虛函數的地址放入虛擬函數表中。
③直接連接 各靜態函數。
這些工作做完之後,模塊圖如圖二:
圖二
執行時候,誕生了oneshape和circleshape兩個對象,oneshape對象的 vptr指針指向shape的VTABLE,circleshape對象的vptr指針指向circleshape的 VTABLE,在執行oneshape.fun()的時候,fun函數的this指針指向了oneshape對象 ,進入fun()之後程序繼續執行this->draw(),由於this指向oneshape對象, oneshape的vptr又指向shape類的VTABLE,這樣就從VTABLE中得到需要綁定的函數 的地址,並連接起來。同樣,this-> area()也經由oneshape對象而連接到相 應的函數上,如圖三。
圖三
現在我們執行baseshape.fun()函數。
circle circleshape;
shape& baseshape=circleshape;
baseshape.fun();
函數進入fun函數之後,函數的this指針將指向 basefun對象,另一方面basefun指向一個circleshape,因此this指針指向的實際 上為circleshape對象,而circleshape的vptr指針指向circle類的虛擬函數表, 這樣編譯器將從虛擬表中取出circle::draw()和circle::area()的地址,進行連 接。因為circle本身沒有重新定義area()方法,因此編譯器使用shape的area()方 法。如圖四。
圖四
遵循上面的思路,基於基類的指針總能找到正確的子類對象的實現。但是 象上面的 this->draw是怎麼編譯的呢。
四 編譯內幕
在上面的 程序中,this指針不同,從而連接到不同的fun函數。那麼C++如何編譯這些指令 呢。道理在於:所有的基類的派生類的虛擬函數表的順序與基類的順序是一樣的 ,對於基類中不存在方法再按照聲明次序進行排放。這樣不管是shape還是circle 或者從shape又繼承出來的其余的類它們的虛擬函數表的第一項總是draw函數的地 址,然後是area的地址。對於circle類,下面的才是adjust的地址。因此不管對 於shape還是circle,this->draw總是編譯成 call this->VTABLE[0]; this->area()總是翻譯成 call this->VTABLE[1]; 程序到真正運行時候將 會發現this的真正指向的對象,如果是shape,則調用shape->VTABLE[0],如果 是circle,則調用circle->VTABLE[1],如圖五。
圖五
請看下面的這個例子。
35. #include
36. class shape{
37. public:
38. virtual void draw() {cout<<"shape::draw()"<draw();//OK
51. oneshape->adjust();//錯誤,編譯器無法通過
52.
53. circle* circleshape;
54. circleshape->adjust();
55. }
在程序編譯期間,由於oneshape為shape類型的,因此它將檢查shape 的虛擬函數表,發現VTABLE[0]為draw函數的地址,於是翻譯成p->VTABLE[0] 。未來執行期間,p 實際上指向的是circle對象,因此真正調用的為circle- >VTABLE[0]處的函數,即circle::draw。同樣對於adjust函數,C++ 編譯器也 會去檢查shape的VTABLE,結果編譯器無法找到adjust函數,因此編譯無法通過。 對於circleshape,因為它是circleshape類型的,因此它將會檢查circle的 VTABLE,得知VTABLE[2]處為adjust的地址,因此編譯器翻譯成call circleshape ->VTABLE[2],真正執行時候circleshape為circle類型,因此它將綁定circle 的VTABLE[2]處的函數即circle:: adjust()。 就這樣,編譯器借助虛擬函數表實 現了動態聯編的過程,從而使多態的實現有了可能。因此說虛擬函數表是多態性 的幕後功臣一點也不為過。
五 結束語
多態性的實現是一個非常復 雜的過程,上面的討論僅僅是針對簡單繼承而言,即基類只有一個的情況,對於 多重繼承,情況又會有所改變。本文僅是拋磚引玉,希望有興趣的朋友可以一起 探討。