虛函數(shù)表與多態(tài)
虛函數(shù)表與多態(tài),是C++開發(fā)人員終究要面對的問題。
雖然很久沒寫C++了,此處還是將其整理一下進(jìn)行記錄。
編譯器信息:
- gcc: gcc (Debian 7.3.0-19) 7.3.0;
- clang: 7.0.1-8 (tags/RELEASE_701/final).
1 類空間
class Empty {
public:
Empty() = default;
~Empty() = default;
void hello() { std::cout << "hello world" << std::endl; }
};
// sizeof(Empty) = 1
首先需要明確,空類(包含非虛函數(shù)),其大小為1。
為了能將class實(shí)例放到數(shù)組里,空類必須具有大小,否則數(shù)組sizeof將是災(zāi)難。
不過空類作為基類時(shí),為了對齊可能占用4各字節(jié)或以上,因此編譯器有空基類優(yōu)化。
空基類優(yōu)化:令非靜態(tài)數(shù)據(jù)成員、無虛函數(shù)的基類實(shí)際占用0字節(jié)。
現(xiàn)在,我們開始加入一個(gè)虛函數(shù),再次查看類大小。
class Empty {
public:
Empty() = default;
~Empty() = default;
void hello() { std::cout << "hello world" << std::endl; }
virtual void virtual_test() {}
};
// sizeof(Empty) = 8
加入虛函數(shù)后,類大小從1字節(jié)增加至為8字節(jié)。
這是因?yàn)?,編譯器在類中隱式插入了虛函數(shù)表指針(void *vptr ),指針大小為8字節(jié)。
關(guān)于編譯器在背后做的事情,建議看<<深度探索C++對象模型>>(雖然看了就忘,但是比沒看要好一些)。
2 虛函數(shù)表指針(vptr)與虛函數(shù)表(vtbl)
對于包含虛函數(shù)的類,編譯器會為類創(chuàng)建相應(yīng)的虛函數(shù)表(vtbl)。
虛函數(shù)表中,主要存放類所對應(yīng)的虛函數(shù)地址。
在編譯期間,編譯器會在構(gòu)造函數(shù)中,對vptr進(jìn)行賦值,數(shù)值為vtbl的地址。
偽代碼如下所示:
class Empty {
public:
Empty() {
vtpr = (void*)&Empty::vtbl;
}
}
改進(jìn)一些,我們修改Empty類如下所示:
class Empty {
public:
Empty() = default;
virtual ~Empty() {}
virtual void virtual_func1() {}
virtual void virtual_func2() {}
public:
int m1 = 0x01020304, m2 = 0x04030201;
};
int main() {
Empty empty;
std::cout << empty.m1 << std::endl;
return 0;
}
主要改進(jìn)就是添加成員變量m1,m2,以及添加若干函數(shù)(包含虛函數(shù))。
使用gdb查看Empty實(shí)例的內(nèi)存布局,具體如下所示:
由上圖可知,Empty實(shí)例的內(nèi)存布局為:
- vptr(紅線部分,指向Empty的虛表);
- m1,m2。
3 多態(tài)調(diào)用
C++的三大特性是封裝,繼承以及多態(tài),其中多態(tài)必須依靠虛函數(shù)實(shí)現(xiàn)。
通俗點(diǎn)說,如果通過調(diào)用虛函數(shù)表指針(vtpr)找到虛函數(shù)表(vtbl)的入口并執(zhí)行虛函數(shù),則程序使用到了多態(tài)。
舉個(gè)例子:
class Base {
public:
virtual void virtual_func() {}
};
int main() {
Base *a = new Base();
a->virtual_func(); // 多態(tài)調(diào)用
Base b;
b.virtual_func(); // 非多態(tài)調(diào)用
Base *c = &b;
c->virtual_func(); // 多態(tài)調(diào)用
return 0;
}
為了驗(yàn)證注釋中的觀點(diǎn),我們使用匯編代碼進(jìn)行佐證:
上圖可以看出,三次調(diào)用virtual_func ,匯編代碼存在較大不同。
原因是a,c實(shí)例調(diào)用virtual_func 相對于b實(shí)例調(diào)用virtual_func ,多了需要去虛表(vtbl)中查找virtual_func 函數(shù)入口的過程。
4 內(nèi)存布局
下文將分別從單繼承,多繼承以及菱形繼承三點(diǎn)闡述虛表的內(nèi)存布局(使用g++ 導(dǎo)出內(nèi)存布局)。
4.1 單繼承
class A
{
int ax;
virtual void f0() {}
};
class B : public A
{
int bx;
virtual void f1() {}
};
class C : public B
{
int cx;
void f0() override {}
virtual void f2() {}
};
內(nèi)存布局如下所示:
Vtable for A
A::vtable for A: 3 entries
0 (int (*)(...))0 // 類型轉(zhuǎn)換偏移量
8 (int (*)(...))(& typeinfo for A) // 運(yùn)行時(shí)類型信息(Run-Time Type Identification,RTTI)
16 (int (*)(...))A::f0 // 虛函數(shù)f0地址
Class A
size=16 align=8
base size=12 base align=8
A (0x0x7f753a178960) 0
vptr=((& A::vtable for A) + 16)
Vtable for B
B::vtable for B: 4 entries
0 (int (*)(...))0 // 類型轉(zhuǎn)換偏移量
8 (int (*)(...))(& typeinfo for B) // 運(yùn)行時(shí)類型信息(Run-Time Type Identification,RTTI)
16 (int (*)(...))A::f0 // 虛函數(shù)f0地址(未override基類函數(shù),因此繼承自A)
24 (int (*)(...))B::f1 // 虛函數(shù)f1地址
Class B
size=16 align=8
base size=16 base align=8
B (0x0x7f753a00e1a0) 0
vptr=((& B::vtable for B) + 16)
A (0x0x7f753a178a20) 0
primary-for B (0x0x7f753a00e1a0)
Vtable for C
C::vtable for C: 5 entries
0 (int (*)(...))0 // 類型轉(zhuǎn)換偏移量
8 (int (*)(...))(& typeinfo for C) // 運(yùn)行時(shí)類型信息(Run-Time Type Identification,RTTI)
16 (int (*)(...))C::f0 // 虛函數(shù)f0地址
24 (int (*)(...))B::f1 // 虛函數(shù)f1地址(未override基類函數(shù),因此繼承自B)
32 (int (*)(...))C::f2 // 虛函數(shù)f2地址
Class C
size=24 align=8
base size=20 base align=8
C (0x0x7f753a00e208) 0
vptr=((& C::vtable for C) + 16)
B (0x0x7f753a00e270) 0
primary-for C (0x0x7f753a00e208)
A (0x0x7f753a178ae0) 0
primary-for B (0x0x7f753a00e270)
此處需要明確,Class A/B/C 均有對應(yīng)的虛表。
虛表主要包含三類信息:
- 類型轉(zhuǎn)換偏移量;
- 運(yùn)行時(shí)類型信息(Run-Time Type Identification,RTTI);
- 虛函數(shù)地址(可以包含多項(xiàng)),具體信息詳見注釋部分。
4.2 多繼承
class A {
int ax;
virtual void f0() {}
};
class B {
int bx;
virtual void f1() {}
};
class C : public A, public B {
virtual void f0() override {}
virtual void f1() override {}
};
得到類內(nèi)存布局如下所示:
// 因?yàn)轭怉與類B比較簡單,因此省略內(nèi)存布局(可參考單繼承內(nèi)存布局)
Vtable for C
C::vtable for C: 7 entries
0 (int (*)(...))0
8 (int (*)(...))(& typeinfo for C)
16 (int (*)(...))C::f0
24 (int (*)(...))C::f1
32 (int (*)(...))-16 // 類型轉(zhuǎn)換偏移量
40 (int (*)(...))(& typeinfo for C) // 運(yùn)行時(shí)類型信息(Run-Time Type Identification,RTTI)
48 (int (*)(...))C::non-virtual thunk to C::f1()
Class C
size=32 align=8
base size=28 base align=8
C (0x0x7f9ce2bde310) 0
vptr=((& C::vtable for C) + 16)
A (0x0x7f9ce2d37ae0) 0
primary-for C (0x0x7f9ce2bde310)
B (0x0x7f9ce2d37b40) 16
vptr=((& C::vtable for C) + 48)
代碼中,類C繼承自類A以及類B,內(nèi)存布局發(fā)生了較大的變化(添加了末尾三行)。
g++的內(nèi)存布局比較晦澀,使用clang導(dǎo)出內(nèi)存布局(基本一致),會比較直觀:
*** Dumping AST Record Layout
0 | struct C
0 | struct A (primary base)
0 | (A vtable pointer)
8 | int ax
16 | struct B (base)
16 | (B vtable pointer)
24 | int bx
| [sizeof=32, dsize=28, align=8,
| nvsize=28, nvalign=8]
由clang的內(nèi)存布局可知,類C的實(shí)例中包含類A與類B的虛指針。
這是因?yàn)锳與B完全獨(dú)立,虛函數(shù)f0與f1之間沒有順序關(guān)系,相對于基類有著相同的起始位置偏移量。
因此在類C中,類A與類B的虛表信息必須保存在兩個(gè)不相交的區(qū)域中,使用兩個(gè)虛指針對其進(jìn)行索引。
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() |
+--------------------+
上圖比較形象的描繪了虛指針,對應(yīng)虛表的內(nèi)容。
首先解釋offset_to_top : 基類轉(zhuǎn)換到派生類時(shí),this指針加上偏移量即可獲得實(shí)際類型的地址。
至于Thunk :
(1) 在B &b = c的場景中,引用的起始地址在C+16處,如果直接調(diào)用f1時(shí),會因?yàn)閠his指針多了16字節(jié)的偏移量導(dǎo)致錯(cuò)誤;
(2) Thunk提示this指針根據(jù)offset_to_top減去16字節(jié)偏移量,繼而調(diào)用f1函數(shù)。
Thunk解釋說明,當(dāng)基類引用持有派生類實(shí)例時(shí),調(diào)用相應(yīng)虛函數(shù),會利用到多態(tài)特性。
4.3 菱形繼承
class A {
public:
virtual void foo() {}
virtual void bar() {}
private:
int ma;
};
class B : virtual public A {
public:
virtual void foo() override {}
private:
int mb;
};
class C : virtual public A {
public:
virtual void bar() override {}
private:
int mc;
};
class D : public B, public C {
public:
virtual void foo() override {}
virtual void bar() override {}
};
基類A中添加了成員變量ma,是因?yàn)轭怉中若不包含成員變量,派生類B/C/D會被優(yōu)化,較難理解。
首先查看類B的內(nèi)存布局:
*** Dumping AST Record Layout
0 | class B
0 | (B vtable pointer)
8 | int mb
16 | class A (virtual base)
16 | (A vtable pointer)
24 | int ma
| [sizeof=32, dsize=28, align=8,
| nvsize=12, nvalign=8]
需要注意,此時(shí)類B中包含兩個(gè)虛指針,且類A的虛指針起始位置為B+16。
查看類B的虛表結(jié)構(gòu),如下所示:
Vtable for 'B' (10 entries).
0 | vbase_offset (16)
1 | offset_to_top (0)
2 | B RTTI
-- (B, 0) vtable address --
3 | void B::foo()
4 | vcall_offset (0)
5 | vcall_offset (-16)
6 | offset_to_top (-16)
7 | B RTTI
-- (A, 16) vtable address --
8 | void B::foo()
[this adjustment: 0 non-virtual, -24 vcall offset offset]
9 | void A::bar()
此時(shí),虛表頭部增加了vbase_offset ,這是因?yàn)樵诰幾g時(shí),無法確定基類A在類B內(nèi)存中的偏移量,因此需要在虛表中添加vbase_offset ,標(biāo)記運(yùn)行時(shí)基類A在類B內(nèi)存中的位置。
此外,虛表中添加了兩項(xiàng)vcall_offset ,這是應(yīng)對使用虛基類A的引用調(diào)用類B實(shí)例的虛函數(shù)時(shí),每一個(gè)虛函數(shù)相對于this指針的偏移量都可能不同,因此需要記錄在vcall_offset中。
- vcall_offset (0): 對應(yīng)A::bar();
- vcall_offset (-16): 對應(yīng)B::foo()。
因此,當(dāng)A引用調(diào)用B實(shí)例的A::bar函數(shù)時(shí),因?yàn)閠his指針指向vptr_a,因此不需要進(jìn)行調(diào)整;調(diào)用B::foo()時(shí),因此foo函數(shù)被B重載,因此需要調(diào)整this指針指向vptr_b。
查看類D的內(nèi)存布局:
*** Dumping AST Record Layout
0 | class D
0 | class B (primary base)
0 | (B vtable pointer)
8 | int mb
16 | class C (base)
16 | (C vtable pointer)
24 | int mc
32 | class A (virtual base)
32 | (A vtable pointer)
40 | int ma
| [sizeof=48, dsize=44, align=8,
| nvsize=28, nvalign=8]
此時(shí),需要注意因?yàn)槭褂锰摾^承,所以類A在類D中只有一份,共擁有三個(gè)虛指針。
虛表內(nèi)容相對較為復(fù)雜,不過基本可以參照類B的虛表進(jìn)行解析,具體如下所示:
Vtable for 'D' (15 entries).
0 | vbase_offset (32)
1 | offset_to_top (0)
2 | D RTTI
-- (B, 0) vtable address --
-- (D, 0) vtable address --
3 | void D::foo()
4 | void D::bar()
5 | vbase_offset (16)
6 | offset_to_top (-16)
7 | D RTTI
-- (C, 16) vtable address --
8 | void D::bar()
[this adjustment: -16 non-virtual]
9 | vcall_offset (-32)
10 | vcall_offset (-32)
11 | offset_to_top (-32)
12 | D RTTI
-- (A, 32) vtable address --
13 | void D::foo()
[this adjustment: 0 non-virtual, -24 vcall offset offset]
14 | void D::bar()
[this adjustment: 0 non-virtual, -32 vcall offset offset]
5 擴(kuò)展
C++的虛表,以及運(yùn)行時(shí)的內(nèi)存模型是很復(fù)雜的問題,在編寫的過程中也是不斷的刷新自己的認(rèn)知。
下面提供一些方式,dump出內(nèi)存中對象的內(nèi)存模型,和類型的虛表結(jié)構(gòu)。
使用clang編譯器:clang++ -cc1 -emit-llvm -fdump-record-layouts -fdump-vtable-layouts main.cpp .
使用gcc編譯器:
g++ -fdump-class-hierarchy -c main.cpp
// g++ dump的內(nèi)容比較晦澀,因此需要使用c++ filt導(dǎo)出具有可讀性的文檔
cat [g++導(dǎo)出的文檔] | c++filt -n > [具有一定可讀性的輸出文檔]
本文內(nèi)存布局部分,參考于:https://zhuanlan.zhihu.com/p/41309205一文。
PS:
如果您覺得我的文章對您有幫助,請關(guān)注我的微信公眾號,謝謝!
|