《深度探索C++物件模型》讀書筆記

Estelle_Z發表於2020-12-14

原文連結:第3章 Data語意學

第三章 Data語意學

這一章主要進一步討論C++物件的記憶體佈局, 特別是在引入繼承, 虛擬函式, 多繼承, 虛繼承後對記憶體佈局的影響, 還包含編譯器對相關特性的實現方式和優化.

下面的程式碼執行於 Archlinux 4.18 x86_64, 編譯器是gcc 8, 使用gdb 8除錯.

不含資料成員的類物件

對於不存在繼承和虛擬函式的類, 沒有資料成員時, 其大小至少是1 byte, 以保證變數有唯一的地址. 當加上虛擬函式後, 由於有虛擬函式指標, 物件大小等於一個指標的大小, 32位系統中是4 bytes, 64位系統中是8 bytes. 看下面的程式碼:

struct Empty {};
struct VirtualEmpty
{
    virtual void f() {}
};

Empty a;
Empty b;

cout<<sizeof(Empty)<<endl; // 輸出為1
cout<<sizeof(VirtualEmpty)<<endl; // 輸出為8

cout<<&a<<' '<<&b<<endl; // 在輸出中可以看到b的地址比a的地址大一.

 但是, 當其作為基類時, 在某些情況下則不必遵循上面的要求, 可以在子類中將其優化掉, 節省所佔空間. 例如下面的情況:

struct Base {};
struct Derived : Base
{
    int64_t i;
};

cout<<sizeof(Base)<<endl; // 輸出為1
cout<<sizeof(Derived)<<endl // 輸出為8

顯然這裡沒有必要保留額外空間來表示基類物件. 上面說過, 為空物件保留空間的原因是保證其有唯一地址, 避免出現不同物件的地址相同的情形. 但是在這裡, 子類地址就可以作為父類地址, 不會出現不同物件地址相同的情形. 但是即使是繼承, 也有不能進行優化的情況:

  • 子類的第一個非靜態資料成員的型別和空基類相同.
  • 子類的第一個非靜態資料成員的基類型別和空基類相同.

不難看出, 這兩種情況下, 會有兩個空基類物件(父類物件和子類資料成員物件)連續出現, 如果優化掉, 將不能區別二者. 示例如下:

struct Base {};

struct Derived1 : Base // 情況一
{
    Base b;
    int64_t i;
}d1;

struct Derived2
{
    Base b;
};
struct Derived3 : Base
{
    Derived2 d2;
    int64_t i;
}d3;

cout<<sizeof(Derived1)<<endl; // 輸出為16, 基類物件和成員b各佔1 byte, 由於記憶體對齊補齊8 bytes
cout<<sizeof(Derived2)<<endl; // 輸出為1
cout<<sizeof(Derived3)<<endl; // 輸出為16, 基類物件和成員d2各佔1 byte, 由於記憶體對齊補齊8 bytes

cout<<&d1<<' '<<&d1.b<<endl; // 前者(基類物件地址)比後者小1
cout<<&d3<<' '<<&d3.d2.b<<endl; // 前者(基類物件地址)比後者小1

對於空類作為虛基類的情況, 同樣可以進行優化. 例如下面的程式碼:

struct Base {};
struct Derived1 : virtual Base {};
struct Derived2 : virtual Base {};
struct Derived3 : Derived1, Derived2 {};
struct Derived4 : Derived1, Derived2
{
    Base b;
}d4;

cout<<sizeof(Derived1)<<endl; // 輸出為8, vptr 佔 8 bytes
cout<<sizeof(Derived2)<<endl; // 輸出為8, vptr 佔 8 bytes
cout<<sizeof(Derived3)<<endl; // 輸出為16, 兩個 vptr 佔 16 bytes
cout<<sizeof(Derived4)<<endl; // 輸出為24, 兩個 vptr 佔 16 bytes, 一個資料成員 b 佔 1 bytes, 由於記憶體對齊使用了 8 bytes.

cout<<&d4<<endl; // 輸出為 0x55c6986ffe70
cout<<dynamic_cast<Base*>(&d4)<<endl; // 輸出為 0x55c6986ffe70
cout<<&(d4.b)<<endl; // 輸出為 0x55c6986ffe80

 

為了實現虛繼承, 類Derived1和Derived2包含一個指標. 而虛基類Base被優化掉了, 因此Derived3大小為16 bytes. 而Derived4中由於包含型別是Base的非靜態成員, 需要佔據8 bytes, 即Derived4大小為24 bytes. 注意這裡基類被優化了, 子類資料成員沒有被優化. 測試顯示, 即使這個成員不是第一個或最後一個, 編譯器仍然不會優化.

