深度解讀《深度探索C++對象模型》之C++虛函數實現分析(二)

来源:https://www.cnblogs.com/isharetech/p/18154673
-Advertisement-
Play Games

本系列深入分析編譯器對於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子對象開始的,會造成不對齊的問題而引起運行崩潰。

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


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • title: 文本語音互相轉換系統設計 date: 2024/4/24 21:26:15 updated: 2024/4/24 21:26:15 tags: 需求分析 模塊化設計 性能優化 系統安全 智能化 跨平臺 區塊鏈 第一部分:導論 第一章:背景與意義 文本語音互相轉換系統的定義與作用 文本語 ...
  • 參考:https://www.cnblogs.com/mc-74120/p/13622008.html pom文件 <dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> </dependency> 啟動 ...
  • 新手下載python和anaconda3要註意哪些 1、python 關於python下載其實很簡單,直接在官網下載就行。 官網:Welcome to Python.org 當然,到了官網下載是預設最新版本,如果你需要舊版本,那就需要找一下了,這裡提供一下windows的各版本的官網鏈接: Pyth ...
  • 來源:https://www.cnblogs.com/405845829qq/p/7552736.html 前言 公司最近在搞服務分離,數據切分方面的東西,因為單張包裹表的數據量實在是太大,並且還在以每天60W的量增長。 之前瞭解過資料庫的分庫分表,讀過幾篇博文,但就只知道個模糊概念, 而且現在回想 ...
  • 大家好,我是 Java陳序員。 我們無論是日常生活還是辦公,常常需要使用一些工具軟體來記錄筆記、代辦事項等。 今天,給大家介紹一款支持私有化部署、支持多端使用的筆記軟體。 關註微信公眾號:【Java陳序員】,獲取開源項目分享、AI副業分享、超200本經典電腦電子書籍等。 項目介紹 Blossom ...
  • 手寫 SpringMVC 底層機制 前景提要:實現的是SpringMVC核心機制 對一些細枝末節的代碼做了簡化,比如字元串的處理... 完成哪些機制 機制一: 通過@RequestMapping ,可以標記一個方法,編寫路徑url,瀏覽器就能通過url完成調用 機制二: 進行依賴註入,使之不需要傳統 ...
  • SpringMVC筆記 SpringMVC介紹 基本介紹 SpringMVC 是WEB 層框架, 接管了Web 層組件, 支持MVC 的開發模式/開發架構 SpringMVC 通過註解,讓POJO 成為控制器,不需要繼承類或者實現介面 SpringMVC 採用低耦合的組件設計方式,具有更好擴展和靈活 ...
  • 1. 安裝 & 配置 & 啟動 MySQL現在的版本主要分為: 5.x 版本,現在互聯網企業中的主流版本,包括:頭條、美圖、百度、騰訊等互聯網公司主流的版本。 8.x 版本,新增了一些了視窗函數、持久化配置、隱藏索引等其他功能。 所以,我們課程會以常用大版本中最新的版本為例來講解,即:5.7.31 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...