C++虛繼承原理與類佈局分析

千松發表於2024-04-02

C++虛繼承原理與類佈局分析

引言

在開始深入瞭解虛繼承之前,我們先要明白C++引入虛繼承的目的。C++有別於其他OOP語言最明顯的特性就是類的多繼承,而菱形繼承結構則是多繼承中最令人頭疼的情況。

我們都知道,當派生類繼承基類時,派生類內部會儲存一份基類資料的副本。在D->B|C, B|C->A的菱形繼承結構中,BC各自存有一份A成員變數的副本,這導致D繼承BC後同時儲存了兩份A成員變數,這就導致了空間浪費和語法二義性的問題。

所以C++引入了虛繼承,用於解決菱形繼承導致的資料冗餘。

本文的目標是探究虛繼承的實現方式和類佈局(Class Layout)的具體規則,主要內容源自於本人對C++: Under the Hood的解讀和提煉。

不過在開始之前,我們需要先熟悉一下普通繼承下的類佈局,方便與之後的虛繼承進行對比。

請注意,以下用於分析的資料皆來自於MSVC的編譯結果。C++標準定義了一些基本規範,但不同編譯器的實現方式可能會有所差異,所以內容僅具有一定的參考性。

單繼承

以下是由A類派生B類的單繼承例子:

class A
{
public:
    int a1;
    int a2;
};
class B : public A
{
public:
    int b1;
    int b2;
};

透過在VS中啟用Class Layout的輸出,我們可以得到以下內容:

class A	size(8):
	+---
 0	| a1
 4	| a2
	+---

class B	size(16):
	+---
 0	| +--- (base class A)
 0	| | a1
 4	| | a2
	| +---
 8	| b1
12	| b2
	+---

Visual Studio中檢視類佈局的方法可以參考這篇部落格

看起來可能有點抽象,它其實是等價於下圖中的內容:

img

由於派生類繼承了其基類的所有屬性和行為,因此派生類的每個例項都將包含基類例項資料的完整副本。在B中,A的成員資料擺放在B的成員資料之前。雖然標準並沒有如此規定,但是當我們需要將B類的地址嵌入A類的指標時(例如:A *p = new B();),這種佈局不需要再新增額外的位移,就可以使指標指向A資料段的開頭(在接下來的多繼承中更能體現這麼做的好處)。圖中A*B*指標指向的位置也體現了這一點。

因此,在單繼承的類層次結構中,每個派生類中引入的新例項資料只是簡單地附加到基類的佈局末尾。

多繼承

class A
{
public:
    int a1;
    int a2;
};

img

class B
{
public:
    int b1;
    int b2;
};

img

class C : public A, public B
{
public:
    int c1;
    int c2;
};

img

C多重繼承自AB,與單繼承一樣,C包含每個基類例項資料的副本,並且置於類的最前方。與單繼承不同是,多繼承不可能使每個基類資料的起始地址都位於派生類的開頭。從圖中也可以看出,在基類A佔據起始位置後,基類B只能儲存在偏移量為8的位置。這就使得將C*轉換為A*B*時的操作出現了差異。

C c;
(void *)(A *)&c == (void *)&c
(void *)(B *)&c > (void *)&c
(void *)(B *)&c == (void*)(sizeof (A) + (char *)&c)

這幾個判斷語句的結果都為true,因此可以看出當C*轉為B*時,會在原地址的基礎上進行偏移。這也是多繼承帶來的開銷之一。

編譯器實現可以採用任何順序佈置基類例項和派生類例項資料。MSVC通常的做法是先按宣告順序佈局基類例項,然後按宣告順序佈置派生類的新資料成員。 不過在後續的例子中我們將會看到,當部分基類具有虛基類表(或虛擬函式表)而其他基類沒有時,情況就不一定如此了。

菱形繼承

現在就搬出我們在文章開頭提到的菱形繼承的例子,來看看具體的佈局是怎麼樣的。