資料成員與記憶體佈局

雖然標準沒有規定非靜態資料成員在記憶體中的排列順序, 但是一般實現都是按照宣告順序排列. 而由於記憶體對齊的要求, 僅僅改變成員的宣告順序可能產生不同大小的物件, 例如下面的宣告:

struct Test1 // 大小為16 bytes
{
    int64_t i1;
    char c1; // c1 和 c2 被放置在一個字(16 bytes)中
    char c2;
};
struct Test2 // 大小為24 bytes
{
    char c1;
    int64_t i1;
    char c2;
};
struct Test3 // 大小為16 bytes
{
    int64_t i1;
    int32_t i2; // i2,c1,c2 被放置在一個字(16 bytes)中
    char c1;
    char c2;
};

 

由於計算機是以字(32位機為4 bytes, 64位機為8 bytes)為單位來讀寫, 因此記憶體對齊可以加快存取操作. 否則當一個變數跨字時, 讀取這個變數就需要兩次記憶體讀. 但是這可能會增加需要的記憶體空間, 這就需要程式設計師仔細安排變數順序, 以保證獲得最佳的空間利用率.

靜態成員與物件記憶體佈局無關, 這裡還是討論一下.

  • 對於普通類的靜態資料成員, 則具有獨立於物件的靜態生存期, 儲存在全域性資料段中.

  • 模板類的靜態資料成員如果沒有被顯式特化或例項化, 則在使用時會被隱式特化, 只有當特化/例項化後才是有效定義的. 有下面幾種情況, 而這幾種都可以歸到C++14引入的 variable template(變數模板), 參考cppreference.

    struct Test1
    {
        template<typename T> static T val; // 非模板類的模板靜態成員.
    };
    template<typename T> T Test1::val = 0;
    
    template<typename T>
    struct Test2
    {
        static T val; // 模板類的非模板靜態成員.
    };
    template<typename T> T Test2<T>::val = 0;
    
    template<typename T1>
    struct Test3
    {
        template<typename T2> static std::pair<T1, T2> val; // 模板類的模板靜態成員.
    };
    template<typename T1>
    template<typename T2>
    std::pair<T1, T2> Test2<T1>::val = std::make_pair(T1(1), T2(2));
    
    auto var = Test3<int>::val<float>; // 即pair<int, float>(1, 2)

    資料成員的存取

    靜態資料成員

    對靜態成員, 通過物件或物件指標訪問和通過類名訪問沒有區別, 編譯器一般會將二者統一為相同形式. 類成員指標不能指向靜態成員, 因為對靜態成員取地址得到的是一個該成員的指標. 如:

class A
{
public:
    static int x;
};
&A::x; // 其型別是 int*

 

因為類靜態成員都是儲存在全域性資料段中, 如果不同類具有相同名字的靜態成員, 就需要保證不會發生名稱衝突. 編譯器的解決方法是對每個靜態資料成員編碼(這種操作稱為name-mangling), 以得到一個獨一無二的名稱.

非靜態資料成員

不存在虛基類時, 通過物件名或物件指標訪問非靜態資料成員沒有區別. 存在虛基類時, 通過物件指標訪問非靜態資料成員需要在執行時才能確定, 因為無法確定指標所指物件的實際型別, 也就不能判斷物件的記憶體佈局, 也就不知道物件中該資料成員的偏移, 而普通繼承的類物件的記憶體佈局在編譯時就可以決定.

繼承對物件佈局的影響

單繼承 

最簡單的一種情況, 單繼承不會修改父類的記憶體佈局, 例如父類由於記憶體對齊產生的額外空間在子類中不會被消除, 而是保持原樣. 所以下面的程式碼中, 子類大小是24 bytes, 而不是16 bytes.

struct Base // 16 bytes
{
    int64_t i1;
    char c1;
};
struct Derived : Base // 24 bytes
{
    char c2;
};

其原因是如果消除了這些額外空間, 將子類物件賦值給父類物件時就可能會在父類物件的額外空間位置賦值, 這改變了程式的語義, 顯然是不合適的.

加上多型

為了支援動態繫結, 編譯器需要在物件中新增虛表指標(vptr), 指向虛表. 虛表中包含類的型別資訊和虛擬函式指標, 值得注意的是, vptr並不是指向虛表的起始地址, 很多時候該地址之前會儲存著物件的型別資訊,程式通過此型別資訊實現RTTI. 而vptr初值的設定和其所佔空間的回收, 則分別由建構函式和解構函式負責, 編譯器自動在其中插入相應程式碼. 這是多型帶來的空間負擔和時間負擔.

