首先聲明一點,虛表并非是C++語言的官方標準的一部分,只是各家編譯器廠商在實現多態時的解決方案。另外即使同為虛表不同的編譯器對于虛表的設計可能也是不同的,本文主要基于
Itanium C++ ABI
(適用于gcc和clang)。
從C的POD類型到C++的類
首先回顧一下C語言純POD的結構體(struct)。如果用C語言實現一個類似面向對象的類,應該怎么做呢?
寫法一
#include <stdio.h>
typedef struct Actress {
int height; // 身高
int weight; // 體重
int age; // 年齡(注意,這不是數據庫,不必一定存儲生日)
void (*desc)(struct Actress*);
} Actress;
// obj中各個字段的值不一定被初始化過,
// 通常還會在類內定義一個類似構造函數的函數指針,這里簡化
void profile(Actress* obj) {
printf("height:%d weight:%d age:%d\n", obj->height, obj->weight, obj->age);
}
int main() {
Actress a;
a.height = 168;
a.weight = 50;
a.age = 20;
a.desc = profile;
a.desc(&a);
return 0;
}
想達到面向對象中數據和操作封裝到一起的效果,只能給struct里面添加函數指針,然后給函數指針賦值。然而在C語言的項目中你很少會看到這種寫法,主要原因就是函數指針是有空間成本的,這樣寫的話每個實例化的對象中都會有一個指針大?。ū热?字節)的空間占用,如果實例化N個對象,每個對象有M個成員函數,那么就要占用N*M*8
的內存。
所以通常C語言不會用在struct內定義成員函數指針的方式,而是直接:
寫法二
#include <stdio.h>
typedef struct Actress {
int height; // 身高
int weight; // 體重
int age; // 年齡(注意,這不是數據庫,不必一定存儲生日)
} Actress;
void desc(Actress* obj) {
printf("height:%d weight:%d age:%d\n", obj->height, obj->weight, obj->age);
}
int main() {
Actress a;
a.height = 168;
a.weight = 50;
a.age = 20;
desc(&a);
return 0;
}
Redis中AE相關的代碼實現,便是如此。
再看一個C++普通的類:
#include <stdio.h>
class Actress {
public:
int height; // 身高
int weight; // 體重
int age; // 年齡(注意,這不是數據庫,不必一定存儲生日)
void desc() {
printf("height:%d weight:%d age:%d\n", height, weight, age);
}
};
int main() {
Actress a;
a.height = 168;
a.weight = 50;
a.age = 20;
a.desc();
return 0;
}
你覺得你這個class實際相當于C語言兩種寫法中的哪一個?
看著像寫法一?其實相當于寫法二。C++編譯器實際會幫你生成一個類似上例中C語言寫法二的形式。這也算是C++ zero overhead
(零開銷
)原則的一個體現。
You shouldn't pay for what you don't use.
當然實際并不完全一致,因為C++支持重載的關系,會存在命名崩壞。但主要思想相同,雖不中,亦不遠矣。
看到這,你會明白:C++中類和操作的封裝只是對于程序員而言的。而編譯器編譯之后其實還是面向過程的代碼。編譯器幫你給成員函數增加一個額外的類指針參數,運行期間傳入對象實際的指針。類的數據(成員變量)和操作(成員函數)其實還是分離的。
每個函數都有地址(指針),不管是全局函數還是成員函數在編譯之后幾乎類似。
在類不含有虛函數的情況下,編譯器在編譯期間就會把函數的地址確定下來,運行期間直接去調用這個地址的函數即可。這種函數調用方式也就是所謂的靜態綁定
(static binding
)。
何謂多態?
虛函數的出現其實就是為了實現面向對象三個特性之一的多態
(polymorphism
)。
#include <stdio.h>
#include <string>
using std::string;
class Actress {
public:
Actress(int h, int w, int a):height(h),weight(w),age(a){};
virtual void desc() {
printf("height:%d weight:%d age:%d\n", height, weight, age);
}
int height; // 身高
int weight; // 體重
int age; // 年齡(注意,這不是數據庫,不必一定存儲生日)
};
class Sensei: public Actress {
public:
Sensei(int h, int w, int a, string c):Actress(h, w, a),cup(c){};
virtual void desc() {
printf("height:%d weight:%d age:%d cup:%s\n", height, weight, age, cup.c_str());
}
string cup;
};
int main() {
Sensei s(168, 50, 20, "36D");
s.desc();
return 0;
}
上例子,最終輸出顯而易見:
height:168 weight:50 age:20 cup:36D
再看:
Sensei s(168, 50, 20, "36D");
Actress* a = &s;
a->desc();
Actress& a2 = s;
a2.desc();
這種情況下,用父類指針指向子類的地址,最終調用desc函數還是調用子類的。輸出:
height:168 weight:50 age:20 cup:36D
height:168 weight:50 age:20 cup:36D
這個現象稱之為動態綁定
(dynamic binding
)或者延遲綁定
(lazy binding
)。
但倘若你 把父類Actress中desc()函數前面的vitural
去掉,這個代碼最終將調用父類的函數desc(),而非子類的desc()!輸出:
height:168 weight:50 age:20
height:168 weight:50 age:20
這是為什么呢?指針實際指向的還是子類對象的內存空間,可是為什么不能調用到子類的desc()?這個就是我在第一部分說過的:類的數據(成員變量)和操作(成員函數)其實是分離的。
僅從對象的內存布局來看,只能看到成員變量,看不到成員函數。因為調用哪個函數是編譯期間就確定了的,編譯期間只能識別父類的desc()。
好了,現在我們對于C++如何應用多態有了一定的了解,那么多態又是如何實現的呢?
終于我們談到虛表
C++具體多態的實現一般是編譯器廠商自由發揮的。但無獨有偶,使用虛表指針來實現多態幾乎是最常見做法(基本上已經是最好的多態實現方法)。廢話不多說,繼續看代碼,有微調:
#include <stdio.h>
class Actress {
public:
Actress(int h, int w, int a):height(h),weight(w),age(a){};
virtual void desc() {
printf("height:%d weight:%d age:%d\n", height, weight, age);
}
virtual void name() {
printf("I'm a actress");
}
int height; // 身高
int weight; // 體重
int age; // 年齡(注意,這不是數據庫,不必一定存儲生日)
};
class Sensei: public Actress {
public:
Sensei(int h, int w, int a, const char* c):Actress(h, w, a){
snprintf(cup, sizeof(cup), "%s", c);
};
virtual void desc() {
printf("height:%d weight:%d age:%d cup:%s\n", height, weight, age, cup);
}
virtual void name() {
printf("I'm a sensei");
}
char cup[4];
};
int main() {
Sensei s(168, 50, 20, "36D");
s.desc();
Actress* a = &s;
a->desc();
Actress& a2 = s;
a2.desc();
return 0;
}
父類有兩個虛函數,子類重載了這兩個虛函數。
clang有個命令可以輸出對象的內存布局(不同編譯器內存布局未必相同,但基本類似):
clang -cc1 -fdump-record-layouts -stdlib=libc++ actress.cpp
可以得到:
*** Dumping AST Record Layout
0 | class Actress
0 | (Actress vtable pointer)
8 | int height
12 | int weight
16 | int age
| [sizeof=24, dsize=20, align=8,
| nvsize=20, nvalign=8]
*** Dumping AST Record Layout
0 | class Sensei
0 | class Actress (primary base)
0 | (Actress vtable pointer)
8 | int height
12 | int weight
16 | int age
20 | char [4] cup
| [sizeof=24, dsize=24, align=8,
| nvsize=24, nvalign=8]
內存布局、大小、內存對齊都一目了然。
可以發現父類Actress的起始位置多了一個Actress vtable pointer
。子類Sensei是在父類的基礎上多了自己的成員cup。
也就是說在含有虛函數的類編譯期間,編譯器會自動給這種類在起始位置追加一個虛表指針,一般稱之為:vptr
。vptr指向一個虛表,稱之為:vtable
或vtbl
,虛表中存儲了實際的函數地址。
再看下虛表存儲了什么東西。你在網上搜一下資料,肯定會說虛表里存儲了虛函數的地址,但是其實不止這些!clang同樣有命令:
clang -Xclang -fdump-vtable-layouts -stdlib=libc++ -c actress.cpp
g++也有打印虛表的操作(請在Linux上使用g++),會自動寫到一個文件里:
g++ -fdump-class-hierarchy actress.cpp
看下clang的結果:
Vtable for 'Actress' (4 entries).
0 | offset_to_top (0)
1 | Actress RTTI
-- (Actress, 0) vtable address --
2 | void Actress::desc()
3 | void Actress::name()
VTable indices for 'Actress' (2 entries).
0 | void Actress::desc()
1 | void Actress::name()
Vtable for 'Sensei' (4 entries).
0 | offset_to_top (0)
1 | Sensei RTTI
-- (Actress, 0) vtable address --
-- (Sensei, 0) vtable address --
2 | void Sensei::desc()
3 | void Sensei::name()
VTable indices for 'Sensei' (2 entries).
0 | void Sensei::desc()
1 | void Sensei::name()
g++的結果(其實也比較清晰,甚至更清晰):
Vtable for Actress
Actress::_ZTV7Actress: 4u entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI7Actress)
16 (int (*)(...))Actress::desc
24 (int (*)(...))Actress::name
Class Actress
size=24 align=8
base size=20 base align=8
Actress (0x0x7f9b1fa8c960) 0
vptr=((& Actress::_ZTV7Actress) + 16u)
Vtable for Sensei
Sensei::_ZTV6Sensei: 4u entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI6Sensei)
16 (int (*)(...))Sensei::desc
24 (int (*)(...))Sensei::name
Class Sensei
size=24 align=8
base size=24 base align=8
Sensei (0x0x7f9b1fa81138) 0
vptr=((& Sensei::_ZTV6Sensei) + 16u)
Actress (0x0x7f9b1fa8c9c0) 0
primary-for Sensei (0x0x7f9b1fa81138)
可以看出二者其實基本一致,只是個別名稱叫法不同。