class A
{
public:
    int a1;
    int a2;
};

img

class B : public A
{
public:
    int b1;
    int b2;
};

img

class C : public A
{
public:
    int c1;
    int c2;
};

img

class D : public B, public C
{
public:
    int d1;
    int d2;
};

img

BC都繼承了A,因此也都儲存了一份基類A的例項資料副本。

當類D同時繼承了類BC之後,也完整地儲存了BC的例項資料副本,也就導致D中出現了兩份A的例項資料副本。

編譯器不能確定我們究竟是要訪問從B繼承來的A成員,還是從C繼承來的A成員,從D*轉換到A*的偏移量也無法確定。因此,下面這些操作都是具有二義性的,不能成功編譯:

D d;
d.a1 = 1; 			// E0266	"D::a1" 不明確
A *p_a = (A *)&d; 	// C2594	“型別強制轉換”: 從“D *”到“A *”的轉換不明確

想要成功執行的話,就必須顯式地宣告訪問路徑,以消除二義性:

D d;
d.B::a1 = 1; 			// 或者d.C::a1
A *p_a = (A *)(B *)&d; 	// 或者(A *)(C *)&d

虛繼承

為了解決這一問題,C++引入了虛繼承的概念。在僅保留一份重複的例項資料副本的情況下,透過虛基類表(vbtable)來訪問共享的例項資料。聽起來有些難以理解,所以接下來我會透過分析虛繼承下的類佈局來解釋虛繼承語法的實現。

我們先來分析單繼承情況下,虛繼承與普通繼承之間的類佈局差異。

class A
{
public:
    int a1;
    int a2;
};

img

class B : public A
{
public:
    int b1;
    int b2;
};

img

class C : virtual public A
{
public:
    int c1;
    int c2;
};

img

A為基類,B繼承於AC虛繼承於A

透過對比BC的類佈局我們可以發現兩個明顯的差異:

  • 虛繼承中,派生類佈局的起始位置增加了vbptr指標,該指標指向vbtable
  • 虛繼承中,基類的例項資料副本被放置在了派生類的末尾

vbtable中的兩個條目也很好理解,我們首先要知道XdYvbptrZ表示的是在X類中,YvbptrZ類入口的偏移量。因此:

  • 第一條記錄CdCvbptrC = 0表示,C類中,CvbptrC類入口的偏移量為0
  • 第二條記錄CdCvbptrA = 16表示,C類中,CvbptrA類入口的偏移量為16。從圖中也可以看出C類中,C::vbptr的儲存位置為0A類的入口位於16,因此偏移量為16

在資料訪問的過程中,需要用到vbtable中的偏移量來計算訪問地址,這就涉及到了查表+偏移的操作。因此,虛繼承的訪問開銷會比前面在多繼承中提到的固定偏移計算來得更大,與此同時vbptrvbtable也造成了額外的記憶體開銷。

從單繼承的例子來看,虛繼承帶來了更大的時間和記憶體開銷,但卻沒有體現出任何的額外優勢。並且也看不出vbptrvbtable存在的必要性,畢竟為什麼我們不直接讓A* = C* + 16

而接下來透過菱形繼承的例子,我們就會明白這種做法的必要性。

虛繼承——菱形繼承

class A
{
public:
    int a1;
    int a2;
};

img

class B : virtual public A
{
public:
    int b1;
    int b2;
};

img

class C : virtual public A
{
public:
    int c1;
    int c2;
};

img

class D : public B, public C
{
public:
    int d1;
    int d2;
};

img

需要注意,在這個例子中BC虛繼承於A,而D則是普通繼承於BC

在為菱形繼承新增上虛繼承之後,我們可以明確地看到BC結尾的A例項資料副本,在D的結尾被合併成了一份。與此同時,編譯器根據D的佈局結構建立了新的vbtableBCvbptr也被修改為指向新的vbtable

