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,用來指向虛表。這樣,當類的對象在創建時便擁有了這個指針,且這個指針的值會自動被設置為指向類的虛表。
圖2:對象與它的虛表
上面指出,一個繼承類的基類如果包含虛函數,那個這個繼承類也有擁有自己的虛表,故這個繼承類的對象也包含一個虛表指針,用來指向它的虛表。
四、動態綁定
說到這里,大家一定會好奇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。由于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