圖說C++物件模型:物件記憶體佈局詳解

melonstreet發表於2016-05-28

0.前言

文章較長,而且內容相對來說比較枯燥,希望對C++物件的記憶體佈局、虛表指標、虛基類指標等有深入瞭解的朋友可以慢慢看。本文的結論都在VS2013上得到驗證。不同的編譯器在記憶體佈局的細節上可能有所不同。

文章如果有解釋不清、解釋不通或疏漏的地方,懇請指出。

1.何為C++物件模型?

引用《深度探索C++物件模型》這本書中的話:

有兩個概念可以解釋C++物件模型:

  1. 語言中直接支援物件導向程式設計的部分。
  2. 對於各種支援的底層實現機制。

直接支援物件導向程式設計,包括了建構函式、解構函式、多型、虛擬函式等等,這些內容在很多書籍上都有討論,也是C++最被人熟知的地方(特性)。而物件模型的底層實現機制卻是很少有書籍討論的。物件模型的底層實現機制並未標準化,不同的編譯器有一定的自由來設計物件模型的實現細節。在我看來,物件模型研究的是物件在儲存上的空間與時間上的更優,並對C++物件導向技術加以支援,如以虛指標、虛表機制支援多型特性。

2.文章內容簡介

這篇文章主要來討論C++物件在記憶體中的佈局,屬於第二個概念的研究範疇。而C++直接支援物件導向程式設計部分則不多講。文章主要內容如下:

  • 虛擬函式表解析。含有虛擬函式或其父類含有虛擬函式的類,編譯器都會為其新增一個虛擬函式表,vptr,先了解虛擬函式表的構成,有助對C++物件模型的理解。
  • 虛基類表解析。虛繼承產生虛基類表(vbptr),虛基類表的內容與虛擬函式表完全不同,我們將在講解虛繼承時介紹虛擬函式表。
  • 物件模型概述:介紹簡單物件模型、表格驅動物件模型,以及非繼承情況下的C++物件模型。
  • 繼承下的C++物件模型。分析C++類物件在下面情形中的記憶體佈局:
    1. 單繼承:子類單一繼承自父類,分析了子類重寫父類虛擬函式、子類定義了新的虛擬函式情況下子類物件記憶體佈局。
    2. 多繼承:子類繼承於多個父類,分析了子類重寫父類虛擬函式、子類定義了新的虛擬函式情況下子類物件記憶體佈局,同時分析了非虛繼承下的菱形繼承。
    3. 虛繼承:分析了單一繼承下的虛繼承、多重基層下的虛繼承、重複繼承下的虛繼承。
  • 理解物件的記憶體佈局之後,我們可以分析一些問題:
    1. C++封裝帶來的佈局成本是多大?
    2. 由空類組成的繼承層次中,每個類物件的大小是多大?

至於其他與記憶體有關的知識,我假設大家都有一定的瞭解,如記憶體對齊,指標操作等。本文初看可能晦澀難懂,要求讀者有一定的C++基礎,對概念一有一定的掌握。

3.理解虛擬函式表

3.1.多型與虛表

C++中虛擬函式的作用主要是為了實現多型機制。多型,簡單來說,是指在繼承層次中,父類的指標可以具有多種形態——當它指向某個子類物件時,通過它能夠呼叫到子類的函式,而非父類的函式。

圖說C++物件模型:物件記憶體佈局詳解

這是一種執行期多型,即父類指標唯有在程式執行時才能知道所指的真正型別是什麼。這種執行期決議,是通過虛擬函式表來實現的。

3.2.使用指標訪問虛表

如果我們豐富我們的Base類,使其擁有多個virtual函式:

圖說C++物件模型:物件記憶體佈局詳解

當一個類本身定義了虛擬函式,或其父類有虛擬函式時,為了支援多型機制,編譯器將為該類新增一個虛擬函式指標(vptr)。虛擬函式指標一般都放在物件記憶體佈局的第一個位置上,這是為了保證在多層繼承或多重繼承的情況下能以最高效率取到虛擬函式表。

當vprt位於物件記憶體最前面時,物件的地址即為虛擬函式指標地址。我們可以取得虛擬函式指標的地址:

我們執行程式碼出結果:

圖說C++物件模型:物件記憶體佈局詳解

