C++類開發的第六篇(虛擬繼承實現原理和cl命令的使用的bug修復)

ivanlee717發表於2024-03-01

Class_memory

接上一篇末尾虛擬繼承的簡單介紹之後,這篇來詳細講一下這個記憶體大小是怎麼分配的。

使用cl

cl 是 Microsoft Visual Studio 中的 C/C++ 編譯器命令。透過在命令列中鍵入 cl 命令,可以呼叫 Visual Studio 的編譯器進行編譯操作。cl 命令提供了各種選項和引數,用於指定原始檔、編譯選項、輸出目標等資訊,從而進行編譯過程。

cl /d1 reportSingleClassLayoutBigBase useclub.cpp這是我們要檢視記憶體使用的命令,具體的語法是

cl /d1 reportSingleClassLayoutSSSS MMMM.cpp

SSSS代表的是你想要檢視的類,MMMM就是所要針對的檔名。

這條命令 cl /d1 reportSingleClassLayoutSSSS MMMM.cpp 是使用 Microsoft Visual C++ 編譯器的特殊選項來生成有關指定類的單個類佈局報告的命令。

具體來說:

  • /d1 是一個編譯選項,用於啟用或禁用某些特定的編譯器功能。
  • reportSingleClassLayoutSSSS 是一個編譯器選項,用於指示編譯器生成關於類的單個類佈局報告。在這裡,SSSS 是指要生成報告的類的名稱。
  • MMMM.cpp 是包含類定義的原始檔。

透過執行這個命令,編譯器將會生成關於指定類的單個類佈局報告,其中包括類的成員變數在記憶體中的偏移量、大小等資訊。這對於除錯和最佳化程式碼時瞭解類的內部佈局非常有用。

image-20240301185724567

但是最初我們還沒有把cl命令放進環境變數裡,所以無法執行。這個是VS自帶的一個工具,所以無法執行。這個是VS自帶的一個工具,根據型號我們選擇的都是X86,找到圖裡三個檔案的位置放進PATH環境裡image-20240301180546409

image-20240301190109394

完成這一步之後執行cl命令就沒問題了。image-20240301190346504

但是又會出現找不到標頭檔案位置fatal error C1034: iostream: 不包括路徑集這種問題是由於標頭檔案也還沒有匯入到系統變數裡面。image-20240301190717870

所以在 Windows 系統中,INCLUDE 環境變數是用於指定 C/C++ 編譯器在編譯過程中搜尋標頭檔案的路徑的一個環境變數。當您編譯 C/C++ 程式碼時,編譯器會根據 INCLUDE 環境變數中指定的路徑去查詢要包含的標頭檔案。

通常情況下,INCLUDE 環境變數會包含一系列目錄路徑,這些路徑是編譯器用來搜尋標頭檔案的位置。如果您使用的是 Visual Studio 或者其他整合開發環境,通常會自動配置好這個環境變數,使得編譯器可以順利地找到所需的標頭檔案。編譯器會自動查詢 C:\Program Files (x86)\Windows Kits\10\Include 目錄中的標頭檔案,因此開發人員無需手動指定這個路徑,所以我們把這裡的檔案都放進去,並且變數名命名為INCLUDE。

image-20240301191340335

此時的問題就變為了下圖的新問題fatal error LNK1104: 無法開啟檔案'libcpmt.lib'

image-20240301191424104

在 Windows 系統中,LIB 環境變數是用於指定編譯器在連結過程中搜尋庫檔案的路徑的一個環境變數。當您使用編譯器連結程式碼時,編譯器會根據 LIB 環境變數中指定的路徑去查詢要連結的庫檔案。

類似於 INCLUDE 環境變數用於指定標頭檔案路徑,LIB 環境變數用於指定庫檔案路徑。這些庫檔案包括靜態庫(.lib)和動態連結庫(.dll)等,它們包含了已經編譯好的函式和資料結構,可以供程式在執行時呼叫和使用。

通常情況下,LIB 環境變數會包含一系列目錄路徑,這些路徑是編譯器用來搜尋庫檔案的位置。如果您使用的是 Visual Studio 或其他整合開發環境,通常會自動配置好這個環境變數,使得編譯器可以順利地找到所需的庫檔案。

所以現在再把lib相關的路徑也新增進去

image-20240301191630683

image-20240301191717161

這下每個類的記憶體圖就都有了。

使用記憶體圖

類名 普通繼承 虛繼承
BigBase image-20240301193220732 image-20240301191926745
Base1 image-20240301193156218 image-20240301191951830
Base2 image-20240301193040510 image-20240301192007263
Derived image-20240301193015307 image-20240301192029630

