轉載
作者:Holy Chen
鏈接:https://zhuanlan.zhihu.com/p/41309205
來源:知乎
著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。
C++中虛函數、虛繼承的內存模型是一個經典問題,其實現依賴於編譯器,但其主要原理大體相同。本文以問題導向的方式,詳細介紹了g++中虛函數和虛繼承的內存模型及其原理。
1 多態類型
在C++中,多態類型是指聲明或者繼承了至少一個虛函數的類型,反之則爲非多態類型。
對於非多態類型的變量,其類型信息都可以在編譯時確定。例如:
struct A
{
void foo() {}
};
...
A a;
std::cout << typeid(a).name(); // 可以在編譯時確定a的類型爲A
a.foo(); // 可以在編譯時確定A::foo在內存中的地址
sizeof(a); // 儘管A爲空,但由於需要在內存中確定a的地址,因此A類型對象所佔空間爲1個字節
而對於多態類型,一些信息必須延遲到運行時纔可以確定,例如它的實際類型、所調用的虛函數的地址等。下面的這個例子中,類型B
繼承了聲明有虛函數的類型A
,因此A
和B
都是多態類型。
struct A
{
virtual void foo() {} // 聲明虛函數
};
struct B : public A
{
// 隱式繼承了虛函數
};
...
B b{};
A& a_rb = b; // 將b綁定到A的左值引用a_rb上
typeid(decltype(a_rb)).name(); // decltype產生的是編譯時即可確定的聲明類型,因此爲A
typeid(a_rb).name(); // 由於a_rb是多態類型的glvalue,typeid在運行時計算,因此爲B
a_rb.foo(); // 這裏調用的是B中的foo,其函數地址是運行時確定的
sizeof(b); // 這裏的sizeof是編譯器決定的,通常爲8 (64位)
2 虛函數內存模型
我們可以用基類型A
的引用或者指針持有實際類型爲派生類B
的對象,這意味着,編譯時我們無法通過其聲明類型來確定其實際類型,也就無法確定應該調用哪個具體的虛函數。考慮到程序中的每個函數都在內存中有着唯一的地址,我們可以將具體函數的地址作爲成員變量,存放在對象之中,這樣就可以在運行時,通過訪問這個成員變量,獲取到實際類型虛函數的地址。
2.1 單繼承內存模型
現代的C++編譯器都採用了表格驅動的對象模型。具體來說,對於每一個多態類型,其所有的虛函數的地址都以一個表格的方式存放在一起,每個函數的偏移量在基類型和導出類型中均相同,這使得虛函數相對於表格首地址的偏移量在可以在編譯時確定。虛函數表格的首地址儲存在每一個對象之中,稱爲虛(表)指針(vptr)或者虛函數指針(vfptr),這個虛指針始終位於對象的起始地址。使用多態類型的引用或指針調用虛函數時,首先通過虛指針和偏移量計算出虛函數的地址,然後進行調用。
例如,有如下所示的類型A
和B
:
struct A
{
int ax; // 成員變量
virtual void f0() {}
virtual void f1() {}
};
struct B : public A
{
int bx; // 成員變量
void f0() override {}; // 重寫f0
};
它們的對象模型和虛表模型如下所示:
struct A
object A VTable (不完整)
0 - vptr_A --------------------------------> +--------------+
8 - int ax | A::f0() |
sizeof(A): 16 align: 8 +--------------+
| A::f1() |
+--------------+
struct B
object
0 - struct A B VTable (不完整)
0 - vptr_A ------------------------------> +--------------+
8 - int ax | B::f0() |
12 - int bx +--------------+
sizeof(A): 16 align: 8 | A::f1() |
+--------------+
注意到,由於B
重寫了方法f0()
,因此它的虛表在同樣的位置,將A::f0()
覆蓋爲B::f0()
。當發生f0()
函數調用時,對於實際類型爲A
的對象,其VTable偏移量爲offset0
的位置爲A::f0()
, 對於實際類型爲B
的對象,對應位置爲B::f0()
,這樣就實現了運行時虛函數函數地址的正確選擇。
A a;
B b;
A &a_ra = a;
A &a_rb = b;
a_ra.f0(); // call (a_ra->vptr_A + offset0) --> A::f0()
a_rb.f0(); // call (a_rb->vptr_A + 0ffset0) --> B::f0()
在以上的例子中,B
中虛函數都已經在A
中聲明過,如果類型B
中出現了基類型A
中沒有的虛函數,新的虛函數將會被附加在虛函數表的最後,不會對與基類重合的部分造成影響。例如B
中新增加了函數f2()
,虛函數表變化如下:
struct B
object
0 - struct A B VTable (不完整)
0 - vptr_A ------------------------------> +--------------+
8 - int ax | B::f0() |
12 - int bx +--------------+
sizeof(A): 16 align: 8 | A::f1() |
+--------------+
| B::f2() |
+--------------+
對於多態類型,除了要在運行時確定虛函數地址外,還需要提供運行時類型信息(Run-Time Type Identification, RTTI)的支持。一個顯然的解決方案是,將類型信息的地址加入到虛表之中。爲了避免虛函數表長度對其位置的影響,g++將它放在虛函數表的前,所示如下:
struct B B VTable (不完整)
object +--------------+
0 - struct A | RTTI for B |
0 - vptr_A ------------------------------> +--------------+
8 - int ax | B::f0() |
12 - int bx +--------------+
sizeof(A): 16 align: 8 | A::f1() |
+--------------+
| B::f2() |
+--------------+
現在的虛表中,不僅含有函數地址,還含有RTTI的地址,之後還會加入許多新項目。虛表中的每一項都稱作一個實體(entity)。
上述的解決方案,可以很好的處理單鏈繼承的情況。在單鏈繼承中,每一個派生類型都包含了其基類型的數據以及虛函數,這些虛函數可以按照繼承順序,依次排列在同一張虛表之中,因此只需要一個虛指針即可。並且由於每一個派生類都包含它的直接基類,且沒有第二個直接基類,因此其數據在內存中也是線性排布的,這意味着實際類型與它所有的基類型都有着相同的起始地址。例如,B
繼承A
,C
繼承B
,它們的定義和內存模型如下所示:
struct A
{
int ax;
virtual void f0() {}
};
struct B : public A
{
int bx;
virtual void f1() {}
};
struct C : public B
{
int cx;
void f0() override {}
virtual void f2() {}
};
內存模型爲
C VTable(不完整)
struct C +------------+
object | RTTI for C |
0 - struct B +-------> +------------+
0 - struct A | | C::f0() |
0 - vptr_A -------------------------+ +------------+
8 - int ax | B::f1() |
12 - int bx +------------+
16 - int cx | C::f2() |
sizeof(C): 24 align: 8 +------------+
從上圖可以看出,使用一個類型A
或B
的引用持有實際類型爲C
的對象,它的起始地址仍然指向C
的起始地址,這意味着單鏈繼承的情況下,動態向下轉換和向上轉換時,不需要對this
指針的地址做出任何修改,只需要對其重新“解釋”。
然而,並非所有派生類都是單鏈繼承的,它們的起始地址和其基類的起始地址不一定始終相同。
2.2 多繼承內存模型
假設類型C
同時繼承了兩個獨立的基類A
和B
, 它們的定義關係如下:
struct A
{
int ax;
virtual void f0() {}
};
struct B
{
int bx;
virtual void f1() {}
};
struct C : public A, public B
{
int cx;
void f0() override {}
void f1() override {}
};
與單鏈繼承不同,由於A
和B
完全獨立,它們的虛函數沒有順序關係,即f0
和f1
有着相同對虛表起始位置的偏移量,不可以順序排布。 並且A
和B
中的成員變量也是無關的,因此基類間也不具有包含關係。這使得A
和B
在C
中必須要處於兩個不相交的區域中,同時需要有兩個虛指針分別對它們虛函數進行索引。 其內存佈局如下所示:
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
的虛指針作爲C
的內存起始地址。
而類型B
的虛指針vptr_B
並不能直接指向虛表中的第4個實體,這是因爲vptr_B
所指向的虛表區域,在格式上必須也是一個完整的虛表。因此,需要爲vptr_B
創建對應的虛表放在虛表A
的部分之後 。
在上圖中,出現了兩個“新”的實體,一個是offset_to_top
,另一個是Thunk
。
在多繼承中,由於不同的基類起點可能處於不同的位置,因此當需要將它們轉化爲實際類型時,this
指針的偏移量也不相同。由於實際類型在編譯時是未知的,這要求偏移量必須能夠在運行時獲取。實體offset_to_top
表示的就是實際類型起始地址到當前這個形式類型起始地址的偏移量。在向上動態轉換到實際類型時,讓this
指針加上這個偏移量即可得到實際類型的地址。需要注意的是,由於一個類型即可以被單繼承,也可以被多繼承,因此即使只有單繼承,實體offset_to_top
也會存在於每一個多態類型之中。
而實體Thunk
又是什麼呢?如果不考慮這個Thunk
,這裏應該存放函數C::f1()
的地址。然而,dump虛表可以看到,Thunk C::f1()
和C::f1()
的地址並不一樣。
爲了弄清楚Thunk
是什麼,我們首先要注意到,如果一個類型B
的引用持有了實際類型爲C
的變量,這個引用的起始地址在C+16
處。當它調用由類型C
重寫的函數f1()
時,如果直接使用this
指針調用C::f1()
會由於this
指針的地址多出16
字節的偏移量導致錯誤。 因此在調用之前,this
指針必須要被調整至正確的位置 。這裏的Thunk
起到的就是這個作用:首先將this
指針調整到正確的位置,即減少16
字節偏移量,然後再去調用函數C::f1()
。
2.3 構造與析構過程
在多態類型的構造和析構過程中,所調用的虛函數並不是最終的實際類型的對應函數,而是當前已經創建了的(或尚未析構的)類型的對應函數。這句話比較繞口,我們通過一個例子來說明。如下所示的兩個類型A
和B
, 它們在構造和析構時都會調用對應的虛函數:
struct A
{
virtual void f0() { std::cout << "A\n"; }
A() { this->f0(); }
virtual ~A() { this->f0(); }
};
struct B : public A
{
virtual void f0() { std::cout << "B\n"; }
B() { this->f0(); }
~B() override { this->f0(); }
};
int main()
{
B b;
return 0;
} // 輸出:ABBA
運行上述程序,可以得到輸出“ABBA”,表明程序依次調用了A::A()
、B::B()
、B::~B()
、A::~A()
。直觀上理解,在構造A
時,B
中的數據還沒有創建,因此B
重寫的虛函數當然不可使用,因此應該調用A
中的版本;反過來,析構的時候,由於B
先析構,在B
析構之後,B
中的函數當然也不可用,因此也應該調用A
中的版本。
在程序運行中,這一過程是通過動態的修改對象的虛指針實現的。
根據C++中繼承類的構造順序,首先基類A
被構造。在構造A
時, 對象自身的虛指針指向A
的虛表。由於A
的虛表中,f0()
的位置保存着A::f0()
的地址,因此A::f0()
被調用。在A
的構造結束後,B
的構造啓動,此時虛指針被修改爲指向B
的虛表。析構過程與此相反。
3 虛繼承的內存模型
上述的模型中,對於派生類對象,它的基類相對於它的偏移量總是確定的,因此動態向下轉換並不需要依賴額外的運行時信息。
而虛繼承破壞了這一條件。它表示虛基類相對於派生類的偏移量可以依實際類型不同而不同,且僅有一份拷貝,這使得虛基類的偏移量在運行時纔可以確定。因此,我們需要對繼承了虛基類的類型的虛表進行擴充,使其包含關於虛基類偏移量的信息。
3.1 菱形繼承的內存模型
下面展示了一個經典的菱形虛繼承關係,爲了避免重複包含A
中的成員,類型B
和C
分別虛繼承A
。類型D
繼承了B
和C
。依據其繼承方式的不同,D
中的B
、C
的偏移量可以在編譯時確定,而A
的偏移量在運行時確定。
struct A
{
int ax;
virtual void f0() {}
virtual void bar() {}
};
struct B : virtual public A /****************************/
{ /* */
int bx; /* A */
void f0() override {} /* v/ \v */
}; /* / \ */
/* B C */
struct C : virtual public A /* \ / */
{ /* \ / */
int cx; /* D */
void f0() override {} /* */
}; /****************************/
struct D : public B, public C
{
int dx;
void f0() override {}
};
首先對類型A
的內存模型進行分析。由於虛繼承影響的是子類,不會對父類造成影響,因此A
的內存佈局和虛表都沒有改變。
A VTable
+------------------+
| offset_to_top(0) |
struct A +------------------+
object | RTTI for A |
0 - vptr_A --------------------------------> +------------------+
8 - int ax | A::f0() |
sizeof(A): 16 align: 8 +------------------+
| A::bar() |
+------------------+
類型B
類和類型C
沒有本質的區別,因此只分析類型B
。下圖爲類型B
的內存模型:
B VTable
+---------------------+
| vbase_offset(16) |
+---------------------+
| offset_to_top(0) |
struct B +---------------------+
object | RTTI for B |
0 - vptr_B -------------------------> +---------------------+
8 - int bx | B::f0() |
16 - struct A +---------------------+
16 - vptr_A --------------+ | vcall_offset(0) |x--------+
24 - int ax | +---------------------+ |
| | vcall_offset(-16) |o----+ |
| +---------------------+ | |
| | offset_to_top(-16) | | |
| +---------------------+ | |
| | RTTI for B | | |
+--------> +---------------------+ | |
| Thunk B::f0() |o----+ |
+---------------------+ |
| A::bar() |x--------+
+---------------------+
對於形式類型爲B
的引用,在編譯時,無法確定它的基類A
它在內存中的偏移量。 因此,需要在虛表中額外再提供一個實體,表明運行時它的基類所在的位置,這個實體稱爲vbase_offset
,位於offset_to_top
上方。
除此之外,如果在B
中調用A
聲明且B
沒有重寫的函數,由於A
的偏移量無法在編譯時確定,而這些函數的調用由必須在A
的偏移量確定之後進行, 因此這些函數的調用相當於使用A
的引用調用。也因此,當使用虛基類A
的引用調用重載函數時 ,每一個函數對this
指針的偏移量調整都可能不同,它們被記錄在鏡像位置的vcall_offset
中。例如,調用A::bar()
時,this
指針指向的是vptr_A
,正是函數所屬的類A
的位置,因此不需要調整,即vcall_offset(0)
;而B::f0()
是由類型B
實現的, 因此需要將this
指針向前調整16
字節。
對於類型D
,它的虛表更爲複雜,但虛表中的實體我們都已熟悉。 以下爲D
的內存模型:
D VTable
+---------------------+
| vbase_offset(32) |
+---------------------+
struct D | offset_to_top(0) |
object +---------------------+
0 - struct B (primary base) | RTTI for D |
0 - vptr_B ----------------------> +---------------------+
8 - int bx | D::f0() |
16 - struct C +---------------------+
16 - vptr_C ------------------+ | vbase_offset(16) |
24 - int cx | +---------------------+
28 - int dx | | offset_to_top(-16) |
32 - struct A (virtual base) | +---------------------+
32 - vptr_A --------------+ | | RTTI for D |
40 - int ax | +---> +---------------------+
sizeof(D): 48 align: 8 | | D::f0() |
| +---------------------+
| | vcall_offset(0) |x--------+
| +---------------------+ |
| | vcall_offset(-32) |o----+ |
| +---------------------+ | |
| | offset_to_top(-32) | | |
| +---------------------+ | |
| | RTTI for D | | |
+--------> +---------------------+ | |
| Thunk D::f0() |o----+ |
+---------------------+ |
| A::bar() |x--------+
+---------------------+
3.2 構造與析構過程
與非虛繼承相似,通過虛繼承產生的派生類在構造和析構時,所調用的虛函數只是當前階段的的虛表中對應的函數。一個問題也就由此產生,由於在虛基類的不同的派生類中,虛基類相對於該類型的偏移量是可以不同的,如果直接使用2.3中的方法,直接用繼承虛基類的類型自身的虛表作爲構建該類時使用的虛表,會由於偏移量的不同,導致無法正確獲取虛基類中的對象。
這個描述比較抽象拗口,我們通過3.1中的菱形繼承的例子進行解釋。四個類型A
,B
,C
和D
的繼承關係如下所示:
struct A
{
int ax;
virtual void f0() {}
virtual void bar() {}
};
struct B : virtual public A /****************************/
{ /* */
int bx; /* A */
void f0() override {} /* v/ \v */
}; /* / \ */
/* B C */
struct C : virtual public A /* \ / */
{ /* \ / */
int cx; /* D */
virtual void f1() {} /* */
}; /****************************/
struct D : public B, public C
{
int dx;
void f0() override {}
};
觀察實際類型爲B
和實際類型爲D
對象的內存佈局可以發現,如果實際類型爲B
,虛基類A
對B
的首地址的偏移量爲16
;若實際類型爲D
,則其中A
對B
首地址的偏移量爲32
。這明顯與B
自身的虛表衝突。如果構建D::B
時還採用的是B
自身的虛表,會由於偏移量的不同導致錯誤。
這一問題的解決方法其實很粗暴,那就是在對象構造、析構階段,會用到多少種虛表,會用到多少種虛指針就生成多少種虛指針。在構造或析構時,“按需分配”。
例如,這裏的類型D
是類型B
和C
的子類,而B
和C
虛繼承了類型A
。 這種繼承關係會導致D
內部含有的B
(稱作B-in-D
)、C
(稱作C-in-D
)的虛表與B
、C
的虛表不同。 因此,這需要生成兩張新的虛表,即B-in-D
和C-in-D
的虛表。
由於B-in-D
也是B
類型的一種佈局,B
的一個虛表對應兩個虛指針,分別是vptr_B
和vptr_A
,因此它也有兩個着兩個虛指針。在構造或析構D::B
時,其對象的內存佈局和虛表佈局如圖所示:
B-in-D VTable
+---------------------+
| vbase_offset(32) |
+---------------------+
struct D (Constructing/Deconstructing B) | offset_to_top(0) |
object +---------------------+
0 - struct B (primary base) | RTTI for B |
0 - vptr_B -----------------------> +---------------------+
8 - int bx | B::f0() |
16 - struct C +---------------------+
16 - vptr_C | vcall_offset(0) |x--------+
24 - int cx +---------------------+ |
28 - int dx | vcall_offset(-32) |o----+ |
32 - struct A (virtual base) +---------------------+ | |
32 - vptr_A --------------+ | offset_to_top(-32) | | |
40 - int ax | +---------------------+ | |
sizeof(D): 48 align: 8 | | RTTI for B | | |
+--------> +---------------------+ | |
| Thunk B::f0() |o----+ |
+---------------------+ |
| A::bar() |x--------+
+---------------------+
同樣的,在C-in-D
中也會有兩個虛指針,分別是vptr_C
和vptr_A
。此外,在最終的D
中還有三個虛指針,總計7
個不同的虛指針,它們指向3
張虛表的7
個不同位置。因此編譯器爲類型D
總共生成了3
個不同的虛表,和7
個不同的虛指針。將這7
個虛指針合併到一個表中,這個表就是虛表的表(Virtual Table Table, VTT)。顯然,只有當一個類的父類是繼承了虛基類的類型時,編譯器纔會爲它創建VTT。
在構造和析構過程中,子類的構造函數或析構函數向基類傳遞一個合適的、指向VTT某個部分指針,使得父類的構造函數或析構函數獲取到正確的虛表。
4 擴展
百聞不如一見,百看不如一練。C++的運行時多態的內存模型是一個相對較複雜的問題,只是看一兩遍很難理解。最好的理解方法是自己dump出內存中對象的內存模型,和類型的虛表的結構。
使用Clang++編譯器,可以通過下面的命令導出main.cpp
中類型的內存模型和虛表模型。
clang++ -cc1 -emit-llvm -fdump-record-layouts -fdump-vtable-layouts main.cpp
需要注意,類型至少定義了一個變量,否則會被編譯器優化掉。例如,有繼承關係A<-B<-C
,需要 至少定義一個C
類型的對象。
使用g++導出繼承結構的指令如下:
g++ -fdump-class-hierarchy -c main.cpp
由於g++的dump出的名稱是其內部表示,因此還需要使用c++filt導出具有一定可讀性的文檔。
cat [g++導出的文檔] | c++filt -n > [具有一定可讀性的輸出文檔]
此外,還可以通過gdb跟蹤內存、寄存器的變化,觀察虛函數、Thunk
的尋址過程,以及this
指針的變化。
對於g++,它採用了安騰ABI(Application Binary Interface),如果想要更深入的瞭解其內存佈局,可以參考安騰ABI文檔。Itanium C++ ABI
對於vc++,內存的佈局稍有不同,它將虛基類的偏移量單獨用一個額外的指針進行索引,因此對於虛繼承的類,除了指向虛函數表的vfptr
外,還會在它的後面緊隨有一個指向虛基類偏移量表的指針vbptr
。 除此之外,vc++將空子類的虛指針,或者或者具有與基類相同虛函數接口的派生類的虛指針與虛基類的虛指針進行合併,這意味着有的時候,對象的首個地址存放的可能是vbptr
而非vfptr
。
5 總結
- 虛函數地址通過虛指針索引的虛函數表在運行時確定;
- 虛表中不僅儲存了虛函數的地址,還儲存了類型RTTI的地址、距實際類型首地址偏移量等信息;
- 虛函數的調用可能涉及到
this
指針的變更,需要Thunk
等方式實現; - 對於虛基類的派生類,虛基類的偏移量由實際類型決定,因此在運行時纔可以確定虛基類的地址;
- 在多態類型的構造和析構過程中,通過修改虛指針使其指向不同的虛表,可以實現在不同的階段調用不同的虛函數;
- 對於虛繼承的情況,由於同一類型虛表的虛表在不同具體類型中可以不同,在構造和析構時,需要通過
VTT
傳遞正確的虛表