那麼vptr放在什麼位置呢? 這是由編譯器決定的, gcc將其放在物件頭部, 這導致物件不能相容C語言中的struct, 但是在多重繼承中, 通過類成員指標訪問虛擬函式會更容易實現. 如果放在物件末尾則可以保證相容性, 但是就需要在執行期間獲得各個vptr在物件中的偏移, 在多重繼承中尤其會增加額外負擔.

多重繼承

標準並沒有規定不同基類在佈局中的順序, 但是大多數實現按照繼承宣告順序安排. 多重繼承給程式帶來了這些負擔:

  • 將子類地址賦值給基類指標變數時, 如果是宣告中的第一個基類, 二者地址相等, 可以直接賦值. 否則, 需要加上一個偏移量, 已獲得對應物件的地址.

  • 上面的直接加偏移並不能保證正確性, 設想子類指標值為0, 直接加上偏移後指向的是一個內容未知的地址. 正確做法應該是將0值賦給基類指標變數. 因此, 需要先判斷基類指標是否為0, 再做處理. 而對於引用, 雖然其底層是指標, 但是不需要檢查是否為0, 因為引用必須要繫結到一個有效地址, 不可能為0.

虛擬繼承

主要問題是如何實現只有一個虛擬基類. 主流方案是將虛擬基類作為共享部分, 其他類通過指標等方式指向虛擬基類, 訪問時需要通過指標或其他方式獲得虛擬基類的地址. gcc的做法是將虛基類放在物件末尾, 在虛表中新增一項, 記錄基類物件在物件中的偏移, 從而獲得其地址. 我們可以通過gdb除錯來看看具體情況.

struct B
{
    int64_t i1 = 1;
    virtual void f()
    {
        cout<<"B::f() called\n";
    }
};
struct D1 : virtual B
{
    int64_t i2 = 2;
};
struct D2 : virtual B
{
    int64_t i3 = 3;
};

struct D3 : D1, D2
{
    int64_t i4 = 4;
}d3;

for(int i = 0 ; i < sizeof(d3)/8; ++i)
    cout<<"d3["<<i<<"] = 0x"<<std::hex<<*((int64_t*)&d3 + i)<<endl;

首先用g++編譯, 載入gdb中

首先用g++編譯, 載入gdb中

# g++ main.cc -g
# gdb a.out

之後, 設定斷點, 執行程式, 再通過下面的命令檢視物件d3的虛表.

(gdb) p d3
$2 = {<D1> = {<B> = {_vptr.B = 0x555555557c58 <vtable for D3+72>, i1 = 1}, _vptr.D1 = 0x555555557c28 <vtable for D3+24>, i2 = 2}, <D2> = { _vptr.D2 = 0x555555557c40 <vtable for D3+48>, i3 = 3}, i4 = 4}
(gdb) p /a *((void**)0x555555557c28-3)@10
$4 = {0x28,
      0x0,
      0x555555557d20 <_ZTI2D3>,
      0x18,
      0xfffffffffffffff0,
      0x555555557d20 <_ZTI2D3>,
      0x0,
      0xffffffffffffffd8,
      0x555555557d20 <_ZTI2D3>,
      0x555555555446 <B::f()>}

可以發現, _vptr.D1等於*(int64_t *)&d3, _vptr.D2等於*((int64_t *)&d3 + 2), _vptr.B等於*((int64_t *)&d3 + 5). 顯然分別是各個物件的vptr的值. gdb的第二個命令是列印部分虛表內容, -3指定起始位置, 10指定長度. 可見_vptr.D1指向輸出的第四個, _vptr.D2指向輸出的第七個, 二者指向位置的地址減3即為對應物件和基類物件的偏移. 同樣可以看到前一個是當前物件的型別資訊. 如果在C++中直接訪問虛表, 可以用下面的程式碼, 這和上面用gdb列印虛表等效:

int64_t *vptr = (int64_t *)*(int64_t *)&d3; // D1的虛表地址.
for(int i = -3; i < 7; ++i)
    cout<<"_vptr.D1["<<i<<"] = 0x"<<std::hex<<*(vptr+i)<<endl;

g++ 版本大於等於 8.0 的還可以用下面的方法直接匯出類的虛表和記憶體佈局, 這些會被儲存在檔案 out_file 中, 其中包含所有涉及的類資訊, 例如 exception 類等, 不僅僅是程式設計師自己定義的類.

g++ -fdump-lang-class=out_file src.cpp

 

相關文章