虛函式表與多型
虛函式表與多型,是C++開發人員終究要面對的問題,
雖然很久沒寫C++了,此處還是將其整理一下進行記錄,
編譯器資訊:
- 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
首先需要明確,空類(包含非虛函式),其大小為1,
為了能將class實體放到陣列里,空類必須具有大小,否則陣列sizeof將是災難,
不過空類作為基類時,為了對齊可能占用4各位元組或以上,因此編譯器有慷訓類優化,
慷訓類優化:令非靜態資料成員、無虛函式的基類實際占用0位元組,
現在,我們開始加入一個虛函式,再次查看類大小,
class Empty {
public:
Empty() = default;
~Empty() = default;
void hello() { std::cout << "hello world" << std::endl; }
virtual void virtual_test() {}
};
// sizeof(Empty) = 8
加入虛函式后,類大小從1位元組增加至為8位元組,
這是因為,編譯器在類中隱式插入了虛函式表指標(void *vptr),指標大小為8位元組,
關于編譯器在背后做的事情,建議看<<深度探索C++物件模型>>(雖然看了就忘,但是比沒看要好一些),
2 虛函式表指標(vptr)與虛函式表(vtbl)
對于包含虛函式的類,編譯器會為類創建相應的虛函式表(vtbl),
虛函式表中,主要存放類所對應的虛函式地址,
在編譯期間,編譯器會在建構式中,對vptr進行賦值,數值為vtbl的地址,
偽代碼如下所示:
class Empty {
public:
Empty() {
vtpr = (void*)&Empty::vtbl;
}
}
改進一些,我們修改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;
}
主要改進就是添加成員變數m1,m2,以及添加若干函式(包含虛函式),
使用gdb查看Empty實體的記憶體布局,具體如下所示:

由上圖可知,Empty實體的記憶體布局為:
- vptr(紅線部分,指向Empty的虛表);
- m1,m2,
3 多型呼叫
C++的三大特性是封裝,繼承以及多型,其中多型必須依靠虛函式實作,
通俗點說,如果通過呼叫虛函式表指標(vtpr)找到虛函式表(vtbl)的入口并執行虛函式,則程式使用到了多型,
舉個例子:
class Base {
public:
virtual void virtual_func() {}
};
int main() {
Base *a = new Base();
a->virtual_func(); // 多型呼叫
Base b;
b.virtual_func(); // 非多型呼叫
Base *c = &b;
c->virtual_func(); // 多型呼叫
return 0;
}
為了驗證注釋中的觀點,我們使用匯編代碼進行佐證:

上圖可以看出,三次呼叫virtual_func,匯編代碼存在較大不同,
原因是a,c實體呼叫virtual_func相對于b實體呼叫virtual_func,多了需要去虛表(vtbl)中查找virtual_func函式入口的程序,
4 記憶體布局
下文將分別從單繼承,多繼承以及菱形繼承三點闡述虛表的記憶體布局(使用g++匯出記憶體布局),
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() {}
};
記憶體布局如下所示:
Vtable for A
A::vtable for A: 3 entries
0 (int (*)(...))0 // 型別轉換偏移量
8 (int (*)(...))(& typeinfo for A) // 運行時型別資訊(Run-Time Type Identification,RTTI)
16 (int (*)(...))A::f0 // 虛函式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 // 型別轉換偏移量
8 (int (*)(...))(& typeinfo for B) // 運行時型別資訊(Run-Time Type Identification,RTTI)
16 (int (*)(...))A::f0 // 虛函式f0地址(未override基類函式,因此繼承自A)
24 (int (*)(...))B::f1 // 虛函式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 // 型別轉換偏移量
8 (int (*)(...))(& typeinfo for C) // 運行時型別資訊(Run-Time Type Identification,RTTI)
16 (int (*)(...))C::f0 // 虛函式f0地址
24 (int (*)(...))B::f1 // 虛函式f1地址(未override基類函式,因此繼承自B)
32 (int (*)(...))C::f2 // 虛函式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均有對應的虛表,
虛表主要包含三類資訊:
- 型別轉換偏移量;
- 運行時型別資訊(Run-Time Type Identification,RTTI);
- 虛函式地址(可以包含多項),具體資訊詳見注釋部分,
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 {}
};
得到類記憶體布局如下所示:
// 因為類A與類B比較簡單,因此省略記憶體布局(可參考單繼承記憶體布局)
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 // 型別轉換偏移量
40 (int (*)(...))(& typeinfo for C) // 運行時型別資訊(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,記憶體布局發生了較大的變化(添加了末尾三行),
g++的記憶體布局比較晦澀,使用clang匯出記憶體布局(基本一致),會比較直觀:
*** 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的記憶體布局可知,類C的實體中包含類A與類B的虛指標,
這是因為A與B完全獨立,虛函式f0與f1之間沒有順序關系,相對于基類有著相同的起始位置偏移量,
因此在類C中,類A與類B的虛表資訊必須保存在兩個不相交的區域中,使用兩個虛指標對其進行索引,
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() |
+--------------------+
上圖比較形象的描繪了虛指標,對應虛表的內容,
首先解釋offset_to_top: 基類轉換到派生類時,this指標加上偏移量即可獲得實際型別的地址,
至于Thunk:
(1) 在B &b = c的場景中,參考的起始地址在C+16處,如果直接呼叫f1時,會因為this指標多了16位元組的偏移量導致錯誤;
(2) Thunk提示this指標根據offset_to_top減去16位元組偏移量,繼而呼叫f1函式,
Thunk解釋說明,當基類參考持有派生類實體時,呼叫相應虛函式,會利用到多型特性,
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,是因為類A中若不包含成員變數,派生類B/C/D會被優化,較難理解,
首先查看類B的記憶體布局:
*** 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]
需要注意,此時類B中包含兩個虛指標,且類A的虛指標起始位置為B+16,
查看類B的虛表結構,如下所示:
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()
此時,虛表頭部增加了vbase_offset,這是因為在編譯時,無法確定基類A在類B記憶體中的偏移量,因此需要在虛表中添加vbase_offset,標記運行時基類A在類B記憶體中的位置,
此外,虛表中添加了兩項vcall_offset,這是應對使用虛基類A的參考呼叫類B實體的虛函式時,每一個虛函式相對于this指標的偏移量都可能不同,因此需要記錄在vcall_offset中,
- vcall_offset (0): 對應A::bar();
- vcall_offset (-16): 對應B::foo(),
因此,當A參考呼叫B實體的A::bar函式時,因為this指標指向vptr_a,因此不需要進行調整;呼叫B::foo()時,因此foo函式被B多載,因此需要調整this指標指向vptr_b,
查看類D的記憶體布局:
*** 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]
此時,需要注意因為使用虛繼承,所以類A在類D中只有一份,共擁有三個虛指標,
虛表內容相對較為復雜,不過基本可以參照類B的虛表進行決議,具體如下所示:
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 擴展
C++的虛表,以及運行時的記憶體模型是很復雜的問題,在撰寫的程序中也是不斷的重繪自己的認知,
下面提供一些方式,dump出記憶體中物件的記憶體模型,和型別的虛表結構,
使用clang編譯器:clang++ -cc1 -emit-llvm -fdump-record-layouts -fdump-vtable-layouts main.cpp.
使用gcc編譯器:
g++ -fdump-class-hierarchy -c main.cpp
// g++ dump的內容比較晦澀,因此需要使用c++ filt匯出具有可讀性的檔案
cat [g++匯出的檔案] | c++filt -n > [具有一定可讀性的輸出檔案]
本文記憶體布區域分,參考于:https://zhuanlan.zhihu.com/p/41309205一文,
PS:
如果您覺得我的文章對您有幫助,請關注我的微信公眾號,謝謝!
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/49358.html
標籤:C++
上一篇:linux環境下的時間編程
下一篇:資料結構-線性表

