C++的虛函數和RTTI
不少人面試的時候,都會被問起來,C++的虛函數是如何實現的,有人會回答到用虛表實現,那么虛表具體又是怎么實現的呢?
最近讀到shaharmike的一個博客系列,很好的回答了這個問題。閱讀的過程中有些筆記和心得,記錄如下。需要注意的是,這里的內容只是在clang++特定版本上用的實現,只作為學習和參考的目的。
普通類的內存布局和帶虛函數類的內存布局
#include <iostream>
using namespace std;
class NonVirtualClass {
public:
void foo() {}
};
class VirtualClass {
public:
virtual void foo() {}
};
int main() {
cout << "Size of NonVirtualClass: " << sizeof(NonVirtualClass) << endl;
cout << "Size of VirtualClass: " << sizeof(VirtualClass) << endl;
}
這里NonVirtualClass的大小為1,而VirtualClass的大小為8(64位情況),有兩個原因造成兩者的不同:
- C++中類的大小不能為0,所以一個空類的大小為1
- 如果對一個空類對象取地址,如果大小為0,這個地址就沒法取了。
- 如果一個空類有虛函數,那其內存布局中只有一個虛表指針,其大小為
sizeof(void*)
單繼承下類的虛表布局和type_info布局
#include <iostream>
class Parent {
public:
virtual void Foo() {}
virtual void FooNotOverridden() {}
};
class Derived : public Parent {
public:
void Foo() override {}
};
int main() {
Parent p1, p2;
Derived d1, d2;
std::cout << "done" << std::endl;
}
虛表布局
單繼承下的虛表比較簡單,虛表指針總是指向虛表偏移+16(2 * sizeof(void*))的地址,這個地址是第一個虛函數的入口地址。
Parent類的虛表布局如下:地址偏移 含義 0x0 top_offset用於多繼承0x8 指向 Parent的type_info指針0x10 Parent::Foo()函數地址,也是p1,p2中虛表指針指向的元素0x18 Parent::FooNotOverridden()函數地址Derived類的虛表布局如下:地址偏移 含義 0x0 top_offset用於多繼承0x8 指向 Derived的type_info指針0x10 Derived::Foo()函數地址,也是d1,d2中虛表指針指向的元素0x18 Parent::FooNotOverridden()函數地址
從這里可以看到:
Derived的虛表中,如果父類的虛函數沒有被override,那么虛表中還是存着父類函數的指針。- 還可以看到,所有的虛函數調用,都是從虛表取查的。因此,如果沒有必要,盡量少的使用虛函數,否則會有一點額外開銷。
type_info布局
而type_info的地址,存在虛表指針指向元素的上一個位置,它的包含三個部分:
- 輔助類地址,用來實現
type_info的函數 - 類名地址
- 父類
type_info地址
多繼承下類的內存布局和虛表布局
多繼承的情況比較復雜,我們知道,多繼承下,子類指針轉為父類指針后,這個父類指針使用起來應當和一個真正的父類對象的指針沒有區別。
子類沒有override父類的虛函數
先來分析這樣一個例子
class Mother {
public:
virtual void MotherMethod() {}
int mother_data;
};
class Father {
public:
virtual void FatherMethod() {}
int father_data;
};
class Child : public Mother, public Father {
public:
virtual void ChildMethod() {}
int child_data;
};
Child類的內存布局如下:
| 偏移 | 大小 | 內容 |
|---|---|---|
| 0x0 | 8 | Mother虛表指針 |
| 0x8 | 4 | Mother::mother_data |
| 0x10 | 8 | Father虛表指針 |
| 0x18 | 4 | Father::father_data |
| 0x1c | 4 | Child::child_data |
Child類的虛表如下:
| 地址偏移 | 含義 |
|---|---|
| 0x0 | top_offset用於多繼承 |
| 0x8 | 指向Child的type_info指針 |
| 0x10 | Mother::MotherMethod()函數地址,也是Child對象中Mother虛表指針指向的元素 |
| 0x18 | Child::ChildMother函數地址 |
| 0x20 | top_offset用於多繼承 |
| 0x28 | 指向Child的type_info指針 |
| 0x30 | Father::FatherMethod()函數地址,也是Child對象中Father虛表指針指向的元素 |
結論和說明:
Mother虛表指針是作為Mother*和Child*時用到的虛表指針。這里Child自己的虛函數Child::ChildMother緊接着Mother的虛函數地址排布,因此作為Mother*和Child*時可以共用一個虛表指針。Father虛表指針是作為Father*用到的虛表指針,它並不是原來子類指針指向的地址。當Child*類型的指針轉型為Father*類型指針時,需要進行偏移。因此,下面這段代碼會觸發斷言。Child c; auto p1 = reinterpret_cast<void*>(&c); auto p2 = reinterpret_cast<void*>(static_cast<Father*>(&c)); assert(p1 == p2 && "this will be triggerred");- 類的內存布局中有padding的地方。
Child::child_data之前沒有padding,這里用到了一種tail padding的技術。
子類override非第一個父類的虛函數
class Mother {
public:
virtual void MotherMethod() {}
};
class Father {
public:
virtual void FatherMethod() {}
};
class Child : public Mother, public Father {
public:
void FatherMethod() override {}
};
Child類的內存布局如下:
| 偏移 | 大小 | 內容 |
|---|---|---|
| 0x0 | 8 | Mother虛表指針 |
| 0x8 | 8 | Father虛表指針 |
它的虛表也發生了一些變化,新的虛表布局如下:
| 地址偏移 | 含義 |
|---|---|
| 0x0 | top_offset用於多繼承 |
| 0x8 | 指向Child的type_info指針 |
| 0x10 | Mother::MotherMethod()函數地址,也是Child對象中Mother虛表指針指向的元素 |
| 0x18 | Child::FatherMethod函數地址 |
| 0x20 | top_offset用於多繼承 |
| 0x28 | 指向Child的type_info指針 |
| 0x30 | 調用Child::FatherMethod()的thunk函數地址,也是Child對象中Father虛表指針指向的元素 |
注意到,最后一個元素存儲的不再是函數地址,而是thunk地址,這個thunk會調用對應的函數。
為什么要這樣多此一舉呢?從上文得知,從Child*轉型為Father*需要進行指針的偏移。如果子類override過非第一個父類的虛函數,當從父類指針調用這個虛函數時,this指針是偏移過的,用這個指針去調用,結果肯定是不對的,需要把指針再偏移回去,這個偏移量就存在對應的top_offset里面,不過在thunk中並沒有用到這個偏移量。
thunk解析
這里把thunk的匯編列出來,順便加了注釋
# 開辟新的棧空間
push %rbp
mov %rsp,%rbp
sub $0x10,%rsp
# 保存rid寄存器內容到棧上,這里存的是this
mov %rdi,-0x8(%rbp)
# 這一步是干嘛的?
mov -0x8(%rbp),%rdi
# this指針偏移,減去了8,指向了Child的起始地址
add $0xfffffffffffffff8,%rdi
# 真正調用的函數
callq 0x400810 <Child::FatherFoo()>
# 清棧
add $0x10,%rsp
pop %rbp
# 結束調用thunk
retq
這里有點疑惑,為什么結束調用后,沒有把偏移的指針地址改回來呢?
我在g++(MinGW)上試了下,發現對thunk的處理不太一樣。window平台下this指針存在rcx寄存器里,同時也沒有做恢復this寄存器的操作。windows的反匯編輸出如下:
Dump of assembler code from 0x402d20 to 0x402f80:
13 void FatherMethod() override {}
0x0000000000402d20 <non-virtual thunk to Child::FatherMethod()+0>: sub $0x8,%rcx
0x0000000000402d24 <non-virtual thunk to Child::FatherMethod()+4>: jmpq 0x402d00 <Child::FatherMethod()>
在VC++中又嘗試了一下,發現這次thunk的內容很簡單,只有一條jump指令,而對this指針的偏移是放在函數體里面做的,而且函數調用方this指針存放在rcx中,在函數內部this指針存放在rdi中,所以不需要恢復rcx。看來不同的編譯器實現的很不一樣。
菱形虛繼承下類的內存布局和虛表布局
虛繼承是一個C++中比較難以讓新手入門的地方,虛繼承主要是為了菱形繼承考慮,如果沒有虛繼承,最后的派生類會擁有兩個祖父對象,這無疑會造成難以查找的bug。
#include <iostream>
using namespace std;
class Grandparent {
public:
virtual void grandparent_foo() {}
int grandparent_data;
};
class Parent1 : virtual public Grandparent {
public:
virtual void parent1_foo() {}
int parent1_data;
};
class Parent2 : virtual public Grandparent {
public:
virtual void parent2_foo() {}
int parent2_data;
};
class Child : public Parent1, public Parent2 {
public:
virtual void child_foo() {}
int child_data;
};
int main() {
Child child;
}
在這段代碼中,祖父類Grandparent派生出兩個直接子類Parent1和Parent2,這兩者又被最子類Child所繼承。與簡單的多重繼承相比,Child類除了類的內存布局和虛表布局不同之外,又新增了兩個construction vtable和一個VTT,下面來看看這些到底是什么東西。
類的內存布局
| 偏移 | 含義 |
|---|---|
| 0x0 | Parent1和Child的虛表指針 |
| 0x8 | Parent1::parent1_data |
| 0x10 | Parent2的虛表指針 |
| 0x18 | Parent2::parent2_data |
| 0x1c | Child::child_data |
| 0x20 | Grandparent的虛表指針 |
| 0x28 | Grandparent::grandparent_data |
從布局可以看到,Grandparent::grandparent_data對象的位置在整個對象的末尾。那么問題來了,Parent1、Parent2和Child的虛函數在調用的時候,怎么去知道Grandparent::grandparent_data的位置的呢?這個疑問先不急着解決,先來看下虛表布局。
類的虛表布局
| 偏移 | 值 | 含義 | 虛指針 |
|---|---|---|---|
| 0x0 | 0x20 | virtual base offset | |
| 0x8 | 0 | top_offset |
|
| 0x10 | 指向Child的type_info指針 |
||
| 0x18 | Parent1::parent_foo()的函數地址 |
Parent1和Child的虛指針指向這里 |
|
| 0x20 | Child::child_foo()的函數地址 |
||
| 0x28 | 0x10 | virtual base offset | |
| 0x30 | -16 | top_offset |
|
| 0x38 | 指向Child的type_info指針 |
||
| 0x40 | Parent2::parent2_foo()的函數地址 |
Parent2的虛指針指向這里 |
|
| 0x48 | 0 | virtual base offset | |
| 0x50 | -32 | top_offset |
|
| 0x58 | 指向Child的type_info指針 |
||
| 0x60 | Grandparent::grandparent_foo()的函數地址 |
Grandparent的虛指針指向這里 |
這里多了一個新的項目virtual base offset,其實從字面意義還是挺明確的,它的意思是虛基類相對於當前this指針的偏移,因此如果要訪問虛基類中的成員變量,只要在當前this指針上加上這個偏移就可以了。
construction vtable
這里有兩個額外的虛表,分別是construction vtable for Parent1-in-Child和construction vtable for Parent2-in-Child。顧名思義,它們是在構造Parent1和Parent2子對象時候用的。
下表是construction vtable for Parent1-in-Child:
| 偏移 | 值 | 含義 |
|---|---|---|
| 0x0 | 0x20 | virtual base offset |
| 0x8 | 0x0 | top_offset |
| 0x10 | Parent1的type_info的地址 |
|
| 0x18 | Parent1::parent1_foo()的地址 |
|
| 0x20 | 0x0 | virtual base offset |
| 0x28 | -0x20 | top_offset |
| 0x30 | Parent1的type_info的地址 |
|
| 0x38 | Grandparent::grandparent_foo的地址 |
VTT
VTT的意思是virtual table table,意思是虛表的表,里面存的是虛表的入口地址。這里VTT的使用方式,沒有google到詳細信息,留坑以后填上。
結論
使用虛繼承可以解決菱形繼承的問題。如果一個祖父類可能被間接繼承多次,並且希望在內存中只有一份,那么只要把它在繼承樹上所有的直接子類改成虛繼承就好了。
編譯器自動生成的代碼
在C++中,很多操作都會包含一些看不見的行為,一不小心就會造成性能問題或引起bug,這也是C++讓人頭疼的地方之一。
構造函數
當程序執行一個構造函數時,會進行如下操作:
- 依次調用父類的構造函數,如果沒有指定則調用父類的默認構造函數
- 如果有虛函數,設置虛函數指針
- 根據初始化成員列表對成員變量進行初始化,如果沒有指定則使用其默認值或initialize list中的參數進行初始化
- 執行構造函數中的代碼
對這樣一段代碼來說
#include <iostream>
#include <string>
using namespace std;
class Parent {
public:
Parent() { Foo(); }
virtual ~Parent() = default;
virtual void Foo() { cout << "Parent" << endl; }
int i = 0;
};
class Child : public Parent {
public:
Child() : j(1) { Foo(); }
void Foo() override { cout << "Child" << endl; }
int j;
};
class Grandchild : public Child {
public:
Grandchild() { Foo(); s = "hello"; }
void Foo() override { cout << "Grandchild" << endl; }
string s;
};
int main() {
Grandchild g;
}
每個類型的執行順序為:
| Parent | Child | Grandchild |
|---|---|---|
| Call Parent's default ctor | Call Child's default ctor | |
| vtable = Parent's vtable | vtable = child's vtable | vtable = Grandchild's vtable |
| i = 0 | j = 1 | call s's default ctor |
| call Foo(); | call Foo(); | call Foo(); |
| call operator= on s; |
由於每個類型在執行其構造函數時,虛指針指向的是自己的虛函數表,所以此時相當於沒有虛函數,所以程序依次輸出Parent,Child,Grandchild。這里也解釋了為什么需要construction vtable的原因。
析構函數
析構函數和構造函數類似,但是執行順序是相反的。在父類的的析構函數中調用虛函數,因為此時虛指針已經指向父類的虛表,所以並不會調用到子類的虛函數。
- 執行析構函數中的代碼
- 執行成員變量的析構函數
- 設置虛指針為父類的虛指針
- 依次調用父類的析構函數
隱式轉型
前面提到,多重繼承中,當指針從父類轉型為非第一個子類時,指針的值會發生變化。
Dynamic Cast(RTTI)
dynamic_cast通過檢查虛表中type_info的信息判斷能否在運行時進行指針轉型以及是否需要指針偏移,需要插入額外的操作,這也解釋了dynamic_cast的開銷問題。
函數指針
留坑。
小測試
#include <iostream>
using namespace std;
class FooInterface {
public:
virtual ~FooInterface() = default;
virtual void Foo() = 0;
};
class BarInterface {
public:
virtual ~BarInterface() = default;
virtual void Bar() = 0;
};
class Concrete : public FooInterface, public BarInterface {
public:
void Foo() override { cout << "Foo()" << endl; }
void Bar() override { cout << "Bar()" << endl; }
};
int main() {
Concrete c;
c.Foo();
c.Bar();
FooInterface* foo = &c;
foo->Foo();
BarInterface* bar = (BarInterface*)(foo);
bar->Bar(); // Prints "Foo()" - WTF?
}
這里Bar()函數的結果卻輸出了Foo()。因為強制轉型后指針的值沒有變化,虛指針也沒有變化還是指向FooInterface的虛表。而因為BarInterface和FooInterface的布局是一樣的,調用Bar()就相當於調用了Foo。這里如果想要得到期望的結果,需要使用dynamic_cast。
