C++ 虛擬函式表剖析

Leo的部落格發表於2017-06-12

一、概述

為了實現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++物件導向程式設計的基石。

參考資料

附錄

示例程式碼

相關文章