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

iShare_爱分享發表於2024-05-16

“深度解讀《深度探索C++物件模型》”系列已經在CSDN上和我的公眾號上更新完畢,請有需要的同學移步到我的CSDN主頁裡去閱讀,主頁地址:https://blog.csdn.net/iShare_Carlos?spm=1010.2135.3001.5421
或者敬請關注我的公眾號:iShare愛分享

前面兩篇請從這裡閱讀:
深度解讀《深度探索C++物件模型》之C++虛擬函式實現分析(一)
深度解讀《深度探索C++物件模型》之C++虛擬函式實現分析(二)

虛繼承情況下的虛擬函式和多型的實現分析

虛繼承如果再加上多重繼承關係,或者具有兩層以上的虛繼承關係,那麼編譯器對於虛擬函式的支援簡直像進了迷宮一樣讓人眼花繚亂,它們的關係讓人撲朔迷離。其實在實際的應用中很少會出現這樣的設計,也不建議這樣做。我們還是以一個較為常用的只有一層的虛繼承關係的例子來講解對於虛擬函式的支援,如以下的例子:

#include <cstdio>

class Base {
public:
    virtual ~Base() = default;
    virtual void virtual_func1() { printf("%s\n", __PRETTY_FUNCTION__); }
    virtual void virtual_func2() { printf("%s\n", __PRETTY_FUNCTION__); }
    int b = 0;
};
class Derived: virtual public Base {
public:
    virtual ~Derived() = default;
    void virtual_func2() override { printf("%s\n", __PRETTY_FUNCTION__); }
    virtual void virtual_func3()  { printf("%s\n", __PRETTY_FUNCTION__); }
    int d = 0;
};

int main() {
    Derived* pd = new Derived;
    pd->virtual_func1();
    pd->virtual_func2();
    pd->virtual_func3();
    Base* pb = pd;
    pb->virtual_func1();
    pb->virtual_func2();
    delete pd;
    return 0;
}

上面的程式碼中繼承關係雖然只是單一繼承,但由於是虛繼承,所以它不像普通的單繼承那樣,基類的子類部分和物件的起始地址是對齊的,虛擬函式表也共用同一個,由於虛繼承的關係,虛基類的子類部分是共享的,一般編譯器的實現會把它放到物件佈局的最尾端,即在所有具體繼承的子物件和子類之後,也不和任何子物件共用虛擬函式表,它自己單獨擁有一個虛擬函式表。所以上面的程式碼編譯器將會產生兩個虛擬函式表,一個是Derived子類的,一個是Base虛基類的,只不過編譯器把兩個表合併在一起,兩個子物件(Derived和Base)的虛擬函式表指標被設定指向不同的偏移地址,看看上面程式碼對應的彙編程式碼中的虛擬函式表:

vtable for Derived:
    .quad   16
    .quad   0
    .quad   typeinfo for Derived
    .quad   Derived::~Derived() [complete object destructor]
    .quad   Derived::~Derived() [deleting destructor]
    .quad   Derived::virtual_func2()
    .quad   Derived::virtual_func3()
    .quad   -16
    .quad   0
    .quad   -16
    .quad   -16
    .quad   typeinfo for Derived
    .quad   virtual thunk to Derived::~Derived() [complete object destructor]
    .quad   virtual thunk to Derived::~Derived() [deleting destructor]
    .quad   Base::virtual_func1()
    .quad   virtual thunk to Derived::virtual_func2()

Derived物件的虛擬函式表被設定指向上面的第5行的位置,Base虛基類的虛擬函式表被設定指向第14行的位置,這些事情都是編譯器在預設解構函式中生成的程式碼來完成的,具體的分析可以見另外一篇文章《深度解讀<深度探索C++物件模型>之預設建構函式》。因為虛繼承的存在,上面的表中除了支援多型的虛擬函式和RTTI資訊外,還包含了支援虛繼承的資訊,主要就是一些正負偏移值,用來在有需要時調整this指標,如第2行的16就是從Derived物件的起始地址調整到Base虛基類子物件的起始地址,第9到12行的-16用於從Base虛基類子物件調整回Derived物件的起始地址。上面部分是主表,下面部分是次表,主表中是Derived類定義的虛擬函式:虛解構函式、virtual_func2和virtual_func3兩個虛擬函式,次表是從Base虛基類繼承而來的虛擬函式,包括了虛解構函式、virtual_func1和virtual_func2兩個虛擬函式,其中虛解構函式和virtual_func2虛擬函式在Derived類中進行了改寫,所以這裡存放的不是真正的虛擬函式例項的地址,而是指向thunk技術實現的一段彙編程式碼,彙編程式碼裡會跳轉到真實的虛擬函式例項中執行。

虛繼承下支援虛擬函式的困難點主要在於兩方面:一個是透過Derived型別的指標呼叫Base虛基類中的虛擬函式;另一個是透過Base虛基類型別的指標呼叫Derived類的虛擬函式。它們的呼叫關係跟多重繼承下處理第二及後繼基類的方式很相似,下面我們以這兩點分別來講解。

  • 透過Derived型別的指標呼叫Base虛基類中的虛擬函式

