天天看點

圖說C++對象模型:對象記憶體布局詳解

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++中虛函數的作用主要是為了實作多态機制。多态,簡單來說,是指在繼承層次中,父類的指針可以具有多種形态——當它指向某個子類對象時,通過它能夠調用到子類的函數,而非父類的函數。

class Base {     virtual void print(void);    }
class Drive1 :public Base{    virtual void print(void);    }
class Drive2 :public Base{    virtual void print(void);    }
           
Base * ptr1 = new Base; 
Base * ptr2 = new Drive1;  
Base * ptr3 = new Drive2;
           
ptr1->print(); //調用Base::print()
prt2->print();//調用Drive1::print()
prt3->print();//調用Drive2::print()
           
圖說C++對象模型:對象記憶體布局詳解

這是一種運作期多态,即父類指針唯有在程式運作時才能知道所指的真正類型是什麼。這種運作期決議,是通過虛函數表來實作的。

3.2.使用指針通路虛表

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

class Base
{
public:
 
    Base(int i) :baseI(i){};

    virtual void print(void){ cout << "調用了虛函數Base::print()"; }

    virtual void setI(){cout<<"調用了虛函數Base::setI()";}

    virtual ~Base(){}
 
private:
 
    int baseI;

};
           
圖說C++對象模型:對象記憶體布局詳解

當一個類本身定義了虛函數,或其父類有虛函數時,為了支援多态機制,編譯器将為該類添加一個虛函數指針(vptr)。虛函數指針一般都放在對象記憶體布局的第一個位置上,這是為了保證在多層繼承或多重繼承的情況下能以最高效率取到虛函數表。

當vprt位于對象記憶體最前面時,對象的位址即為虛函數指針位址。我們可以取得虛函數指針的位址:

Base b(1000);
int * vptrAdree = (int *)(&b);  
cout << "虛函數指針(vprt)的位址是:\t"<<vptrAdree << endl;
           

我們運作代碼出結果:

圖說C++對象模型:對象記憶體布局詳解

我們強行把類對象的位址轉換為 int* 類型,取得了虛函數指針的位址。虛函數指針指向虛函數表,虛函數表中存儲的是一系列虛函數的位址,虛函數位址出現的順序與類中虛函數聲明的順序一緻。對虛函數指針位址值,可以得到虛函數表的位址,也即是虛函數表第一個虛函數的位址:

typedef void(*Fun)(void);
    Fun vfunc = (Fun)*( (int *)*(int*)(&b));
    cout << "第一個虛函數的位址是:" << (int *)*(int*)(&b) << endl;
    cout << "通過位址,調用虛函數Base::print():";
    vfunc();
           
  • 我們把虛表指針的值取出來: *(int*)(&b),它是一個位址,虛函數表的位址
  • 把虛函數表的位址強制轉換成 int* : ( int *) *( int* )( &b )
  • 再把它轉化成我們Fun指針類型 : (Fun )*(int *)*(int*)(&b)

這樣,我們就取得了類中的第一個虛函數,我們可以通過函數指針通路它。

運作結果:

圖說C++對象模型:對象記憶體布局詳解

同理,第二個虛函數setI()的位址為:

(int * )(*(int*)(&b)+1)
           

同樣可以通過函數指針通路它,這裡留給讀者自己試驗。

到目前為止,我們知道了類中虛表指針vprt的由來,知道了虛函數表中的内容,以及如何通過指針通路虛函數表。下面的文章中将常使用指針通路對象記憶體來驗證我們的C++對象模型,以及讨論在各種繼承情況下虛表指針的變化,先把這部分的内容消化完再接着看下面的内容。

4.對象模型概述

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

圖說C++對象模型:對象記憶體布局詳解

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

class Base
{
public:
 
    Base(int i) :baseI(i){};
  
    int getI(){ return baseI; }
 
    static void countI(){};
 
    virtual ~Base(){}

    virtual void print(void){ cout << "Base::print()"; }

    
 
private:
 
    int baseI;
 
    static int baseS;
};
           
圖說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上驗證類對象的布局:

Base b(1000);
           
圖說C++對象模型:對象記憶體布局詳解

可見對象b含有一個vfptr,即vprt。并且隻有nonstatic資料成員被放置于對象内。我們展開vfprt:

圖說C++對象模型:對象記憶體布局詳解

