c++ 內存佈局

結構體和類的基礎知識

結構體(c語言的)和類的區別

a. 結構體主要是c語言的特色 類是c++的基本機制
b. 結構體中的數據默認方式是public的,而類是private的
c. 結構體不提供繼承機制,類提供繼承機制,實現代碼複用
d. 類可以實現多態,結構體不支持

以上幾點在C++中只有b是成立的。

結構體的內存對齊

struct CStyle{
    char c1;
    int i;
    char c2;
};

這裏寫圖片描述
從這個簡單的例子簡單的總結一下:
1. 結構體每個成員相對於結構體首地址的偏移量(offset)都是(這個)成員大小的整數倍,如有需要編譯器會在成員之間加上填充字節(internal adding);
2. 結構體的總大小爲結構體最寬基本類型成員大小的整數倍,如有需要編譯器會在最末一個成員之後加上填充字節(trailing padding)。
3. 對於結構體成員屬性中包含結構體變量的複合型結構體,應當將其包含的子結構的成員拆出來看。

struct mystruct1
{
    int i;
    char c;
    double d;
};
struct mystruct2
{
    int c;
    char a;
    mystruct1 mst;
};

上式cout << sizeof(mystruct2) << endl; 得到24。

4.在確定複合型結構體成員的偏移位置時則是將複合類型作爲整體看待。

struct mystruct1
{
    char c1;
    char c2;
    double d;
};
struct mystruct2
{
    char c1;
    char c2;
    mystruct1 mst;
};

上式cout << sizeof(mystruct2) << endl; 也是得到24,而不是16。

還有些關於對齊的注意點,大家可以查閱更多資料,這裏不多說了。

其它一些知識

1.除了成員變量外,C++風格的還可以封裝成員函數和其他東西。
2.除非 爲了實現虛函數和虛繼承引入的隱藏成員變量外,C++類實例的大小完全取決於一個類及其基類的成員變量!成員函數基本上不影響類實例的大小。
3.C++標準委員會不限制關鍵字“public/protected/private”分開的各段成員變量實現的先後順序(VC++中,成員變量總是按照聲明時的順序排列)

struct CplusplusStyle
{
    public:
        int m_i1;
    protected:
        int m_i2;
    private:
        int m_i3;

        static int m_si;
        void mf();
        static void msf();
        typedef void* mpv;
        struct N{};
};

上面的結構體 sizeof(CplusplusStyle) 爲12個字節。 你會問爲什麼不是20個字節,或者不是16個字節嗎? 思考一下。

類中虛函數問題

最簡單的包含虛函數的類佈局

X類的每一個非靜態的成員函數都會接受一個特殊的隱藏參數,X* const this的指針。

struct P {
     int p1;
     void pf(); 
     virtual void pvf(); 
};

這裏寫圖片描述
    1.此處因爲P中有虛成員函數,所以生成一個隱藏成員,虛函數表指針, 而聲明非虛成員函數(存放在代碼區,不佔用對象內存)不會造成任何對象實例的內存開銷。

虛函數表指針放做實例的第一個成員變量,是爲了使虛函數調用能夠儘量快一些。實際上,VC++的實現方式是,保證任何有虛函數的類的第一項永遠是vfptr。

爲了實現這個原則,我們會發現如下的一個情況:

class CA { int a;};
class CB { int b;};
class CL : public CB, public CA{ int c;};

內存佈局:這裏寫圖片描述

而當CA中有了虛函數時,爲了保證子類和父類共享的虛函數指針放到實例第一個成員位置,內存佈局變化了:

class CA
{
     int a;
     virtual void seta( int _a ) { a = _a; }
};

這裏寫圖片描述

     2.編譯器通常會把this指針緩存到寄存器中。訪問局部變量,需要到SP寄存器中得到棧指針,再加上局部變量與棧頂的偏移。在沒有虛繼承的情況下,如果編譯器把this指針緩存到寄存器中,訪問成員變量的過程將與訪問局部變量的開銷類似。

