本系列深入分析編譯器對於C++虛函數的底層實現,最後分析C++在多態的情況下的性能是否有受影響,多態究竟有多大的性能損失。 ...
接下來我將持續更新“深度解讀《深度探索C++對象模型》”系列,敬請期待,歡迎關註!也可以關註公眾號:iShare愛分享,自動獲得推文和全部的文章列表。
第一篇請從這裡閱讀:
深度解讀《深度探索C++對象模型》之C++虛函數實現分析(一)
這一篇主要講解多重繼承情況下的虛函數實現分析。
在多重繼承下支持虛函數,主要體現在對第二及其後繼的基類的處理上,下麵我們以一個具體的例子來講解:
#include <cstdio>
class Base1 {
public:
virtual ~Base1() = default;
virtual void virtual_func1() { printf("%s\n", __PRETTY_FUNCTION__); }
virtual void virtual_func2() { printf("%s\n", __PRETTY_FUNCTION__); }
virtual Base1* clone() { return new Base1; }
int b1 = 0;
};
class Base2 {
public:
virtual ~Base2() = default;
virtual void virtual_func3() { printf("%s\n", __PRETTY_FUNCTION__); }
virtual void virtual_func4() { printf("%s\n", __PRETTY_FUNCTION__); }
virtual Base2* clone() { return new Base2; }
int b2 = 0;
};
class Derived: public Base1, public Base2 {
public:
virtual ~Derived() = default;
void virtual_func1() override { printf("%s\n", __PRETTY_FUNCTION__); }
void virtual_func3() override { printf("%s\n", __PRETTY_FUNCTION__); }
virtual void virtual_func5() { printf("%s\n", __PRETTY_FUNCTION__); }
virtual Derived* clone() override { return new Derived; }
int d = 0;
};
int main() {
Derived* pd = new Derived;
pd->virtual_func1();
pd->virtual_func2();
pd->virtual_func3();
pd->virtual_func4();
Base1* pb1 = pd;
pb1->virtual_func1();
pb1->virtual_func2();
Base2* pb2 = pd;
Base2* pb = pb2->clone();
pb->virtual_func3();
pb->virtual_func4();
delete pd;
delete pb;
return 0;
}
多重繼承下圍繞第二及後繼的基類的問題主要表現在虛函數表的處理、this指針的調整,虛析構函數的調用,下麵將一一展開來分析。
多重繼承下虛函數表的問題
每個類主要有虛函數,編譯器將會為這個類生成虛函數表,子類會繼承基類的虛函數表,這是我們已經知道的事情。但是在多重繼承下,將會有兩個以上的基類,那麼子類將會繼承到多個虛函數表,如果多重繼承中,有N個基類有虛函數表,子類中也將會有N個虛函數表。編譯器將如何處理這種情況?不同的編譯器可能有不同的處理方式,Clang和Gcc編譯器是將多個虛函數表合併在一起,每個子表仍然是包含RTTI信息和子對象的虛函數地址,具體看一下實際彙編代碼中的虛函數表:
vtable for Derived:
.quad 0
.quad typeinfo for Derived
.quad Derived::~Derived() [base object destructor]
.quad Derived::~Derived() [deleting destructor]
.quad Derived::virtual_func1()
.quad Base1::virtual_func2()
.quad Derived::clone()
.quad Derived::virtual_func3()
.quad Derived::virtual_func5()
.quad -16
.quad typeinfo for Derived
.quad non-virtual thunk to Derived::~Derived() [complete object destructor]
.quad non-virtual thunk to Derived::~Derived() [deleting destructor]
.quad non-virtual thunk to Derived::virtual_func3()
.quad Base2::virtual_func4()
.quad covariant return thunk to Derived::clone()
Base1類和Base2類的虛函數表跟普通情況下的一樣,就不貼出來了。上面表中的第2到第10行是Base1子對象的虛函數表,它和Derived類的對象共用同一個,稱為主表,第11到第17行是Base2子對象的虛函數表,也稱為次表。對應有兩個虛函數表指針,一個是在對象的起始地址(也是Base1子對象的起始地址),另一個是在Base2子對象的起始地址(對象首地址加上大小為Base1子對象大小的偏移量)。這兩個虛函數表指針是在對象構造時,在構造函數中由編譯器生成的彙編代碼設置的,Base1子對象的虛函數表指針被設置為指向表中第4行的第一個虛函數的位置,Base2子對象的虛函數表指針被設置為指向表中第13行次表的第一個虛函數的位置,具體的代碼就不分析了,詳見另一篇《深度解讀《深度探索C++對象模型》之預設構造函數》。
繼續分析上面虛函數表的內容,表中有兩個析構函數,第一個是完整的析構函數,完成主要的析構動作,用於局部對象、臨時對象等釋放時被調用,第二個析構函數是給在堆空間中申請的對象釋放時調用的,也就是用new函數申請的記憶體空間,在這個析構函數里會先調用第一個析構函數,然後再調用delete函數釋放申請的記憶體空間。主表中有兩個(第4、5行),次表也有兩個(第13、14行),次表中的兩個最終也是調用主表中的析構函數,這裡涉及到thunk技術,稍後再細講。
主表繼承了Base1基類的虛函數表,按順序是虛析構函數、virtual_func1、virtual_func2和clone函數,其中只有virtual_func2沒有改寫,直接拷貝了基類的虛函數的地址,之後virtual_func3和virtual_func5是Derived子類新增的虛函數,virtual_func3雖然是對Base2基類中的虛函數的改寫,但對於Base1基類來說相當於是新增的,它和Base2子對象中virtual_func3是共用一個函數,在稍後詳細講解。
判定一個虛函數是否被改寫的規則是函數名稱、參數個數和類型以及返回類型都必須相同,但有兩個例外的地方,第一個是虛析構函數,只要基類中定義了虛析構函數,子類就一定繼承了虛析構函數,即使代碼中沒有定義,編譯器也會為它生成一個,而且名稱也不要求相同,當然也不可能相同。第二個是類似上面的clone函數,在基類中返回類型是基類類型,在派生類中返回的是派生類的類型時,規則允許例外,它也會被當做是重寫。
用派生類指針調用第二及後繼基類的虛函數
通過派生類指針調用第二及後繼基類中一個繼承而來的虛函數,主要的工作在於調整this指針,如C++代碼中使用Derived類型的指針pd調用virtual_func4虛函數,virtual_func4是Base2基類定義的虛函數,Derived類沒有改寫它,直接繼承它的實現,因此它只存在於Base2子對象的虛函數表中,調用virtual_func4函數,需要把this指針調整到Base2子對象的起始位置,它和Derived對象的起始地址相差Base1子對象的大小,彙編代碼中調用virtual_func4函數的實現:
mov rax, qword ptr [rbp - 16]
mov rdi, rax
add rdi, 16
mov rax, qword ptr [rax + 16]
call qword ptr [rax + 24]
[rbp - 16]是存放Derived對象的起始地址,把它載入到rdi寄存器後再加上16的偏移量(第2、3行),16就是Base1子對象的大小,偏移後還是保存在rdi寄存器,rdi寄存器作為第5行調用函數時的參數,也即是this指針,這時它是指向Base2子對象,第4行中的[rax + 16]是將Derived對象的起始地址加上16的偏移量,也就是指向Base2子對象的起始地址,這裡保存著指向Base2子對象的虛函數表的指針,對其取值後就是Base2子對象的虛函數表的起始地址,在第5行的調用中,[rax + 24]就是在虛函數表的起始地址偏移24,相當於跳過3個虛函數(每個虛函數的地址占用8位元組),也就是上面虛函數表中的第16行virtual_func4函數(請參考上表),對其取值即virtual_func4虛函數的地址,然後調用之。
用第二及後繼基類的指針調用派生類的虛函數
通過第二及後繼基類的指針調用派生類中的虛函數,主要圍繞在幾方面上:派生類Derived類改寫的Base2基類的虛函數如virtual_func3虛函數,調用clone函數的問題,虛析構函數的問題。
通過第二基類如Base2基類的指針調用virtual_func3函數的問題體現在:因為Derived類中對virtual_func3虛函數進行改寫,所以virtual_func3也被添加到Base1子對象的虛函數表中(相當於新增函數),同時它也是對繼承自Base2基類的virtual_func3虛函數的改寫,所以它也必然存在於Base2子對象的虛函數表中,因此在兩個表格中占了兩個條目,但實際的函數實例只有一個。在Base1子對象的虛函數表中存放的是真實的virtual_func3虛函數的地址,而在Base2子對象的虛函數表中存放的是一個輔助函數的地址,這個輔助函數是由編譯器實現的,就是一段彙編代碼,主要的工作就是去調整this指針,調整後再去調用真正的virtual_func3函數,這就是thunk技術。來看看彙編代碼中的實現:
# pb->virtual_func3();
mov rdi, qword ptr [rbp - 40]
mov rax, qword ptr [rdi]
call qword ptr [rax + 16]
non-virtual thunk to Derived::virtual_func3(): # @non-virtual thunk to Derived::virtual_func3()
push rbp
mov rbp, rsp
mov qword ptr [rbp - 8], rdi
mov rdi, qword ptr [rbp - 8]
add rdi, -16
pop rbp
jmp Derived::virtual_func3() # TAILCALL
上面幾行的彙編代碼是通過Base2類型的指針調用virtual_func3函數,做法就是通過Base2子對象的虛函數表找到virtual_func3虛函數的地址然後調用它,但是這裡的virtual_func3的地址不是真實的virtual_func3函數實例的地址,而是我們上面分析的輔助函數,即thunk技術,是編譯器實現的一段彙編代碼。在這彙編代碼里,首先將參數rdi寄存器(保存著Base2子對象的地址,即Base2子對象的this指針)取出來保存到棧空間[rbp - 8]中,然後減去16的偏移量,16是Base1子對象的大小,也就是調整到Derived類對象的起始的地址,然後保存到rdi寄存器作為調用virtual_func3函數的參數,最後跳轉到真正的virtual_func3函數去執行(第13行)。
對clone函數的調用也存在同樣的問題,clone函數在Base1基類和Base2基類中都有定義,在Derived類中進行改寫,因此在Base1子對象和Base2子對象的虛函數表中都各自占了一個條目,主表中存放的是真正的clone函數的實現,次表中存放的是thunk技術實現的輔助函數,但它比對virtual_func3函數的調用要更複雜一些。看一下這段彙編代碼的實現:
# Base2* pb = pb2->clone();
mov rdi, qword ptr [rbp - 32]
mov rax, qword ptr [rdi]
call qword ptr [rax + 32]
mov qword ptr [rbp - 40], rax
covariant return thunk to Derived::clone(): # @covariant return thunk to Derived::clone()
# 略...
add rdi, -16
call Derived::clone()
mov qword ptr [rbp - 16], rax # 8-byte Spill
cmp rax, 0
je .LBB13_2
mov rax, qword ptr [rbp - 16] # 8-byte Reload
add rax, 16
mov qword ptr [rbp - 24], rax # 8-byte Spill
jmp .LBB13_3
.LBB13_2:
# 略...
.LBB13_3:
# 略...
上面彙編代碼的前面幾行是調用虛函數的常規做法,只不過這時調用到的是下麵這個thunk技術實現的clone函數。它比調用virtual_func3函數麻煩的地方在於,在調用真正的clone函數之前要先調整this指針,即上面彙編代碼的第9行,這時將this指針調整為指向Derived對象的起始地址,然後調用真正的clone函數(第10行)。調用完clone函數之後還得再調整一次this指針,因為clone函數返回的是Derived對象的起始地址,我們要把它賦值給Base2類型的指針,所以要把this指針調整到指向Base2子對象的起始地址,不然通過它返回的指針(即pb指針)調用函數或者存取數據成員時將引起錯誤,首先判斷返回的指針是否為0(第12行),不為0的話就加上16的偏移量(第15行),即指向Base2子對象,然後返回。
虛析構函數的問題和實現手法跟上面兩種情況類似,同樣存在兩種類型的虛析構函數,一個為真正的實例,一個是thunk技術實現的。有兩種調用到虛析構函數的情況,第一種是new出來的Derived對象賦值給Base1類型的指針,最後再通過Base1類型的指針delete掉,如:
Base1* pb1 = new Derived;
...
delete pd1;
這種情況下跟直接使用Derived類型的指針是一樣的,因為Base1子對象的起始地址和Derived對象的起始地址是對齊的,不需要調整this指針,這時將調用的是Base1子對象的虛函數表中真正的析構函數,完成析構動作。
第二種情況是通過Base2類型的指針來操作,如:
Base2* pb2 = new Derived;
...
delete pb2;
這時因為Base2子對象和Derived的起始地址不對齊,需要調整this指針,所以這時先調用thunk技術實現的析構函數,在析構函數里完成this指針調整後再調用真正的析構函數,下麵是彙編代碼:
non-virtual thunk to Derived::~Derived() [deleting destructor]: # @non-virtual thunk to Derived::~Derived() [deleting destructor]
push rbp
mov rbp, rsp
mov qword ptr [rbp - 8], rdi
mov rdi, qword ptr [rbp - 8]
add rdi, -16
pop rbp
jmp Derived::~Derived() [deleting destructor]
代碼的意思跟上面的彙編代碼差不多,就不詳細解釋了。
為什麼多態時需要虛析構函數
最後來談談在多態時為什麼需要將析構函數聲明為虛函數。假如在上面的例子中,我們沒有將析構函數聲明為虛函數,那麼析構函數將沒有多態的行為。當Base2類型的指針指向一個Derived對象時,這時通過Base2類型的指針來釋放對象,調用的將是Base2類的析構函數,它將只會釋放掉Base2子對象部分的記憶體,這將會引起程式的崩潰,因為申請的記憶體的起始地址是Derived對象開始的,釋放時是從Base2子對象開始的,會造成不對齊的問題而引起運行崩潰。
是否在多重繼承下才會有這樣的問題?其實不然,在單一繼承下也會存在問題,雖然在單一繼承下,對象中的父類的子對象和對象的起始地址是對齊的,釋放記憶體不會造成程式崩潰,但是這時調用的是父類的析構函數而不是子類的析構函數,這將導致派生類真正想要的析構動作將不會被執行到,例如本來要在析構函數中釋放資源的動作將沒有被執行,將導致資源的泄露,如在構造函數中申請的記憶體等。