vfptr中有兩個指針類型的資料(位址),第一個指向了Base類的析構函數,第二個指向了Base的虛函數print,順序與聲明順序相同。

這與上述的C++對象模型相符合。也可以通過代碼來進行驗證:

void testBase( Base&p)
{
    cout << "對象的記憶體起始位址:" << &p << endl;
    cout << "type_info資訊:" << endl;
    RTTICompleteObjectLocator str = *((RTTICompleteObjectLocator*)*((int*)*(int*)(&p) - 1));
 
 
    string classname(str.pTypeDescriptor->name);
    classname = classname.substr(4, classname.find("@@") - 4);
    cout <<  "根據type_info資訊輸出類名:"<< classname << endl;
 
    cout << "虛函數表位址:" << (int *)(&p) << endl;
 
    //驗證虛表
    cout << "虛函數表第一個函數的位址:" << (int *)*((int*)(&p)) << endl;
    cout << "析構函數的位址:" << (int* )*(int *)*((int*)(&p)) << endl;
    cout << "虛函數表中,第二個虛函數即print()的位址:" << ((int*)*(int*)(&p) + 1) << endl;
 
    //通過位址調用虛函數print()
    typedef void(*Fun)(void);
    Fun IsPrint=(Fun)* ((int*)*(int*)(&p) + 1);
    cout << endl;
    cout<<"調用了虛函數";
    IsPrint(); //若位址正确,則調用了Base類的虛函數print()
    cout << endl;
 
    //輸入static函數的位址
    p.countI();//先調用函數以産生一個執行個體
    cout << "static函數countI()的位址:" << p.countI << endl;
 
    //驗證nonstatic資料成員
    cout << "推測nonstatic資料成員baseI的位址:" << (int *)(&p) + 1 << endl;
    cout << "根據推測出的位址,輸出該位址的值:" << *((int *)(&p) + 1) << endl;
    cout << "Base::getI():" << p.getI() << endl;
 
}
           
Base b(1000);
testBase(b);
           
圖說C++對象模型:對象記憶體布局詳解

結果分析:

  • 通過 (int *)(&p)取得虛函數表的位址
  • type_info資訊的确存在于虛表的前一個位置。通過((int)(int*)(&p) - 1))取得type_infn資訊,并成功獲得類的名稱的Base
  • 虛函數表的第一個函數是析構函數。
  • 虛函數表的第二個函數是虛函數print(),取得位址後通過位址調用它(而非通過對象),驗證正确
  • 虛表指針的下一個位置為nonstatic資料成員baseI。
  • 可以看到,static成員函數的位址段位與虛表指針、baseI的位址段位不同。

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

5.繼承下的C++對象模型

5.1.單繼承

如果我們定義了派生類

class Derive : public Base
{
public:
    Derive(int d) :Base(1000),      DeriveI(d){};
    //overwrite父類虛函數
    virtual void print(void){ cout << "Drive::Drive_print()" ; }
    // Derive聲明的新的虛函數
        virtual void Drive_print(){ cout << "Drive::Drive_print()" ; }
    virtual ~Derive(){}
private:
    int DeriveI;
};
           

繼承類圖為:

圖說C++對象模型:對象記憶體布局詳解

一個派生類如何在機器層面上塑造其父類的執行個體呢?在簡單對象模型中,可以在子類對象中為每個基類子對象配置設定一個指針。如下圖:

圖說C++對象模型:對象記憶體布局詳解

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

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

在C++對象模型中,對于一般繼承(這個一般是相對于虛拟繼承而言),若子類重寫(overwrite)了父類的虛函數,則子類虛函數将覆寫虛表中對應的父類虛函數(注意子類與父類擁有各自的一個虛函數表);若子類并無overwrite父類虛函數,而是聲明了自己新的虛函數,則該虛函數位址将擴充到虛函數表最後(在vs中無法通過監視看到擴充的結果,不過我們通過取位址的方法可以做到,子類新的虛函數确實在父類子物體的虛函數表末端)。而對于虛繼承,若子類overwrite父類虛函數,同樣地将覆寫父類子物體中的虛函數表對應位置,而若子類聲明了自己新的虛函數,則編譯器将為子類增加一個新的虛表指針vptr,這與一般繼承不同,在後面再讨論。

圖說C++對象模型:對象記憶體布局詳解

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

typedef void(*Fun)(void);
 