如何根據地址偏移取出虛函數

這裏寫圖片描述
對於這樣一個類,取地址的操作見註釋:
這裏寫圖片描述
其實上類中的取地址操作可以有另一種寫法:

    Base b;
    typedef void(*Fun)(void);
    Fun pFun = NULL;

    //方式1
    pFun = (Fun)*((int*)*(int*)&b + 1);
    pFun();  //Base::g
    //方式2
    int** pVtab = (int**)&b;
    pFun = (Fun)pVtab[0][0];   
    pFun(); //Base::f

因爲可以這樣直接通過虛函數表指針取到虛函數,故也帶來了一些安全性問題:
I. 通過父類指針訪問子類自己的虛函數,任何妄圖使用父類指針想調用子類中的未覆蓋父類的成員函數的行爲都會被編譯器視爲非法,所以,這樣的程序根本無法編譯通過。但在運行時,我們可以通過指針的方式訪問虛函數表來達到違反C++語義的行爲。
這裏寫圖片描述

II. 如果父類的虛函數是private或是protected的,但這些非public的虛函數同樣會存在於虛函數表中,所以,我們同樣可以使用訪問虛函數表的方式來訪問這些non-public的虛函數。
我們改造一下Base1如下:
這裏寫圖片描述
這裏寫圖片描述
對於private的g()函數,會發現可以通過這種方式訪問到,破壞了其封裝性。
這裏寫圖片描述

類的各種繼承關係下的佈局

這一塊將是本文的重點!!!

單一繼承

struct Base
{
     int m_b1;
     void m_bfun();
}

這裏寫圖片描述

struct Derived:Base{
     int m_d1;
     void m_dfun();
}

這裏寫圖片描述

之所以這樣內存佈局,並不是說基類的數據一定要放在子類之前,這樣保證,有了派生類Derived的指針之後,要獲得基類Base的指針就不需要計算地址偏移了。幾乎所有的C++廠商,都是這樣安排內存的,在單繼承下,每一個新的派生類都簡單的把自己的成員變量添加到基類的成員變量之後。

下面是發生覆蓋的例子:

struct P {
     int p1;
     void pf(); 
     virtual void pvf(); 
};
struct Q : P {
     int q1;
     void pf(); // 隱藏
     void qf(); // new
     void pvf(); // overrides P::pvf
     virtual void qvf(); // new
};

這裏寫圖片描述

覆蓋是靜態 (根據成員函數的靜態類型在編譯時決定)還是動態 (通過對象指針在運行時動態決定),依賴於成員函數是否被聲明爲“虛函數”。

int main()
{
    P p; 
    P* pp = &p; 
    Q q; 
    P* ppq = &q; 
    Q* pq = &q;
    pp->pf();   // pp->P::pf(); => P::pf(pp);
    ppq->pf();  // ppq->P::pf(); => P::pf(ppq);
    pq->pf();   // pq->Q::pf(); => Q::pf((P*)pq); (錯誤!pq->qf();   // pq->Q::qf(); => Q::qf(pq);
    pp->pvf();  // pp->P::pvf(); => P::pvf(pp);
    ppq->pvf(); // ppq->Q::pvf(); => Q::pvf((Q*)ppq);
    pq->pvf();  // pq->Q::pvf(); => Q::pvf((P*)pq); (錯誤!)
    return 1;
}

標記“錯誤”處,P*似應爲Q*。因爲pf 非虛函數,而pq 的類型爲Q*,故應該調用到Q 的pf 函數上,從而該函數應該要求一個Q* const 類型的this 指針。

a. 對於非虛的成員函數,調用那個函數是在編譯時根據”->”操作符左邊指針表達式的類型靜態決定的。即使ppq 指向Q 的實例,ppq->pf()仍然調用的是P::pf(),此時出現了所謂的隱藏現象。【派生類的函數與基類的函數同名,並且參數也相同, 但是基類函數沒有virtual 關鍵字。】【此也就解釋了之所以虛函數需要一張虛函數表,是因爲指針可能指向其基類或派生類內存空間,所以需要虛函數表定位。】

