1. 程式人生 > >【轉】C++虛擬函式表

【轉】C++虛擬函式表

引言

C++中的虛擬函式的作用主要是實現了多型的機制。關於多型,簡而言之就是用父類型別的指標指向其子類的例項,然後通過父類的指標呼叫實際子類的成員函式。這種技術可以讓父類的指標有“多種形態”,這是一種泛型技術。所謂泛型技術,說白了就是試圖使用不變的程式碼來實現可變的演算法。比如:模板技術,RTTI技術,虛擬函式技術,要麼是試圖做到在編譯時決議,要麼試圖做到執行時決議。
對C++ 瞭解的人都應該知道虛擬函式(Virtual Function)是通過一張虛擬函式表(Virtual Table)來實現的。簡稱為V-Table。在這個表中,主是要一個類的虛擬函式的地址表,這張表解決了繼承、覆蓋的問題,保證其容真實反應實際的函式。這樣,在有虛擬函式的類的例項中這個表被分配在了這個例項的記憶體中,所以,當我們用父類的指標來操作一個子類的時候,這張虛擬函式表就顯得由為重要了,它就像一個地圖一樣,指明瞭實際所應該呼叫的函式。
這裡我們著重看一下這張虛擬函式表。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

圖1:類A的虛表示意圖

虛表是一個指標陣列,其元素是虛擬函式的指標,每個元素對應一個虛擬函式的函式指標。需要指出的是,普通的函式即非虛擬函式,其呼叫並不需要經過虛表,所以虛表的元素並不包括普通函式的函式指標。
虛表內的條目,即虛擬函式指標的賦值發生在編譯器的編譯階段,也就是說在程式碼的編譯階段,虛表就可以構造出來了。

虛表指標

虛表是屬於類的,而不是屬於某個具體的物件,一個類只需要一個虛表即可。同一個類的所有物件都使用同一個虛表。
為了指定物件的虛表,物件內部包含一個虛表的指標,來指向自己所使用的虛表。為了讓每個包含虛表的類的物件都擁有一個虛表指標,編譯器在類中添加了一個指標,*__vptr,用來指向虛表。這樣,當類的物件在建立時便擁有了這個指標,且這個指標的值會自動被設定為指向類的虛表。

圖片2

圖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

圖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++面向物件程式設計的基石。


參考文獻:

https://blog.csdn.net/lihao21/article/details/50688337

https://blog.csdn.net/haoel/article/details/1948051