int main()
{
    Derive d(2000);
    //[0]
    cout << "[0]Base::vptr";
    cout << "\t位址:" << (int *)(&d) << endl;
        //vprt[0]
        cout << "  [0]";
        Fun fun1 = (Fun)*((int *)*((int *)(&d)));
        fun1();
        cout << "\t位址:\t" << *((int *)*((int *)(&d))) << endl;
 
        //vprt[1]析構函數無法通過位址調用,故手動輸出
        cout << "  [1]" << "Derive::~Derive" << endl;
 
        //vprt[2]
        cout << "  [2]";
        Fun fun2 = (Fun)*((int *)*((int *)(&d)) + 2);
        fun2();
        cout << "\t位址:\t" << *((int *)*((int *)(&d)) + 2) << endl;
    //[1]
    cout << "[2]Base::baseI=" << *(int*)((int *)(&d) + 1);
    cout << "\t位址:" << (int *)(&d) + 1;
    cout << endl;
    //[2]
    cout << "[2]Derive::DeriveI=" << *(int*)((int *)(&d) + 2);
    cout << "\t位址:" << (int *)(&d) + 2;
    cout << endl;
    getchar();
}
           
圖說C++對象模型:對象記憶體布局詳解

這個結果與我們的對象模型符合。

5.2.多繼承

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

單繼承中(一般繼承),子類會擴充父類的虛函數表。在多繼承中,子類含有多個父類的子對象,該往哪個父類的虛函數表擴充呢?當子類overwrite了父類的函數,需要覆寫多個父類的虛函數表嗎?

  • 子類的虛函數被放在聲明的第一個基類的虛函數表中。
  • overwrite時,所有基類的print()函數都被子類的print()函數覆寫。
  • 記憶體布局中,父類按照其聲明順序排列。

其中第二點保證了父類指針指向子類對象時,總是能夠調用到真正的函數。

為了友善檢視,我們把代碼都粘貼過來

class Base
{
public:
 
    Base(int i) :baseI(i){};
    virtual ~Base(){}
 
    int getI(){ return baseI; }
 
    static void countI(){};
 
    virtual void print(void){ cout << "Base::print()"; }
 
private:
 
    int baseI;
 
    static int baseS;
};
class Base_2
{
public:
    Base_2(int i) :base2I(i){};

    virtual ~Base_2(){}

    int getI(){ return base2I; }

    static void countI(){};

    virtual void print(void){ cout << "Base_2::print()"; }
 
private:
 
    int base2I;
 
    static int base2S;
};
 
class Drive_multyBase :public Base, public Base_2
{
public:

    Drive_multyBase(int d) :Base(1000), Base_2(2000) ,Drive_multyBaseI(d){};
 
    virtual void print(void){ cout << "Drive_multyBase::print" ; }
 
    virtual void Drive_print(){ cout << "Drive_multyBase::Drive_print" ; }
 
private:
    int Drive_multyBaseI;
};
           
圖說C++對象模型:對象記憶體布局詳解

此時Drive_multyBase 的對象模型是這樣的:

圖說C++對象模型:對象記憶體布局詳解

我們使用代碼驗證:

typedef void(*Fun)(void);
 
int main()
{
    Drive_multyBase d(3000);
    //[0]
    cout << "[0]Base::vptr";
    cout << "\t位址:" << (int *)(&d) << endl;
 
        //vprt[0]析構函數無法通過位址調用,故手動輸出
        cout << "  [0]" << "Derive::~Derive" << endl;
 
        //vprt[1]
        cout << "  [1]";
        Fun fun1 = (Fun)*((int *)*((int *)(&d))+1);
        fun1();
        cout << "\t位址:\t" << *((int *)*((int *)(&d))+1) << endl;
 
 
        //vprt[2]
        cout << "  [2]";
        Fun fun2 = (Fun)*((int *)*((int *)(&d)) + 2);
        fun2();
        cout << "\t位址:\t" << *((int *)*((int *)(&d)) + 2) << endl;
 
 
    //[1]
    cout << "[1]Base::baseI=" << *(int*)((int *)(&d) + 1);
    cout << "\t位址:" << (int *)(&d) + 1;
    cout << endl;
 
 
    //[2]
    cout << "[2]Base_::vptr";
    cout << "\t位址:" << (int *)(&d)+2 << endl;
 
        //vprt[0]析構函數無法通過位址調用,故手動輸出
        cout << "  [0]" << "Drive_multyBase::~Derive" << endl;
 
        //vprt[1]
        cout << "  [1]";
        Fun fun4 = (Fun)*((int *)*((int *)(&d))+1);
        fun4();
        cout << "\t位址:\t" << *((int *)*((int *)(&d))+1) << endl;
 
    //[3]
    cout << "[3]Base_2::base2I=" << *(int*)((int *)(&d) + 3);
    cout << "\t位址:" << (int *)(&d) + 3;
    cout << endl;
 
    //[4]
    cout << "[4]Drive_multyBase::Drive_multyBaseI=" << *(int*)((int *)(&d) + 4);
    cout << "\t位址:" << (int *)(&d) + 4;
    cout << endl;
 
    getchar();
}
           