b. 對於虛函數調用來說,調用哪個成員函數在運行時 決定。不管“->”操作符左邊的指針表達式的類型如何,調用的虛函數都是由指針實際指向的實例類型所決定 。比如,儘管ppq 的類型是P*,當ppq 指向Q 的實例時,調用的仍然是Q::pvf()。

許多C++的實現會共享或者重用從基類繼承來的vfptr。比如,Q 並不會有一個額外的vfptr,指向一個專門存放新的虛函數qvf()的虛函數表。Qvf 項只是簡單地追加 到P 的虛函數表的末尾。如此一來,單繼承的代價就不算高昂。一旦一個實例有vfptr 了,它就不需要更多的vfptr。新的派生類可以引入更多的虛函數,這些新的虛函數只是簡單地在已存在的,“每類一個”的虛函數表的末尾追加新項。

單一繼承的複雜一點的例子:
這裏寫圖片描述
main函數部分:
這裏寫圖片描述
輸出:
這裏寫圖片描述

內存佈局圖:
這裏寫圖片描述

1)虛函數表在最前面的位置。
2)成員變量根據其繼承和聲明順序依次放在後面。
3)在單一的繼承中,被overwrite 的虛函數在虛函數表中得到了更新。

多重繼承

比如,有這樣一個組織模型,有經理類(分任務),工人類(幹活),對於一線經理,既要從上級經理那領取任務幹活,又要給下級工人分配任務。爲了實現多態和代碼重用,這時候就需要同時繼承這兩個類。
這裏寫圖片描述
內存佈局:
這裏寫圖片描述
多繼承時,內嵌的兩個基類的對象指針不可能全都與派生類對象的指針相同。注意main中的兩行打印。

多重繼承的成員變量偏移
將上例的main函數改成如下:
這裏寫圖片描述
注意註釋中dMMM表示類MM到M的偏移,其它同理。註釋的內容說明了多重繼承的成員變量的偏移關係。非虛的這種多重繼承,直接簡單偏移即可。
MM 繼承自M 和W,mm 是指向MM 對象的指針。
a. 訪問M 類成員m1 時,MM 對象與內嵌M 對象的相這裏寫代碼片對偏移爲0,可以接計算MM 和m1 的偏移;
b. 訪問W 類成員w1 時,MM 對象與內嵌W 對象的相對偏移是一個常數,MM 和w1 之間的偏移計算也可以被簡化;
c. 訪問MM 自己的成員mm1 時,直接計算偏移量。

多重繼承下的虛函數
1. 如果從多個有虛函數的基類繼承,一個實例就有可能包含多個vfptr。

struct P {
     int p1;
     void pf(); 
     virtual void pvf(); 
};

struct R {
     int r1;
     virtual void pvf(); 
     virtual void rvf(); 
};

這裏寫圖片描述

struct S : P, R {
     int s1;
     void pvf(); // overrides P::pvf and R::pvf
     void rvf(); // overrides R::rvf
     void svf(); // new
};

這裏寫圖片描述

 S* ps = &s;
((P*)ps)->pvf();  // (*(P*)ps)->P::vfptr[0])((S*)(P*)ps)
((R*)ps)->pvf();  // (*(R*)ps)->R::vfptr[0])((S*)(R*)ps)
ps->pvf();           

調用((P*)ps)->pvf()時,先到P 的虛函數表中取出第一項,然後把ps 轉化爲S*作爲this 指針傳遞進去;
但是對於((R*)ps)->pvf() 我們會發現,虛函數必須把R*轉化爲S*,作爲隱藏的this指針參數。但是R*和S*指向內存佈局中的不同位置。所以在S 對R 虛函數表的拷貝中,pvf 函數對應的項,指向的是一個“調整塊 ”的地址,該調整塊使用必要的計算把R*轉換爲需要的S*。