所有虛函數的的調用取的是哪個函數(地址)是在運行期間通過查虛表確定的。
更新:vptr指向的并不是虛表的表頭,而是直接指向的虛函數的位置。使用gdb或其他工具可以發現:
(gdb) p s
$2 = {<Actress> = {_vptr.Actress = 0x400a70 <vtable for Sensei+16>, height = 168, weight = 50, age = 20}, cup = "36D"}
vptr指向的是Sensei的vtable + 16
個字節的位置,也就是虛表的地址。
虛表本身是連續的內存。動態綁定的實現也就相當于(假設p為含有虛函數的對象指針):
(*(p->vptr)[n])(p)
但其實上面的圖片也只是簡化版,不是完整的的虛表。通過gdb查看,你其實可以發現子類和父類的虛表是連在一起的。上面gdb打印出了虛表指針指向:0x400a70。我們倒退16個字節(0x400a60)輸出一下:

可以發現子類和父類的虛表其實是連續的。并且下面是它們的typeinfo信息也是連續的。
虛表的第一個條目vtable for Sensei
值為0。
虛表的第二個條目vtable for Sensei+8
指向的其實是0x400ab0,也就是下面的typeinfo for Sensei
。
再改一下代碼。我們讓子類Sensei只重載一個父類函數desc()。
class Sensei: public Actress {
public:
Sensei(int h, int w, int a, const char* c):Actress(h, w, a){
snprintf(cup, sizeof(cup), "%s", c);
};
virtual void desc() {
printf("height:%d weight:%d age:%d cup:%s\n", height, weight, age, cup);
}
char cup[4];
};
其他地方不變,重新用clang或g++剛才的命令執行一遍。clang的輸出:
Vtable for 'Actress' (4 entries).
0 | offset_to_top (0)
1 | Actress RTTI
-- (Actress, 0) vtable address --
2 | void Actress::desc()
3 | void Actress::name()
VTable indices for 'Actress' (2 entries).
0 | void Actress::desc()
1 | void Actress::name()
Vtable for 'Sensei' (4 entries).
0 | offset_to_top (0)
1 | Sensei RTTI
-- (Actress, 0) vtable address --
-- (Sensei, 0) vtable address --
2 | void Sensei::desc()
3 | void Actress::name()
VTable indices for 'Sensei' (1 entries).
0 | void Sensei::desc()
可以看到子類的name由于沒有重載,所以使用的還是父類的。一圖勝千言:

好了,寫了這么多,相信大家應該已經能理解虛表存在的意義及其實現原理。但同時我也埋下了新的坑沒有填:
虛表中的前兩個條目是做什么用的?
它倆其實是為多重繼承服務的。
第一個條目存儲的offset,是一種被稱為 thunk
的技術(或者說技巧)。第二個條目存儲著為 RTTI
服務的type_info
信息。
關于這部分的介紹,請關注后續文章!
往期推薦