圖說C++對象模型:對象記憶體布局詳解

5.2.2 菱形繼承

菱形繼承也稱為鑽石型繼承或重複繼承,它指的是基類被某個派生類簡單重複繼承了多次。這樣,派生類對象中擁有多份基類執行個體(這會帶來一些問題)。為了友善叙述,我們不使用上面的代碼了,而重新寫一個重複繼承的繼承層次:

圖說C++對象模型:對象記憶體布局詳解
class B
 
{
 
public:
 
    int ib;
 
public:
 
    B(int i=1) :ib(i){}
 
    virtual void f() { cout << "B::f()" << endl; }
 
    virtual void Bf() { cout << "B::Bf()" << endl; }
 
};
 
class B1 : public B
 
{
 
public:
 
    int ib1;
 
public:
 
    B1(int i = 100 ) :ib1(i) {}
 
    virtual void f() { cout << "B1::f()" << endl; }
 
    virtual void f1() { cout << "B1::f1()" << endl; }
 
    virtual void Bf1() { cout << "B1::Bf1()" << endl; }
 
 
 
};
 
class B2 : public B
 
{
 
public:
 
    int ib2;
 
public:
 
    B2(int i = 1000) :ib2(i) {}
 
    virtual void f() { cout << "B2::f()" << endl; }
 
    virtual void f2() { cout << "B2::f2()" << endl; }
 
    virtual void Bf2() { cout << "B2::Bf2()" << endl; }
 
};
 
 
class D : public B1, public B2
 
{
 
public:
 
    int id;
 
 
 
public:
 
    D(int i= 10000) :id(i){}
 
    virtual void f() { cout << "D::f()" << endl; }
 
    virtual void f1() { cout << "D::f1()" << endl; }
 
    virtual void f2() { cout << "D::f2()" << endl; }
 
    virtual void Df() { cout << "D::Df()" << endl; }
 
};
           

這時,根據單繼承,我們可以分析出B1,B2類繼承于B類時的記憶體布局。又根據一般多繼承,我們可以分析出D類的記憶體布局。我們可以得出D類子對象的記憶體布局如下圖:

圖說C++對象模型:對象記憶體布局詳解

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

D d;
 
d.ib =1 ;               //二義性錯誤,調用的是B1的ib還是B2的ib?
 
d.B1::ib = 1;           //正确
 
d.B2::ib = 1;           //正确
           

盡管我們可以通過明确指明調用路徑以消除二義性,但二義性的潛在性還沒有消除,我們可以通過虛繼承來使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類:

//類的内容與前面相同
class B{...}
class B1 : virtual public B

           
圖說C++對象模型:對象記憶體布局詳解

根據我們前面對虛繼承的派生類的記憶體布局的分析,B1類的對象模型應該是這樣的:

圖說C++對象模型:對象記憶體布局詳解

我們通過指針通路B1類對象的記憶體,以驗證上面的C++對象模型:

int main()
{
B1 a;
    cout <<"B1對象記憶體大小為:"<< sizeof(a) << endl;
 
    //取得B1的虛函數表
    cout << "[0]B1::vptr";
    cout << "\t位址:" << (int *)(&a)<< endl;
 
    //輸出虛表B1::vptr中的函數
    for (int i = 0; i<2;++ i)
    {
        cout << "  [" << i << "]";
        Fun fun1 = (Fun)*((int *)*(int *)(&a) + i);
        fun1();
        cout << "\t位址:\t" << *((int *)*(int *)(&a) + i) << endl;
    }
 
    //[1]
    cout << "[1]vbptr "  ;
    cout<<"\t位址:" << (int *)(&a) + 1<<endl;  //虛表指針的位址
    //輸出虛基類指針條目所指的内容
    for (int i = 0; i < 2; i++)
    {
        cout << "  [" << i << "]";
 
        cout << *(int *)((int *)*((int *)(&a) + 1) + i);
 
        cout << endl;
    }
 
 
    //[2]
    cout << "[2]B1::ib1=" << *(int*)((int *)(&a) + 2);
    cout << "\t位址:" << (int *)(&a) + 2;
    cout << endl;
 
    //[3]
    cout << "[3]值=" << *(int*)((int *)(&a) + 3);
    cout << "\t\t位址:" << (int *)(&a) + 3;
    cout << endl;
 
    //[4]
    cout << "[4]B::vptr";
    cout << "\t位址:" << (int *)(&a) +3<< endl;
 
    //輸出B::vptr中的虛函數
    for (int i = 0; i<2; ++i)
    {
        cout << "  [" << i << "]";
        Fun fun1 = (Fun)*((int *)*((int *)(&a) + 4) + i);
        fun1();
        cout << "\t位址:\t" << *((int *)*((int *)(&a) + 4) + i) << endl;
    }
 
    //[5]
    cout << "[5]B::ib=" << *(int*)((int *)(&a) + 5);
    cout << "\t位址: " << (int *)(&a) + 5;
    cout << endl;
           
圖說C++對象模型:對象記憶體布局詳解

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

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

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

6.3.虛拟菱形繼承

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

class B{...}
class B1: virtual public  B{...}
class B2: virtual public  B{...}
class D : public B1,public B2{...}
           

類圖如下所示:

圖說C++對象模型:對象記憶體布局詳解

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

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

菱形虛拟繼承下的C++對象模型為:

圖說C++對象模型:對象記憶體布局詳解

下面使用代碼加以驗證:

int main()
{
    D d;
    cout << "D對象記憶體大小為:" << sizeof(d) << endl;
 
    //取得B1的虛函數表
    cout << "[0]B1::vptr";
    cout << "\t位址:" << (int *)(&d) << endl;
 
    //輸出虛表B1::vptr中的函數
    for (int i = 0; i<3; ++i)
    {
        cout << "  [" << i << "]";
        Fun fun1 = (Fun)*((int *)*(int *)(&d) + i);
        fun1();
        cout << "\t位址:\t" << *((int *)*(int *)(&d) + i) << endl;
    }
 
    //[1]
    cout << "[1]B1::vbptr ";
    cout << "\t位址:" << (int *)(&d) + 1 << endl;  //虛表指針的位址
    //輸出虛基類指針條目所指的内容
    for (int i = 0; i < 2; i++)
    {
        cout << "  [" << i << "]";
 
        cout << *(int *)((int *)*((int *)(&d) + 1) + i);
 
        cout << endl;
    }
 
 
    //[2]
    cout << "[2]B1::ib1=" << *(int*)((int *)(&d) + 2);
    cout << "\t位址:" << (int *)(&d) + 2;
    cout << endl;
 
    //[3]
    cout << "[3]B2::vptr";
    cout << "\t位址:" << (int *)(&d) + 3 << endl;
 
    //輸出B2::vptr中的虛函數
    for (int i = 0; i<2; ++i)
    {
        cout << "  [" << i << "]";
        Fun fun1 = (Fun)*((int *)*((int *)(&d) + 3) + i);
        fun1();
        cout << "\t位址:\t" << *((int *)*((int *)(&d) + 3) + i) << endl;
    }
 
    //[4]
    cout << "[4]B2::vbptr ";
    cout << "\t位址:" << (int *)(&d) + 4 << endl;  //虛表指針的位址
    //輸出虛基類指針條目所指的内容
    for (int i = 0; i < 2; i++)
    {
        cout << "  [" << i << "]";
 
        cout << *(int *)((int *)*((int *)(&d) + 4) + i);
 
        cout << endl;
    }
 
    //[5]
    cout << "[5]B2::ib2=" << *(int*)((int *)(&d) + 5);
    cout << "\t位址: " << (int *)(&d) + 5;
    cout << endl;
 
    //[6]
    cout << "[6]D::id=" << *(int*)((int *)(&d) + 6);
    cout << "\t位址: " << (int *)(&d) + 6;
    cout << endl;
 
    //[7]
    cout << "[7]值=" << *(int*)((int *)(&d) + 7);
    cout << "\t\t位址:" << (int *)(&d) + 7;
    cout << endl;
 
    //間接父類
    //[8]
    cout << "[8]B::vptr";
    cout << "\t位址:" << (int *)(&d) + 8 << endl;
 
    //輸出B::vptr中的虛函數
    for (int i = 0; i<2; ++i)
    {
        cout << "  [" << i << "]";
        Fun fun1 = (Fun)*((int *)*((int *)(&d) + 8) + i);
        fun1();
        cout << "\t位址:\t" << *((int *)*((int *)(&d) + 8) + i) << endl;
    }
 
    //[9]
    cout << "[9]B::id=" << *(int*)((int *)(&d) + 9);
    cout << "\t位址: " << (int *)(&d) +9;
    cout << endl;
 
    getchar();
}
 
           

檢視運作結果:

圖說C++對象模型:對象記憶體布局詳解

7.一些問題解答

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

在C語言中,“資料”和“處理資料的操作(函數)”是分開來聲明的,也就是說,語言本身并沒有支援“資料和函數”之間的關聯性。

在C++中,我們通過類來将屬性與操作綁定在一起,稱為ADT,抽象資料結構。

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

typedef struct Point3
{
    float x;
    float y;
    float z;
} Point3;
           

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

void Point3d_print(const Point3d *pd)
{
    printf("(%f,%f,%f)",pd->x,pd->y,pd_z);
}
           

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

class Point3d
{
    public:
        point3d (float x = 0.0,float y = 0.0,float z = 0.0)
            : _x(x), _y(y), _z(z){}

        float x() const {return _x;}
        float y() const {return _y;}
        float z() const {return _z;}
    
    private:
        float _x;
        float _y;
        float _z;
};

    inline ostream&
    operator<<(ostream &os, const Point3d &pt)
    {
        os<<"("<<pr.x()<<","
            <<pt.y()<<","<<pt.z()<<")";
    }

           

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

上面的類聲明中,三個資料成員直接内含在每一個Point3d對象中,而成員函數雖然在類中聲明,卻不出現在類對象(object)之中,這些函數(non-inline)屬于類而不屬于類對象,隻會為類産生唯一的函數執行個體。

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

  • 虛函數機制(virtual function) , 用以支援執行期綁定,實作多态。
  • 虛基類 (virtual base class) ,虛繼承關系産生虛基類,用于在多重繼承下保證基類在子類中擁有唯一執行個體。

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

void TestPoint3Member(const Point3d& p)
{
 
    cout << "推測_x的位址是:" << (float *) (&p) << endl;
    cout << "推測_y的位址是:" << (float *) (&p) + 1 << endl;
    cout << "推測_z的位址是:" << (float *) (&p) + 2 << endl;
 
    cout << "根據推測出的位址輸出_x的值:" << *((float *)(&p)) << endl;
    cout << "根據推測出的位址輸出_y的值:" << *((float *)(&p)+1) << endl;
    cout << "根據推測出的位址輸出_z的值:" << *((float *)(&p)+2) << endl;
 
}
           
//測試代碼
    Point3d a(1,2,3);
    TestPoint3Member(a);
           
圖說C++對象模型:對象記憶體布局詳解

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

總結一下:

不考慮虛函數與虛繼承,當資料都在同一個通路辨別符下,C++的類與C語言的結構體在對象大小和記憶體布局上是一緻的,C++的封裝并沒有帶來空間時間上的影響。

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

今有類如下:

class B{};
class B1 :public virtual  B{};
class B2 :public virtual  B{};
class D : public B1, public B2{};

int main()
{
    B b;
    B1 b1;
    B2 b2;
    D d;
    cout << "sizeof(b)=" << sizeof(b)<<endl;
    cout << "sizeof(b1)=" << sizeof(b1) << endl;
    cout << "sizeof(b2)=" << sizeof(b2) << endl;
    cout << "sizeof(d)=" << sizeof(d) << endl;
    getchar();
}
           

輸出結果是:

圖說C++對象模型:對象記憶體布局詳解

解析:

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

轉載請注明原出處:http://www.cnblogs.com/QG-whz/p/4909359.html

   

作者:melonstreet

出處:https://www.cnblogs.com/QG-whz/

本文版權歸作者和部落格園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連接配接,否則保留追究法律責任的權利。