在微軟VC++實現中,對於有虛函數的多重繼承,只有當派生類虛函數覆蓋了多個基類的虛函數時,才使用調整塊。當覆蓋非最左邊的基類虛函數時,一般不創建調整塊,也不增加額外的函數項。(還有些更復雜的調整塊以及this指針的問題,此處就不講了。)

對於上面這種“調整塊”不是很理解的可以簡單理解如下:
這裏寫圖片描述

這裏寫圖片描述

Derive d;
Base1 *b1 = &d;
Base2 *b2 = &d;
Base3 *b3 = &d;

b1->f(); //Derive::f()
b2->f(); //Derive::f()
b3->f(); //Derive::f()
b1->g(); //Base1::g()
b2->g(); //Base2::g()
b3->g(); //Base3::g()

2.而派生類中如果再有新的虛函數,附加到第一個基類的虛函數表後面:
這裏寫圖片描述

這裏寫圖片描述

多重繼承的複雜一點的例子:
這裏寫圖片描述
main函數部分:
這裏寫圖片描述
輸出:
這裏寫圖片描述

內存佈局圖:
這裏寫圖片描述

通過以上我們可以看到:
1) 每個父類都有自己的虛表。
2) 子類的成員函數被放到了第一個父類的表中。
3) 內存佈局中,其父類佈局依次按聲明順序排列。
4) 每個父類的虛表中的f()函數都被overwrite 成了子類的f()。這樣做就是爲了解決不同的父類類型的指針指向同一個子類實例,而能夠調用到實際的函數。

重複繼承與共享繼承(鑽石繼承)

如上述場景中,經理類和工人類都繼承自僱員類。

struct Employee { ... };
struct Manager : Employee { ... };
struct Worker : Employee { ... };
struct MiddleManager : Manager, Worker { ... };

如果就簡單這樣實現多繼承,一線經理類的實例中將含有兩個僱員類實例,一是造成額外的開銷,二是這兩份不同的僱員實例可能分別被修改,造成數據的不一致。三是,子類在訪問的時候,根本不能確定是訪問的哪一份,編譯器會告訴你訪問對象不明確。
這裏寫圖片描述
注意因爲E的成員變量被拷貝了兩次,故size = 20;

因此C++中出現了“共享繼承”,被稱爲“虛繼承”,語法如下:

struct Employee { ... };
struct Manager : virtual Employee { ... };
struct Worker : virtual Employee { ... };
struct MiddleManager : Manager, Worker { ... };

這裏寫圖片描述
此時共享e1之後爲何size變大了?是因爲在M和W中生成了兩個隱藏成員虛基類表指針(後面會將)。 故16+8 = 24。

當然使用使用虛繼承也有一些弊端:更大的實現開銷、調用開銷。因爲其地址不再是簡單的固定偏移量。

上述類結果的內存佈局分析:
這裏寫圖片描述
虛基類表,記錄了虛基類表與自己和基類之間的地址偏移。 故MdMvbptrM=0,MdMvbptrE=8

在多繼承的MM中:
這裏寫圖片描述

發現在MM中和在M中M相對E的偏移量是不同的,當使用指針訪問虛基類成員變量時,由於指針是可以指向派生類實例的基類指針,所以編譯器不能根據聲明的指針類型計算偏移。
這裏寫圖片描述
都是M類型指針,此時根本無法判斷E的偏移位置。

故需要使用一種間接的方法,從派生類指針計算虛基類的位置。對每個繼承自虛基類的實例,將增加一個隱藏的“虛基類表指針”(vbptr)成員變量,從而達到間接計算虛基類位置的目的。該變量指向一個全類共享的偏移量表,表中項目記錄了,對於該類而言,“虛基類表指針”與虛基類之間的偏移量。

虛繼承的成員變量偏移
此時,訪問非虛基類成員仍然是計算固定偏移量。然而訪問虛基類成員時,就麻煩了:
I. 獲取“虛基類表指針”;
II. 獲取虛基類表中某一表項的內容;
III. 把內容中指出的偏移量加到“虛基類表指針”的地址上。
這裏寫圖片描述