我們強行把類物件的地址轉換為 int* 型別,取得了虛擬函式指標的地址。虛擬函式指標指向虛擬函式表,虛擬函式表中儲存的是一系列虛擬函式的地址,虛擬函式地址出現的順序與類中虛擬函式宣告的順序一致。對虛擬函式指標地址值,可以得到虛擬函式表的地址,也即是虛擬函式表第一個虛擬函式的地址:

  • 我們把虛表指標的值取出來: *(int*)(&b),它是一個地址,虛擬函式表的地址
  • 把虛擬函式表的地址強制轉換成 int* : ( int *) *( int* )( &b )
  • 再把它轉化成我們Fun指標型別 : (Fun )*(int *)*(int*)(&b)

這樣,我們就取得了類中的第一個虛擬函式,我們可以通過函式指標訪問它。
執行結果:
圖說C++物件模型:物件記憶體佈局詳解

同理,第二個虛擬函式setI()的地址為:

同樣可以通過函式指標訪問它,這裡留給讀者自己試驗。

到目前為止,我們知道了類中虛表指標vprt的由來,知道了虛擬函式表中的內容,以及如何通過指標訪問虛擬函式表。下面的文章中將常使用指標訪問物件記憶體來驗證我們的C++物件模型,以及討論在各種繼承情況下虛表指標的變化,先把這部分的內容消化完再接著看下面的內容。

4.物件模型概述

在C++中,有兩種資料成員(class data members):static 和nonstatic,以及三種類成員函式(class member functions):static、nonstatic和virtual:

圖說C++物件模型:物件記憶體佈局詳解

現在我們有一個類Base,它包含了上面這5中型別的資料或函式:

圖說C++物件模型:物件記憶體佈局詳解

那麼,這個類在記憶體中將被如何表示?5種資料都是連續存放的嗎?如何佈局才能支援C++多型? 我們的C++標準與編譯器將如何塑造出各種資料成員與成員函式呢?

4.1.簡單物件模型

說明:在下面出現的圖中,用藍色邊框框起來的內容在記憶體上是連續的。
這個模型非常地簡單粗暴。在該模型下,物件由一系列的指標組成,每一個指標都指向一個資料成員或成員函式,也即是說,每個資料成員和成員函式在類中所佔的大小是相同的,都為一個指標的大小。這樣有個好處——很容易算出物件的大小,不過賠上的是空間和執行期效率。想象一下,如果我們的Point3d類是這種模型,將會比C語言的struct多了許多空間來存放指向函式的指標,而且每次讀取類的資料成員,都需要通過再一次定址——又是時間上的消耗。
所以這種物件模型並沒有被用於實際產品上。

圖說C++物件模型:物件記憶體佈局詳解

4.2.表格驅動模型

這個模型在簡單物件模型的基礎上又新增一個間接層,它把類中的資料分成了兩個部分:資料部分與函式部分,並使用兩張表格,一張存放資料本身,一張存放函式的地址(也即函式比成員多一次定址),而類物件僅僅含有兩個指標,分別指向上面這兩個表。這樣看來,物件的大小是固定為兩個指標大小。這個模型也沒有用於實際應用於真正的C++編譯器上。

4.3.非繼承下的C++物件模型

概述:在此模型下,nonstatic 資料成員被置於每一個類物件中,而static資料成員被置於類物件之外。static與nonstatic函式也都放在類物件之外,而對於virtual 函式,則通過虛擬函式表+虛指標來支援,具體如下:

  • 每個類生成一個表格,稱為虛表(virtual table,簡稱vtbl)。虛表中存放著一堆指標,這些指標指向該類每一個虛擬函式。虛表中的函式地址將按宣告時的順序排列,不過當子類有多個過載函式時例外,後面會討論。
  • 每個類物件都擁有一個虛表指標(vptr),由編譯器為其生成。虛表指標的設定與重置皆由類的複製控制(也即是建構函式、解構函式、賦值操作符)來完成。vptr的位置為編譯器決定,傳統上它被放在所有顯示宣告的成員之後,不過現在許多編譯器把vptr放在一個類物件的最前端。關於資料成員佈局的內容,在後面會詳細分析。
    另外,虛擬函式表的前面設定了一個指向type_info的指標,用以支援RTTI(Run Time Type Identification,執行時型別識別)。RTTI是為多型而生成的資訊,包括物件繼承關係,物件本身的描述等,只有具有虛擬函式的物件在會生成。