現在我們就可以解答前面提出的問題:“為什麼不直接讓`A* = C* + 16呢?”

從圖中就可以看出,在C類的佈局中,C* + 16 == A*是成立的,因此以下程式碼的執行結果是1

C* p_c = new C();
A* p_a = p_c;		// 編譯器自動轉換的結果
printf("%d", (void*)p_a == (void*)(16 + (char*)p_c)); // 返回1

而在D類之中,C* + 16訪問的就是D::d1的地址了,這種做法明顯是錯誤的,因此程式碼的執行結果是0

C* p_c = new D(); // 注意:這裡的C*來源於型別D
A* p_a = p_c;
printf("%d", (void*)p_a == (void*)(16 + (char*)p_c)); // 返回0

所以根本的問題在於,不同類中的A*相對於C*的位置是不固定的,在執行時多型的情況下,我們無法僅在編譯階段計算出確定的偏移量。

但有了vbptrvbtable之後,無論是C類的C*還是D類的C*,我們都可以訪問當前vbptr所指向的vbtable獲取偏移量。而vbptrvbtable都是可以在編譯時根據類佈局來確定的。所以下面的程式碼中,無論C*的來源是C類還是D類,執行的結果始終為1

C* p_c = new D();
A* p_a = p_c;
int* vbptr_c = *(int**)p_c; // 這裡根據C類的佈局知道vbptr位於C*的起始位置(編譯時確定)
printf("%d", (void*)p_a == (void*)(*(vbptr_c + 1) + (char*)p_c)); // vbptr_c + 1是因為A*偏移量位於vbtable[1](編譯時確定)

虛表指標(vbptr)的位置

關於虛繼承的實現方式已經解釋的差不多了,接下來我們再介紹幾種類佈局的情況,以幫助你更好地理解這些概念。

讓我們先複習一下上一個章節中的例子來說明:

class A
{
public:
    int a1;
    int a2;
};

class C : virtual public A
{
public:
    int c1;
    int c2;
};

img

我們已經介紹過了這個佈局,C虛繼承A後,在起始位置新增了vbptr,並將A的例項資料副本佈置在了末尾。

讓我們把情況弄得稍微複雜一些:

class A
{
public:
    int a1;
    int a2;
};

class B // 注意,這次B沒有繼承A
{
public:
    int b1;
    int b2;
};

class C : virtual public A, public B
{
public:
    int c1;
    int c2;
};

img

我們讓C虛繼承A的同時,再普通繼承B。這次C發生了兩個變化:

  1. vbptr的位置從0變為了8,也就是說vbptr的行為似乎和普通成員變數一樣,被佈置在基類的成員之後。注意我這裡說的是"似乎",因為下一章節我們就會找到特例。
  2. 第二個變化則是vbtable中的CdCvbptrC的值從0變為了-8,這其實就是受到vbptr位置變化的影響。

共用虛基類表(vbtable)

介紹完“正常情況”後,我們再來看一個特殊情況。

class A
{
public:
    int a1;
    int a2;
};

img

class B : virtual public A
{
public:
    int b1;
    int b2;
};

img

class C : virtual public A, public B
{
public:
    int c1;
    int c2;
};

img

這次我們讓B虛繼承於A,然後和上一章一樣,讓C虛繼承A的同時,再普通繼承B

可以看到,由於BC都有vbptr,並且具有公共的虛基類A,導致二者的vbptr合併到了起始位置,並且共用一個vbtable

後續我經過幾次測試後發現一個規律,當派生類同時進行虛繼承和非虛繼承的情況下,只要非虛繼承的基類中存在vbptr指標,那麼派生類的虛繼承就會與之共用一個vbptrvbtable

參考資料

C++: Under the Hood

How virtual inheritance is implemented in memory by c++ compiler?

深入理解C++ 虛擬函式表


本文釋出於2024年4月2日

最後編輯於2024年4月2日

相關文章