歡迎來到Linux教程網
Linux教程網
Linux教程網
Linux教程網
您现在的位置: Linux教程網 >> UnixLinux >  >> Linux編程 >> Linux編程

C++對象模型詳解

前言

文章較長,而且內容相對來說比較枯燥,希望對C++對象的內存布局、虛表指針、虛基類指針等有深入了解的朋友可以慢慢看。
本文的結論都在VS2013上得到驗證。不同的編譯器在內存布局的細節上可能有所不同。
文章如果有解釋不清、解釋不通或疏漏的地方,懇請指出。

何為C++對象模型?

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

有兩個概念可以解釋C++對象模型:

  1. 語言中直接支持面向對象程序設計的部分。
  2. 對於各種支持的底層實現機制。

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

深度探索C++對象模型 PDF中文清晰版  http://www.linuxidc.com/Linux/2012-03/56159.htm

文章內容簡介

這篇文章主要來討論C++對象在內存中的布局,屬於第二個概念的研究范疇。而C++直接支持面向對象程序設計部分則不多講。文章主要內容如下:

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

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

理解虛函數表

多態與虛表

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

class Base {     virtualvoid print(void);    }
class Drive1 :public Base{    virtualvoid print(void);    }
class Drive2 :public Base{    virtualvoid 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()

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

使用指針訪問虛表

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

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

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

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

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

};

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

當vprt位於對象內存最前面時,對象的地址即為虛函數指針地址。我們可以取得虛函數指針的地址:

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

我們運行代碼出結果:

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

    typedefvoid(*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)

這樣,我們就取得了類中的第一個虛函數,我們可以通過函數指針訪問它。
運行結果:

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

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

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

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

對象模型概述

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

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

class Base
{
public:
 
    Base(int i) :baseI(i){};
  
    int getI(){ return baseI; }
 
    staticvoid countI(){};
 
    virtualvoid print(void){ cout << "Base::print()"; }

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

那麼,這個類在內存中將被如何表示?5種數據都是連續存放的嗎?如何布局才能支持C++多態? 我們的C++標准與編譯器將如何塑造出各種數據成員與成員函數呢?

簡單對象模型

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

表格驅動模型

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

非繼承下的C++對象模型

概述:在此模型下,nonstatic 數據成員被置於每一個類對象中,而static數據成員被置於類對象之外。static與nonstatic函數也都放在類對象之外,而對於virtual 函數,則通過虛函數表+虛指針來支持,具體如下:

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

在此模型下,Base的對象模型如圖:

先在VS上驗證類對象的布局:

Base b(1000);

可見對象b含有一個vfptr,即vprt。並且只有nonstatic數據成員被放置於對象內。我們展開vfprt:

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()
    typedefvoid(*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);

結果分析:

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

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

繼承下的C++對象模型

單繼承

如果我們定義了派生類

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

繼承類圖為:

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

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

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

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

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

typedefvoid(*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();
}

運行結果:

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

更多詳情見請繼續閱讀下一頁的精彩內容: http://www.linuxidc.com/Linux/2015-12/126525p2.htm

Copyright © Linux教程網 All Rights Reserved