強制轉化:
如果沒有虛基類的問題,強制類型轉換,僅僅加上或減去一個偏移量即可。如果有虛基類會開銷比較大,和訪問虛基類對象的開銷相當。
所以爲了提高效率,當在派生類中訪問虛基類的成員時,先強制轉換派生類指針爲虛基類指針,然後一直使用虛基類指針訪問虛基類成員函數。避免每次都要計算虛基類地址的開銷。

然而如果不是通過指針訪問,而是直接通過對象實例,則派生類的佈局就可以在編譯期間靜態獲得,偏移量也可以在編譯時計算,因此也就不必要根據虛基類表的表項來間接計算了。
這裏寫圖片描述

因爲對象的佈局在編譯時就確定了,然而一個指針,可以指向其基類或者派生類,需要運行時確定其指向的對象佈局。

當訪問類繼承層次中,多層虛基類的成員變量時,情況又如何呢?比如,訪問虛基類的成員變量時?一些實現方式爲:保存一個指向直接虛基類的指針,然後就可以從直接虛基類找到它的虛基類,逐級上推。VC++優化了這個過程。VC++在虛基類表中增加了一些額外的項,這些項保存了從派生類到其各層虛基類的偏移量。

虛繼承下的虛函數

struct P {
     int p1;
     void pf(); 
     virtual void pvf(); 
};
struct T : virtual P {
     int t1;
     void pvf(); // overrides P::pvf
     virtual void tvf(); // new
};
void T::pvf() {
     ++p1; // ((P*)this)->p1++; // vbtable lookup!
     ++t1; // this->t1++;
}

內存佈局如下:
這裏寫圖片描述

1.在VC++中,爲了避免獲取虛函數表時,轉換到虛基類P 的高昂代價,T 中的新虛函數通過一個新的虛函數表獲取,而不是追加的基類尾端,從而帶來了一個新的虛函數表指針。該指針放在T 實例的頂端。

2.虛析構函數與delete操作符:
A* p = new B(); //A是B的父類
如果析構函數不虛,那麼必須這樣刪除才安全: delete (B*) p;
如果爲虛,則可以 delete p; 可以動態綁定到B類的析構函數。
實際上,很多人這樣總結:當且僅當類裏包含至少一個虛函數的時候纔去聲明虛析構函數。

重複繼承的複雜一點的例子:
這裏寫圖片描述
main函數部分:
這裏寫圖片描述
輸出:
這裏寫圖片描述
內存佈局:
這裏寫圖片描述

我們會發現重複繼承導致數據重複拷貝,並且會導致二義性:
這裏寫圖片描述

共享(鑽石)繼承的複雜一點的例子:
虛擬繼承的出現就是爲了解決重複繼承中多個間接父類的問題的。
只需改造一下上面的結構:

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

在這裏先分析一下,B1單一繼承的內存結構:
main函數:
這裏寫圖片描述
輸出:
這裏寫圖片描述
內存結構圖:
這裏寫圖片描述

最後關於D的內存佈局留給讀者自己嘗試,如果你可以分析出來了,說明你讀懂理解本帖。

簡單總結內存佈局如下:
1. 首先排列非虛繼承的基類實例
2. 有虛基類時,爲每個基類增加一個隱藏的vbptr,除非已經從非虛繼承的類那裏繼承了一個vbptr
3. 排列派生類的新數據成員
4. 在實例最後,排列每個虛基類的實例

      這些內存佈局之所以變的如此複雜原因是因爲多態,所謂多態,就是用父類型別的指針指向其子類的實例,然後通過父類的指針調用實際子類的成員函數。這種技術可以讓父類指針有“多種形態”,這是一種泛型技術。

由於c++內存結構着實不是很易理解,如果偏頗, 歡迎批評指正,切磋探討。

發佈了14 篇原創文章 · 獲贊 5 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章