在此模型下,Base的物件模型如圖:
圖說C++物件模型:物件記憶體佈局詳解

先在VS上驗證類物件的佈局:

圖說C++物件模型:物件記憶體佈局詳解

可見物件b含有一個vfptr,即vprt。並且只有nonstatic資料成員被放置於物件內。我們展開vfprt:

圖說C++物件模型:物件記憶體佈局詳解

vfptr中有兩個指標型別的資料(地址),第一個指向了Base類的解構函式,第二個指向了Base的虛擬函式print,順序與宣告順序相同。
這與上述的C++物件模型相符合。也可以通過程式碼來進行驗證:

圖說C++物件模型:物件記憶體佈局詳解

結果分析:

  • 通過 (int *)(&p)取得虛擬函式表的地址
  • type_info資訊的確存在於虛表的前一個位置。通過((int)(int*)(&p) – 1))取得type_infn資訊,併成功獲得類的名稱的Base
  • 虛擬函式表的第一個函式是解構函式。
  • 虛擬函式表的第二個函式是虛擬函式print(),取得地址後通過地址呼叫它(而非通過物件),驗證正確
  • 虛表指標的下一個位置為nonstatic資料成員baseI。
  • 可以看到,static成員函式的地址段位與虛表指標、baseI的地址段位不同。

好的,至此我們瞭解了非繼承下類物件五種資料在記憶體上的佈局,也知道了在每一個虛擬函式表前都有一個指標指向type_info,負責對RTTI的支援。而加入繼承後類物件在記憶體中該如何表示呢?

5.繼承下的C++物件模型

5.1.單繼承

如果我們定義了派生類

繼承類圖為:
圖說C++物件模型:物件記憶體佈局詳解

一個派生類如何在機器層面上塑造其父類的例項呢?在簡單物件模型中,可以在子類物件中為每個類子物件分配一個指標。如下圖:
圖說C++物件模型:物件記憶體佈局詳解

簡單物件模型的缺點就是因間接性導致的空間存取時間上的額外負擔,優點則是類的大小是固定的,基類的改動不會影響子類物件的大小。

在表格驅動物件模型中,我們可以為子類物件增加第三個指標:基類指標(bptr),基類指標指向指向一個基類表(base class table),同樣的,由於間接性導致了空間和存取時間上的額外負擔,優點則是無須改變子類物件本身就可以更改基類。表格驅動模型的圖就不再貼出來了。

在C++物件模型中,對於一般繼承(這個一般是相對於虛擬繼承而言),若子類重寫(overwrite)了父類的虛擬函式,則子類虛擬函式將覆蓋虛表中對應的父類虛擬函式(注意子類與父類擁有各自的一個虛擬函式表);若子類並無overwrite父類虛擬函式,而是宣告瞭自己新的虛擬函式,則該虛擬函式地址將擴充到虛擬函式表最後(在vs中無法通過監視看到擴充的結果,不過我們通過取地址的方法可以做到,子類新的虛擬函式確實在父類子物體的虛擬函式表末端)。而對於虛繼承,若子類overwrite父類虛擬函式,同樣地將覆蓋父類子物體中的虛擬函式表對應位置,而若子類宣告瞭自己新的虛擬函式,則編譯器將為子類增加一個新的虛表指標vptr,這與一般繼承不同,在後面再討論。

圖說C++物件模型:物件記憶體佈局詳解

我們使用程式碼來驗證以上模型

執行結果:
圖說C++物件模型:物件記憶體佈局詳解

這個結果與我們的物件模型符合。

5.2.多繼承

5.2.1一般的多重繼承(非菱形繼承)

單繼承中(一般繼承),子類會擴充套件父類的虛擬函式表。在多繼承中,子類含有多個父類的子物件,該往哪個父類的虛擬函式表擴充套件呢?當子類overwrite了父類的函式,需要覆蓋多個父類的虛擬函式表嗎?

  • 子類的虛擬函式被放在宣告的第一個基類的虛擬函式表中。
  • overwrite時,所有基類的print()函式都被子類的print()函式覆蓋。
  • 記憶體佈局中,父類按照其宣告順序排列。

其中第二點保證了父類指標指向子類物件時,總是能夠呼叫到真正的函式。

為了方便檢視,我們把程式碼都貼上過來

繼承類圖為:

圖說C++物件模型:物件記憶體佈局詳解

此時Drive_multyBase 的物件模型是這樣的:

圖說C++物件模型:物件記憶體佈局詳解

我們使用程式碼驗證:

執行結果:
圖說C++物件模型:物件記憶體佈局詳解

5.2.2 菱形繼承

菱形繼承也稱為鑽石型繼承或重複繼承,它指的是基類被某個派生類簡單重複繼承了多次。這樣,派生類物件中擁有多份基類例項(這會帶來一些問題)。為了方便敘述,我們不使用上面的程式碼了,而重新寫一個重複繼承的繼承層次:

圖說C++物件模型:物件記憶體佈局詳解

這時,根據單繼承,我們可以分析出B1,B2類繼承於B類時的記憶體佈局。又根據一般多繼承,我們可以分析出D類的記憶體佈局。我們可以得出D類子物件的記憶體佈局如下圖:
圖說C++物件模型:物件記憶體佈局詳解

D類物件記憶體佈局中,圖中青色表示b1類子物件例項,黃色表示b2類子物件例項,灰色表示D類子物件例項。從圖中可以看到,由於D類間接繼承了B類兩次,導致D類物件中含有兩個B類的資料成員ib,一個屬於來源B1類,一個來源B2類。這樣不僅增大了空間,更重要的是引起了程式歧義:

儘管我們可以通過明確指明呼叫路徑以消除二義性,但二義性的潛在性還沒有消除,我們可以通過虛繼承來使D類只擁有一個ib實體。

6.虛繼承

虛繼承解決了菱形繼承中最派生類擁有多個間接父類例項的情況。虛繼承的派生類的記憶體佈局與普通繼承很多不同,主要體現在:

  • 虛繼承的子類,如果本身定義了新的虛擬函式,則編譯器為其生成一個虛擬函式指標(vptr)以及一張虛擬函式表。該vptr位於物件記憶體最前面。
    • vs非虛繼承:直接擴充套件父類虛擬函式表。
  • 虛繼承的子類也單獨保留了父類的vprt與虛擬函式表。這部分內容接與子類內容以一個四位元組的0來分界。
  • 虛繼承的子類物件中,含有四位元組的虛表指標偏移值。

為了分析最後的菱形繼承,我們還是先從單虛繼承繼承開始。

6.1.虛基類表解析

在C++物件模型中,虛繼承而來的子類會生成一個隱藏的虛基類指標(vbptr),在Microsoft Visual C++中,虛基類表指標總是在虛擬函式表指標之後,因而,對某個類例項來說,如果它有虛基類指標,那麼虛基類指標可能在例項的0位元組偏移處(該類沒有vptr時,vbptr就處於類例項記憶體佈局的最前面,否則vptr處於類例項記憶體佈局的最前面),也可能在類例項的4位元組偏移處。
一個類的虛基類指標指向的虛基類表,與虛擬函式表一樣,虛基類表也由多個條目組成,條目中存放的是偏移值。第一個條目存放虛基類表指標(vbptr)所在地址到該類記憶體首地址的偏移值,由第一段的分析我們知道,這個偏移值為0(類沒有vptr)或者-4(類有虛擬函式,此時有vptr)。我們通過一張圖來更好地理解。
圖說C++物件模型:物件記憶體佈局詳解

圖說C++物件模型:物件記憶體佈局詳解

虛基類表的第二、第三…個條目依次為該類的最左虛繼承父類、次左虛繼承父類…的記憶體地址相對於虛基類表指標的偏移值,這點我們在下面會驗證。

6.2.簡單虛繼承

如果我們的B1類虛繼承於B類:

圖說C++物件模型:物件記憶體佈局詳解

根據我們前面對虛繼承的派生類的記憶體佈局的分析,B1類的物件模型應該是這樣的:
圖說C++物件模型:物件記憶體佈局詳解

我們通過指標訪問B1類物件的記憶體,以驗證上面的C++物件模型:

執行結果:

圖說C++物件模型:物件記憶體佈局詳解

這個結果與我們的C++物件模型圖完全符合。這時我們可以來分析一下虛表指標的第二個條目值12的具體來源了,回憶上文講到的:

第二、第三…個條目依次為該類的最左虛繼承父類、次左虛繼承父類…的記憶體地址相對於虛基類表指標的偏移值。

