深度解讀《深度探索C++物件模型》之C++虛擬函式實現分析(二)

iShare_爱分享發表於2024-04-24

接下來我將持續更新“深度解讀《深度探索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子物件開始的,會造成不對齊的問題而引起執行崩潰。

是否在多重繼承下才會有這樣的問題?其實不然,在單一繼承下也會存在問題,雖然在單一繼承下,物件中的父類的子物件和物件的起始地址是對齊的,釋放記憶體不會造成程式崩潰,但是這時呼叫的是父類的解構函式而不是子類的解構函式,這將導致派生類真正想要的析構動作將不會被執行到,例如本來要在解構函式中釋放資源的動作將沒有被執行,將導致資源的洩露,如在建構函式中申請的記憶體等。

相關文章