C++ 虛擬函式表解析

阿玛尼迪迪發表於2024-09-13

一、何為多型

多型(polymorphism)指為不同資料型別的實體提供統一的介面,或使用單一的符號來表示多個不同的型別。比如我們熟悉的函式過載、模板技術,都屬於多型。無論是模板還是函式過載,都是靜態繫結的。也就是說,究竟該呼叫哪個過載函式或者說呼叫哪個模板類的例項化,在編譯期就是確認的。虛擬函式也是多型的一種,它是執行時的多型。

下面的程式碼演示了透過虛擬函式實現的多型:

 1 #include<iostream>
 2 using namespace std;
 3 class Base 
 4 { 
 5 public: 
 6     virtual void f()
 7     {
 8         cout<<"Base::f()"<<endl;
 9     }
10 };
11 class Derived: public Base 
12 {
13 public:
14     void f()
15     {
16         cout<<"Derived::f()"<<endl;
17     }
18 };
19 int main() {
20     Derived x;
21     Base* p = &x;
22     p->f(); //輸出Derived::f()。
23     Base &b = x;
24     b.f();  //輸出Derived::f()。
25     Base b1 = x;
26     b1.f();//輸出Base::f(),值語義,不能表現出多型
27     return 0;
28 }

執行結果:

[root@VM-16-4-opencloudos vtable]# ./main 
Derived::f()
Derived::f()
Base::f()

用法是:基類的指標或引用,用不同的子類賦值時,就表現不同的行為。而值語義是不能表現出多型的。

實現的機制是因編譯器而異,但基本上使用虛擬函式表來實現的,這個後面再介紹。

這會我想談的是:為什麼說虛擬函式是執行時的多型,基類的指標指向的型別需要在執行期間才能確定?

其實單看上面的程式碼,也不需要在執行時才知道p->f()呼叫的是Devied中的函式呀,程式碼裡已經明確了Base的指標p就是指向子類Derived的,我直接看程式碼都知道了,難道編譯器是傻的嗎,還要等到執行期時才去透過虛擬函式表找到p->f()實際呼叫的是Derived中的函式?其實不是的,對於編譯期能確定呼叫目標的虛擬函式,最終生成的程式碼並不會傻乎乎的去查虛表,編譯器會執行一些最佳化,進行靜態繫結。具體的討論可以參考下面這個回答:虛擬函式一定是執行期才繫結麼?

那為什麼都說是執行期繫結的?

其實上面這份程式碼看不出來,可以看下面這份:

 1 #include<iostream>
 2 using namespace std;
 3 class Base 
 4 { 
 5 public: 
 6     virtual void f()
 7     {
 8         cout<<"Base::f()"<<endl;
 9     }
10 };
11 class Derived: public Base 
12 {
13 public:
14     void f()
15     {
16         cout<<"Derived::f()"<<endl;
17     }
18 };
19 int main() {
20     char k = getchar();
21     Base *p = NULL;
22     if(k == 'a') {
23         p = new Base();
24     } else {
25         p = new Derived();
26     }
27     p->f(); //輸出Base::f() or Derived::f() ?
28     return 0;
29 }

在編譯期間無法分析出這個指標究竟指向什麼型別的物件,只有等到程式執行時,使用者從鍵盤輸入字元之後才能確定。此時只能是透過執行期動態繫結了。

之所以要在執行期動態繫結來實現的原因就是執行期外部 IO。參考知乎的回答:為什麼C++實現多型必須要虛擬函式表?

搞清楚了為什麼需要執行期動態繫結了,那下面就來說說怎麼實現的吧。

二、虛擬函式表

我們來看以下的程式碼。類 A 包含虛擬函式vfunc1vfunc2,由於類 A 包含虛擬函式,故類 A 擁有一個虛表。

1 class A {
2 public:
3     virtual void vfunc1();
4     virtual void vfunc2();
5     void func1();
6     void func2();
7 private:
8     int m_data1, m_data2;
9 };

類 A 的虛表如圖 1 所示。

圖 1:物件它的虛表示意圖

虛表是一個指標陣列,其元素是虛擬函式的指標,每個元素對應一個虛擬函式的函式指標。需要指出的是,普通的函式即非虛擬函式,其呼叫並不需要經過虛表,所以虛表的元素並不包括普通函式的函式指標。

虛表內的條目,即虛擬函式指標的賦值發生在編譯器的編譯階段,也就是說在程式碼的編譯階段,虛表就可以構造出來了。

虛表是屬於類的,而不是屬於某個具體的物件,一個類只需要一個虛表即可。同一個類的所有物件都使用同一個虛表。

為了指定物件的虛表,物件內部包含一個虛表的指標,來指向自己所使用的虛表。為了讓每個包含虛表的類的物件都擁有一個虛表指標,編譯器在類中新增了一個指標,*__vptr,用來指向虛表。這樣,當類的物件在建立時便擁有了這個指標,且這個指標的值會自動被設定為指向類的虛表。

為什麼弄懂虛表的記憶體佈局,我找了很多資料,認為這兩個知乎的回答我能理解清楚:

一個回答是:單繼承下的虛擬函式表的記憶體佈局