因為使用了虛擬繼承,所以會涉及到虛表指標(vptr)和虛表(vtable)的處理,以及對齊等問題。在大多數情況下,編譯器為了記憶體對齊的目的會在資料成員後面填充一些位元組,以保證存取效率。因此,儘管只有一個成員變數,但實際佔用的空間會比較大。

首先注意到一點是上一篇中講到這裡使用sizeof(Derived)得到了24,但是此時的大小是12image-20240301192714292

這兩個大小指的是不同的概念。

sizeof(Derived) 表示派生類 Derived 物件所佔用的記憶體大小。在這個例子中,Derived 類包含了一個虛基類 BigBase 的子物件和一些額外的資訊,比如虛擬函式表等。因此,sizeof(Derived) 的結果為 24。

sizeof(class Derived) 表示僅考慮 Derived 類本身所需的記憶體大小,不包括其繼承的部分。在這個例子中,Derived 類本身沒有定義任何成員變數,因此 sizeof(class Derived) 的結果為 12,表示只包含了一些額外的資訊,比如虛擬函式表等。

總結起來,sizeof(Derived) 考慮了整個 Derived 物件所需的記憶體大小,包括繼承的部分和額外的資訊,而 sizeof(class Derived) 只考慮了 Derived 類本身的大小。

但是我們可以看到在普通繼承關係當中,Derived 類同時繼承了 Base1Base2 兩個類,而這兩個類又都直接繼承自 BigBase 類。這種多重繼承的情況下,如果沒有使用虛擬繼承(virtual),每個基類會在派生類中各自存在一份例項,導致記憶體佔用增加。

因為 Base1Base2 都直接繼承自 BigBase,所以在 Derived 類中將會包含兩份 BigBase 的子物件,每份包含一個 int 型別的成員變數 mParam。這就導致了 Derived 物件的大小等於 Base1Base2BigBase 中成員變數的總和,所以大小和類本身一樣。

換句話說,每個基類都會在派生類中引入自己的成員變數和函式,而不會共享相同的基類例項。這種情況下,派生類的大小等於各個基類的大小之和,因此大小和類本身一樣。

兩種繼承方式對比著看,

  1. BigBase 菱形最頂層的類,記憶體佈局圖沒有發生改變。

  2. Base1和Base2透過虛繼承的方式派生自BigBase,這兩個物件的佈局圖中可以看出編譯器為我們的物件中增加了一個vbptr (virtual base pointer),vbptr指向了一張表,這張表儲存了當前的虛指標相對於虛基類的首地址的偏移量。

  3. Derived派生於Base1和Base2,繼承了兩個基類的vbptr指標,並調整了vbptr與虛基類的首地址的偏移量。

由此可知編譯器幫我們做了一些幕後工作,使得這種菱形問題在繼承時候能只繼承一份資料,並且也解決了二義性的問題。現在模型就變成了Base1和 Base2 Derived三個類物件共享了一份BigBase資料。

當使用虛繼承時,虛基類是被共享的,也就是在繼承體系中無論被繼承多少次,物件記憶體模型中均只會出現一個虛基類的子物件(這和多繼承是完全不同的)。即使共享虛基類,但是必須要有一個類來完成基類的初始化(因為所有的物件都必須被初始化,哪怕是預設的),同時還不能夠重複進行初始化,那到底誰應該負責完成初始化呢?C++標準中選擇在每一次繼承子類中都必須書寫初始化語句(因為每一次繼承子類可能都會用來定義物件),但是虛基類的初始化是由最後的子類完成,其他的初始化語句都不會呼叫。

class A {
public:	
	A() {
		cout << "A(): " << endl;
	}
};
class B : virtual public A {
public:
	B() :A() {
		cout << "B():A(): " << endl;
	}
};
class C : virtual public A {
public:
	C() :A() {
		cout << "C():A(): " << endl;
	}
};
class D : public C, public B {
public:
	D() {
		cout << "D() " << endl;
	}
};
void test() {
	D d;
}

image-20240301223934521

這個輸出代表了物件的構造順序。根據輸出可以看出:

  1. 首先,類 A 的建構函式被呼叫,輸出 "A(): "。
  2. 接著,類 C 的建構函式被呼叫,由於 C 類繼承了虛基類 A,所以會先呼叫 A 的建構函式,輸出 "C():A(): "。
  3. 然後,類 B 的建構函式被呼叫,同樣會先呼叫 A 的建構函式,輸出 "B():A(): "。
  4. 最後,類 D 的建構函式被呼叫,由於 D 類同時繼承了類 C 和類 B,而這兩個類都已經初始化過虛基類 A 的部分,所以在 D 的建構函式中不需要再次呼叫 A 的建構函式。

由於類D是多重繼承體系中的最底層類,它同時繼承了類C和類B,而這兩個類都間接繼承了虛基類A。在這種情況下,編譯器會負責確保虛基類A只被初始化一次

相關文章