在上面C++程式碼中的第20到22行的三行呼叫中,對virtual_func2和virtual_func3虛擬函式的呼叫,因為這兩個虛擬函式存在於Derived類的虛擬函式表中,所以對這兩個的呼叫採用的是常規的呼叫方法。對virtual_func1虛擬函式的呼叫,因為virtual_func1虛擬函式是從Base虛基類繼承來的且在Derived類中沒有進行改寫,因此它只存在於Base虛基類的虛擬函式表中,呼叫它之前先要進行this指標的調整,讓this指標指向Base子物件的起始地址,再透過Base子物件的虛擬函式表指標來定址到它的虛擬函式表,並呼叫對應的虛擬函式,下面是它的彙編程式碼:

mov     rax, qword ptr [rbp - 16]
mov     rcx, qword ptr [rax]
mov     rcx, qword ptr [rcx - 24]
mov     rdi, rax
add     rdi, rcx
mov     rax, qword ptr [rax + rcx]
call    qword ptr [rax + 16]

[rbp - 16]棧空間存放的是Derived物件的起始地址,對其取值即是虛擬函式表指標(如不熟悉請參考《深度解讀<深度探索C++物件模型>之C++物件的記憶體佈局》),它指向的是Derived類的虛擬函式表的起始地址,也即是上表中的第5的位置,[rcx - 24]的意思是往上偏移24位元組並取值,往上偏移24位元組即指向了表的開頭位置,它的值是16,這個值就是上面介紹的用於支援虛繼承調整this指標的作用,然後上面彙編程式碼的第4、5行把它加到rdi上,rdi暫存器存放的是Derived物件的起始地址,rdi暫存器(作為this指標)也將作為第7行呼叫虛擬函式時的引數。第6行的[rax + rcx]的意思是Derived物件的起始地址加上16偏移值然後取值,它是Base子物件的虛擬函式表指標(指向上表中的第14行),然後在第7行程式碼的呼叫時再加上16的偏移值即是virtual_func1虛擬函式對應的地址,即上表中的第16行。

  • 透過Base虛基類型別的指標呼叫Derived類的虛擬函式

透過Base虛基類型別的指標呼叫Derived類的虛解構函式和virtual_func2虛擬函式,採用的是相同的實現方法,即thunk技術。所以放在一起來講,先來看下它們的彙編程式碼:

virtual thunk to Derived::~Derived() [deleting destructor]:	# @virtual thunk to Derived::~Derived() [deleting destructor]
    push    rbp
    mov     rbp, rsp
    mov     qword ptr [rbp - 8], rdi
    mov     rdi, qword ptr [rbp - 8]
    mov     rax, qword ptr [rdi]
    mov     rax, qword ptr [rax - 24]
    add     rdi, rax
    pop     rbp
    jmp     Derived::~Derived() [deleting destructor] # TAILCALL
# 另一個虛解構函式的程式碼差不多,這裡省略

virtual thunk to Derived::virtual_func2():	# @virtual thunk to Derived::virtual_func2()
    push    rbp
    mov     rbp, rsp
    mov     qword ptr [rbp - 8], rdi
    mov     rdi, qword ptr [rbp - 8]
    mov     rax, qword ptr [rdi]
    mov     rax, qword ptr [rax - 40]
    add     rdi, rax
    pop     rbp
    jmp     Derived::virtual_func2()    # TAILCALL

透過Base型別的指標來呼叫Derived類的虛解構函式的場景是:Base型別的指標指向Derived的物件,然後呼叫了delete函式釋放這個物件,這時呼叫的是在Base子物件的虛擬函式表中的虛解構函式,它是thunk技術實現的一段彙編程式碼。virtual_func2虛擬函式定義在Derived類中,又是對Base虛基類中的virtual_func2虛擬函式的改寫,所以存在於兩個虛擬函式表中,但實際的函式例項只有一個,在Base虛基類的虛擬函式表中存放的是thunk技術實現的一段彙編程式碼。

上面的兩個函式都是thunk技術生成的彙編程式碼,程式碼的內容基本一樣,只是在最後一行跳轉到不同的函式中去執行。首先將this指標(儲存在rdi暫存器中,這時指向Base子物件的地址)儲存到[rbp - 8]的棧空間中,然後取值並儲存到rax暫存器中,這裡取到的值是Base子物件中的虛擬函式表指標,即指向上表中第14行的位置,然後減去24(或40)的偏移量並取值,這兩處的值都是-16,然後加上rdi中,rdi儲存的是Base子物件的地址,向下偏移16位元組後回到Derived物件的起始地址,然後跳轉到相應的函式中去執行。

“深度解讀《深度探索C++物件模型》”系列已經在CSDN上和我的公眾號上更新完畢,請有需要的同學移步到我的CSDN主頁裡去閱讀,主頁地址:https://blog.csdn.net/iShare_Carlos?spm=1010.2135.3001.5421
或者敬請關注我的公眾號:iShare愛分享

相關文章