C++虛擬函式表剖析
關鍵詞:虛擬函式,虛表,虛表指標,動態繫結,多型
一、概述
為了實現C++的多型,C++使用了一種動態繫結的技術。這個技術的核心是虛擬函式表(下文簡稱虛表)。本文介紹虛擬函式表是如何實現動態繫結的。
二、類的虛表
每個包含了虛擬函式的類都包含一個虛表。
我們知道,當一個類(A)繼承另一個類(B)時,類A會繼承類B的函式的呼叫權。所以如果一個基類包含了虛擬函式,那麼其繼承類也可呼叫這些虛擬函式,換句話說,一個類繼承了包含虛擬函式的基類,那麼這個類也擁有自己的虛表。
我們來看以下的程式碼。類A包含虛擬函式vfunc1,vfunc2,由於類A包含虛擬函式,故類A擁有一個虛表。
class A { public: virtual void vfunc1(); virtual void vfunc2(); void func1(); void func2(); private: int m_data1, m_data2; };
類A的虛表如圖1所示。
圖1:類A的虛表示意圖
虛表是一個指標陣列,其元素是虛擬函式的指標,每個元素對應一個虛擬函式的函式指標。需要指出的是,普通的函式即非虛擬函式,其呼叫並不需要經過虛表,所以虛表的元素並不包括普通函式的函式指標。
虛表內的條目,即虛擬函式指標的賦值發生在編譯器的編譯階段,也就是說在程式碼的編譯階段,虛表就可以構造出來了。
三、虛表指標
虛表是屬於類的,而不是屬於某個具體的物件,一個類只需要一個虛表即可。同一個類的所有物件都使用同一個虛表。
為了指定物件的虛表,物件內部包含一個虛表的指標,來指向自己所使用的虛表。為了讓每個包含虛表的類的物件都擁有一個虛表指標,編譯器在類中添加了一個指標,*__vptr,用來指向虛表。這樣,當類的物件在建立時便擁有了這個指標,且這個指標的值會自動被設定為指向類的虛表。
上面指出,一個繼承類的基類如果包含虛擬函式,那個這個繼承類也有擁有自己的虛表,故這個繼承類的物件也包含一個虛表指標,用來指向它的虛表。
四、動態繫結
說到這裡,大家一定會好奇C++是如何利用虛表和虛表指標來實現動態繫結的。我們先看下面的程式碼。
class A { public: virtual void vfunc1(); virtual void vfunc2(); void func1(); void func2(); private: int m_data1, m_data2; }; class B : public A { public: virtual void vfunc1(); void func1(); private: int m_data3; }; class C: public B { public: virtual void vfunc2(); void func2(); private: int m_data1, m_data4; };
類A是基類,類B繼承類A,類C又繼承類B。類A,類B,類C,其物件模型如下圖3所示。
圖3:類A,類B,類C的物件模型
由於這三個類都有虛擬函式,故編譯器為每個類都建立了一個虛表,即類A的虛表(A vtbl),類B的虛表(B vtbl),類C的虛表(C vtbl)。類A,類B,類C的物件都擁有一個虛表指標,*__vptr,用來指向自己所屬類的虛表。
類A包括兩個虛擬函式,故A vtbl包含兩個指標,分別指向A::vfunc1()和A::vfunc2()。
類B繼承於類A,故類B可以呼叫類A的函式,但由於類B重寫了B::vfunc1()函式,故B vtbl的兩個指標分別指向B::vfunc1()和A::vfunc2()。
類C繼承於類B,故類C可以呼叫類B的函式,但由於類C重寫了C::vfunc2()函式,故C vtbl的兩個指標分別指向B::vfunc1()(指向繼承的最近的一個類的函式)和C::vfunc2()。
雖然圖3看起來有點複雜,但是隻要抓住“物件的虛表指標用來指向自己所屬類的虛表,虛表中的指標會指向其繼承的最近的一個類的虛擬函式”這個特點,便可以快速將這幾個類的物件模型在自己的腦海中描繪出來。
非虛擬函式的呼叫不用經過虛表,故不需要虛表中的指標指向這些函式。
假設我們定義一個類B的物件。由於bObject是類B的一個物件,故bObject包含一個虛表指標,指向類B的虛表。
int main()
{
B bObject;
}
現在,我們宣告一個類A的指標p來指向物件bObject。雖然p是基類的指標只能指向基類的部分,但是虛表指標亦屬於基類部分,所以p可以訪問到物件bObject的虛表指標。bObject的虛表指標指向類B的虛表,所以p可以訪問到B vtbl。如圖3所示。
int main()
{
B bObject;
A *p = & bObject;
}
當我們使用p來呼叫vfunc1()函式時,會發生什麼現象?
int main()
{
B bObject;
A *p = & bObject;
p->vfunc1();
}
程式在執行p->vfunc1()時,會發現p是個指標,且呼叫的函式是虛擬函式,接下來便會進行以下的步驟。
首先,根據虛表指標p->__vptr來訪問物件bObject對應的虛表。雖然指標p是基類A*型別,但是*__vptr也是基類的一部分,所以可以通過p->__vptr可以訪問到物件對應的虛表。
然後,在虛表中查詢所呼叫的函式對應的條目。由於虛表在編譯階段就可以構造出來了,所以可以根據所呼叫的函式定位到虛表中的對應條目。對於 p->vfunc1()的呼叫,B vtbl的第一項即是vfunc1對應的條目。
最後,根據虛表中找到的函式指標,呼叫函式。從圖3可以看到,B vtbl的第一項指向B::vfunc1(),所以 p->vfunc1()實質會呼叫B::vfunc1()函式。
如果p指向類A的物件,情況又是怎麼樣?
int main()
{
A aObject;
A *p = &aObject;
p->vfunc1();
}
當aObject在建立時,它的虛表指標__vptr已設定為指向A vtbl,這樣p->__vptr就指向A vtbl。vfunc1在A vtbl對應在條目指向了A::vfunc1()函式,所以 p->vfunc1()實質會呼叫A::vfunc1()函式。
可以把以上三個呼叫函式的步驟用以下表達式來表示:
(*(p->__vptr)[n])(p)
可以看到,通過使用這些虛擬函式表,即使使用的是基類的指標來呼叫函式,也可以達到正確呼叫執行中實際物件的虛擬函式。
我們把經過虛表呼叫虛擬函式的過程稱為動態繫結,其表現出來的現象稱為執行時多型。動態繫結區別於傳統的函式呼叫,傳統的函式呼叫我們稱之為靜態繫結,即函式的呼叫在編譯階段就可以確定下來了。
那麼,什麼時候會執行函式的動態繫結?這需要符合以下三個條件。
通過指標來呼叫函式
指標upcast向上轉型(繼承類向基類的轉換稱為upcast,關於什麼是upcast,可以參考本文的參考資料)
呼叫的是虛擬函式
如果一個函式呼叫符合以上三個條件,編譯器就會把該函式呼叫編譯成動態繫結,其函式的呼叫過程走的是上述通過虛表的機制。
五、總結
封裝,繼承,多型是面向物件設計的三個特徵,而多型可以說是面向物件設計的關鍵。C++通過虛擬函式表,實現了虛擬函式與物件的動態繫結,從而構建了C++面向物件程式設計的基石。
參考資料
《C++ Primer》第三版,中文版,潘愛民等譯
http://www.learncpp.com/cpp-tutorial/125-the-virtual-table/
侯捷《C++最佳程式設計實踐》視訊,極客班,2015
Upcasting and Downcasting, http://www.bogotobogo.com/cplusplus/upcasting_downcasting.php
附錄
示例程式碼
---------------------
作者:haozlee
來源:CSDN
原文:https://blog.csdn.net/lihao21/article/details/50688337
版權宣告:本文為博主原創文章,轉載請附上博文連結!