在我們的例子中,也就是B類例項記憶體地址相對於vbptr的偏移值,也即是:[4]-[1]的偏移值,結果即為12,從地址上也可以計算出來:007CFDFC-007CFDF4結果的十進位制數正是12。現在,我們對虛基類表的構成應該有了一個更好的理解。

6.3.虛擬菱形繼承

如果我們有如下繼承層次:

類圖如下所示:
圖說C++物件模型:物件記憶體佈局詳解

菱形虛擬繼承下,最派生類D類的物件模型又有不同的構成了。在D類物件的記憶體構成上,有以下幾點:

  • 在D類物件記憶體中,基類出現的順序是:先是B1(最左父類),然後是B2(次左父類),最後是B(虛祖父類)
  • D類物件的資料成員id放在B類前面,兩部分資料依舊以0來分隔。
  • 編譯器沒有為D類生成一個它自己的vptr,而是覆蓋並擴充套件了最左父類的虛基類表,與簡單繼承的物件模型相同。
  • 超類B的內容放到了D類物件記憶體佈局的最後。

菱形虛擬繼承下的C++物件模型為:

圖說C++物件模型:物件記憶體佈局詳解

下面使用程式碼加以驗證:

檢視執行結果:
圖說C++物件模型:物件記憶體佈局詳解

7.一些問題解答

7.1.C++封裝帶來的佈局成本是多大?

在C語言中,“資料”和“處理資料的操作(函式)”是分開來宣告的,也就是說,語言本身並沒有支援“資料和函式”之間的關聯性。
在C++中,我們通過類來將屬性與操作繫結在一起,稱為ADT,抽象資料結構。

C語言中使用struct(結構體)來封裝資料,使用函式來處理資料。舉個例子,如果我們定義了一個struct Point3如下:

為了列印這個Point3d,我們可以定義一個函式:

而在C++中,我們更傾向於定義一個Point3d類,以ADT來實現上面的操作:

看到這段程式碼,很多人第一個疑問可能是:加上了封裝,佈局成本增加了多少?答案是class Point3d並沒有增加成本。學過了C++物件模型,我們知道,Point3d類物件的記憶體中,只有三個資料成員。

上面的類宣告中,三個資料成員直接內含在每一個Point3d物件中,而成員函式雖然在類中宣告,卻不出現在類物件(object)之中,這些函式(non-inline)屬於類而不屬於類物件,只會為類產生唯一的函式例項。

所以,Point3d的封裝並沒有帶來任何空間或執行期的效率影響。而在下面這種情況下,C++的封裝額外成本才會顯示出來:

  • 虛擬函式機制(virtual function) , 用以支援執行期繫結,實現多型。
  • 虛基類 (virtual base class) ,虛繼承關係產生虛基類,用於在多重繼承下保證基類在子類中擁有唯一例項。

不僅如此,Point3d類資料成員的記憶體佈局與c語言的結構體Point3d成員記憶體佈局是相同的。C++中處在同一個訪問識別符號(指public、private、protected)下的宣告的資料成員,在記憶體中必定保證以其宣告順序出現。而處於不同訪問識別符號宣告下的成員則無此規定。對於Point3類來說,它的三個資料成員都處於private下,在記憶體中一起宣告順序出現。我們可以做下實驗:

執行結果:
圖說C++物件模型:物件記憶體佈局詳解

從結果可以看到,_x,_y,_z三個資料成員在記憶體中緊挨著。

總結一下:
不考慮虛擬函式與虛繼承,當資料都在同一個訪問識別符號下,C++的類與C語言的結構體在物件大小和記憶體佈局上是一致的,C++的封裝並沒有帶來空間時間上的影響。

7.2.下面這個空類構成的繼承層次中,每個類的大小是多少?

今有類如下:

輸出結果是:

圖說C++物件模型:物件記憶體佈局詳解

解析:

  • 編譯器為空類安插1位元組的char,以使該類物件在記憶體得以配置一個地址。
  • b1虛繼承於b,編譯器為其安插一個4位元組的虛基類表指標(32為機器),此時b1已不為空,編譯器不再為其安插1位元組的char(優化)。
  • b2同理。
  • d含有來自b1與b2兩個父類的兩個虛基類表指標。大小為8位元組。

打賞支援我寫出更多好文章,謝謝!

打賞作者

打賞支援我寫出更多好文章,謝謝!

任選一種支付方式

圖說C++物件模型:物件記憶體佈局詳解 圖說C++物件模型:物件記憶體佈局詳解

相關文章