另一個回答是我不太明白的地方:物件在其起始地址處存放了虛表指標(vptr),vptr指向虛表,虛表中儲存了實際的函式地址。原來我以為虛表中儲存的只有虛擬函式的地址,但其實不是的,vptr指向的並不是虛表的表頭,而是直接指向虛擬函式的位置。實際上虛表中虛擬函式的位置之前,還有兩個槽位,每個槽位佔8個位元組,這篇回答說明瞭這兩個槽位的作用:多繼承下的虛擬函式表的記憶體佈局

另外之前我有個疑惑:派生類中會有幾個vptr呢?比如B繼承A,然後C繼承了B,C類中會有幾個vptr呢?是有3個嗎?(A、B、C各一個),還是隻有1個?

答案是:在單鏈繼承中,每一個派生型別都包含了其基型別的資料以及虛擬函式,這些虛擬函式可以按照繼承順序,依次排列在同一張虛表之中,因此只需要一個虛指標即可。並且由於每一個派生類都包含它的直接基類,且沒有第二個直接基類,因此其資料在記憶體中也是線性排布的,這意味著實際型別與它所有的基型別都有著相同的起始地址。

而對於多繼承而言,假設型別C同時繼承了兩個獨立的基類A和B,比如:

 1 struct A
 2 {
 3     int ax;
 4     virtual void f0() {}
 5 };
 6 
 7 struct B
 8 {
 9     int bx;
10     virtual void f1() {}
11 };
12 
13 struct C : public A, public B
14 {
15     int cx;
16     void f0() override {}
17     void f1() override {}
18 };

與單鏈繼承不同,由於AB完全獨立,它們的虛擬函式沒有順序關係,即f0f1有著相同對虛表起始位置的偏移量,不可以順序排布。 並且AB中的成員變數也是無關的,因此基類間也不具有包含關係。這使得ABC中必須要處於兩個不相交的區域中,同時需要有兩個虛指標分別對它們虛擬函式進行索引。 其記憶體佈局如下所示:

                                               C Vtable (7 entities)
                                                +--------------------+
struct C                                        | offset_to_top (0)  |
object                                          +--------------------+
    0 - struct A (primary base)                 |     RTTI for C     |
    0 -   vptr_A -----------------------------> +--------------------+       
    8 -   int ax                                |       C::f0()      |
   16 - struct B                                +--------------------+
   16 -   vptr_B ----------------------+        |       C::f1()      |
   24 -   int bx                       |        +--------------------+
   28 - int cx                         |        | offset_to_top (-16)|
sizeof(C): 32    align: 8              |        +--------------------+
                                       |        |     RTTI for C     |
                                       +------> +--------------------+
                                                |    Thunk C::f1()   |
                                                +--------------------+

在上面的記憶體佈局中,C將A作為主基類,也就是將它虛擬函式“併入”A的虛表之中,並將A的vptr作為C的記憶體起始地址。

上面的記憶體佈局中,offset_to_top(-16)用於確保如果將類C例項的地址賦給類B的指標p,呼叫p->f0()時,能找到對應的虛擬函式,其內部會將指標偏移offset_to_top個位元組,找到類C的虛表指標(vptr_A),然後找到對應的虛擬函式來呼叫。至於為什麼是16個位元組,是因為vptr本身佔8個位元組,另外還有int ax; 雖然int是4位元組的,但因為記憶體對齊,所以總共佔16個位元組。

假設型別C同時繼承了兩個獨立的基類AB

C++的編譯器保證虛擬函式表的指標存在於物件例項中最前面的位置, 這意味著我們透過物件例項的地址得到這張虛擬函式表,然後就可以遍歷其中函式指標,並呼叫相應的函式。

下面的程式用於驗證包含虛擬函式的類物件記憶體的首地址是vptr,並演示如何透過vptr找到虛擬函式表並訪問虛擬函式:

#include <iostream>
using namespace std;

class Base {
     public:
            virtual void f() { cout << "Base::f" << endl; }
            virtual void g() { cout << "Base::g" << endl; }
            virtual void h() { cout << "Base::h" << endl; }

};
typedef void(*Fun)(void);
int main(int argc, char *argv[])
{
    Base b;
    Fun pFun = NULL;
    cout << "虛擬函式表地址:" << (int*)(&b) << endl;  //vptr
    cout << "虛擬函式表 — 函式指標陣列的首地址:" << (int*)*(int*)(&b) << endl;
    // Invoke the first virtual function
    pFun = (Fun)*((int*)*(int*)(&b)+0);  // Base::f()
    pFun();
    return 0;
}

執行結果:

[root@VM-16-4-opencloudos vtable]# ./main 
虛擬函式表地址:0x7ffdb2560880
虛擬函式表 — 第一個函式地址:0x400b78
Base::f

&b取到了虛擬函式表的地址(vptr),*(int*)(&b)是對虛擬函式表地址的解引用,得到的是虛擬函式表。虛擬函式表中存放了一個函式指標陣列,即陣列中的每個元素都是一個函式指標,指向每一個虛擬函式。直接訪問(int*)*(int*)(&b)得到的是函式指標陣列的首地址。對其進行解引用,即*(int*)*(int*)(&b),則可以得到函式指標陣列的首地址指向的元素,該元素是一個函式指標,因為虛擬函式按照其宣告順序放於虛擬函式表中,所以*(int*)*(int*)(&b)對應的是Base::f()函式的地址。

相關文章