C++虛函數表與類的內存分佈深入分析理解

不可定義為虛函數的函數

類的靜態函數和構造函數不可以定義為虛函數:

靜態函數的目的是通過類名+函數名訪問類的static變量,或者通過對象調用staic函數實現對static成員變量的讀寫,要求內存中隻有一份數據。而虛函數在子類中重寫,並且通過多態機制實現動態調用,在內存中需要保存不同的重寫版本。

構造函數的作用是構造對象,而虛函數的調用是在對象已經構造完成,並且通過調用時動態綁定。動態綁定是因為每個類對象內部都有一個指針,指向虛函數表的首地址。而且虛函數,類的成員函數,static成員函數都不是存儲在類對象中,而是在內存中隻保留一份。

將析構函數定義為虛函數的作用

類的構造函數不能定義為虛函數,析構函數可以定義為虛函數,這樣當我們delete一個指向子類對象的基類指針時可以達到調用子類析構函數的作用,從而動態釋放內存。

如下我們先定義一個基類和子類

class VirtualTableA
{
public:
    virtual ~VirtualTableA()
    {
        cout << "Desturct Virtual Table A" << endl;
    }
    virtual void print()
    {
        cout << "print virtual table A" << endl;
    }
};
class VirtualTableB : public VirtualTableA
{
public:
    virtual ~VirtualTableB()
    {
        cout << "Desturct Virtual Table B" << endl;
    }
    virtual void print();
};
void VirtualTableB::print()
{
    cout << "this is virtual table B" << endl;
}

我們寫一個函數做測試

void destructVirtualTable()
{
    VirtualTableA *pa = new VirtualTableB();
    useTable(pa);
    delete pa;
}
void useTable(VirtualTableA *pa)
{
    //實現動態調用
    pa->print();
}

程序輸出

this is virtual table B
Desturct Virtual Table B
Desturct Virtual Table A

在上面的例子中我們先在destructVirtualTable函數中new瞭一個VirtualTableB類型對象,並用基類VirtualTableA的指針指向瞭這個對象。

然後將基類指針對象pa傳遞給useTable函數,這樣會根據多態原理調用VirtualTableB的print函數,然後再執行delete pa操作。

此時如果pa的析構函數不寫成虛函數,那麼就隻會調用VirtualTableA的析構函數,不會調用子類VirtualTableB的析構函數,導致內存泄露。

而我們將析構函數寫成虛析構之後,可以看到先調用瞭子類VirtualTableB的析構函數,再調用瞭基類VirtualTableA的析構函數,達到瞭釋放子類空間的目的。

有人會問?將析構函數不寫為虛函數,直接delete子類對象VirtualTableB,調用子類的析構函數不可以嗎?比如,如下的調用

    VirtualTableB *pb = new VirtualTableB();
    delete pa;

上述調用沒有問題,無論析構函數是否為虛析構都可以成功釋放子類空間。但是項目編程中常常會編寫一些通用接口,比如上面的useTable函數,

它隻接受VirtualTableA類型的指針,所以我們常常會用基類指針接受子類對象來通過多態的方式調用子類函數,為瞭方便delete基類指針也要釋放子類空間,

就要將析構函數設置為虛函數。

虛函數表原理

為瞭介紹虛函數表原理,我們先實現一個基類和子類

class Baseclass
{
public:
    Baseclass() : a(1024) {}
    virtual void f() { cout << "Base::f" << endl; }
    virtual void g() { cout << "Base::g" << endl; }
    virtual void h() { cout << "Base::h" << endl; }
    int a;
};
// 0 1 2 3   4 5 6 7(虛函數表空間)    8 9 10 11 12 13 14 15(存儲的是a)
class DeriveClass : public Baseclass
{
public:
    virtual void f() { cout << "Derive::f" << endl; }
    virtual void g2() { cout << "Derive::g2" << endl; }
    virtual void h3() { cout << "Derive::h3" << endl; }
};

一個類對象其內存分佈的基本結構為虛函數表地址+非靜態成員變量,類的成員函數不占用類對象的空間,他們分佈在一片屬於類的共有區域。

類的靜態成員函數喝成員變量不占用類對象的空間,他們分配在靜態區。

虛函數表的地址存儲在類對象的起始位置。所以我們利用這個原理,通過尋址的方式訪問虛函數表裡的函數

void useVitualTable()
{
    Baseclass b;
    b.a = 1024;
    cout << "sizeof b is " << sizeof(b) << endl;
    int *p = (int *)(&b);
    cout << "pointer address of vitural table " << p << endl;
    cout << "address of b is " << &b << endl;
    cout << "address of a is " << p + 2 << endl;
    cout << "address of p+1 is " << p +1 << endl;
    cout << "value of a is " << *(p + 2) << endl;
    cout << "address of vitural table" << (int *)(*p) << endl;
    cout << "sizeof int is " << sizeof(int) << endl;
    cout << "sizeof p is " << sizeof(p) << " sizeof(int*) is " << sizeof(int *) << endl;
    Func pFun = (Func)(*(int *)(*p));
    pFun();
    pFun = (Func) * ((int *)(*p) + 2);
    pFun();
    pFun = (Func)(*((int *)(*p) + 4));
    pFun();
}

上面的程序輸出

sizeof b is 16
pointer address of vitural table 0xb6fdd0
address of b is 0xb6fdd0
address of a is 0xb6fdd8
address of p+1 is 0xb6fdd4
value of a is 1024
address of vitural table0x46d890
sizeof int is 4
sizeof p is 8 sizeof(int*) is 8
Base::f
Base::g
Base::h

可以看到b的大小為16字節,因為我的機器是64位的,所以指針類型都占用8字節,int 占用4字節,但是要遵循補齊原則,結構體的大小要為最大成員大小的整數倍,所以要補齊4字節,那麼8+4+4 = 16 字節,關於類對象對齊和補齊原則稍後再詳述。

b的內存分佈如下圖

這個根據不同的機器所占的字節數不一樣,在32位機器上int為4字節,虛函數表地址為4字節,4+4 = 8字節,這個再之後再說明對齊和補齊的原則。

&b表示取b的地址,因為虛函數表地址存儲在b的起始地址,所以&b也是虛函數表的地址的地址,我們通過int* 強轉是方便存儲b的地址,因為64位機器指針都是8字節,32位機器指針是4字節。

p為虛函數表的地址的地址,p+1具體移動瞭4個字節,因為p+1移動多少個字節取決於p所指向的數據類型int,int為4字節,所以p+1在p的地址移動四個字節,p+2在p的地址移動8個字節。

p隻想虛函數表的地址,換句話說p存儲的是虛函數表的地址,虛函數表地址占用8字節,p+2就是從p向後移動8字節,這樣剛好找到a的地址。

那麼*(p+2)就是取a的數值。

int*(*p)就是取虛函數表的地址,轉為int*是方便讀寫。

我們將b的內存分佈以及虛函數表結構畫出來

上圖中可以看到虛函數表中存儲的是虛函數的地址,所以通過不斷位移虛函數表的指針就可以達到指向不同虛函數的目的。

Func pFun = (Func)(*(int *)(*p));
pFun();

*(int *)(*p)就是取出虛函數表首地址指向的虛函數,再通過Func轉化為函數類型,然後調用pFun即可調用虛函數f。

所以想調用第二個虛函數g,將(int*)(*p) 加2 位移8個字節即可

 pFun = (Func) * ((int *)(*p) + 2);
 pFun();

同樣的道理調用h就不贅述瞭。

繼承關系中虛函數表結構

DeriveClass繼承瞭BaseTest類,子類如果重寫瞭虛函數,則子類的虛函數表中存儲的虛函數為子類重寫的,否則為基類的。

我們畫一下DeriveClass的虛函數表結構

因為函數f被DeriveClass重寫,所以DeriveClass的虛函數表存儲的是自己重寫的f。

而虛函數g和h沒有被DeriveClass重寫,所以DeriveClass虛函數表存儲的是基類的g和h。

另外DeriveClass虛函數表裡也存儲瞭自己特有的虛函數g2和h3.

下面我們還是利用尋址的方式調用虛函數

void deriveTable()
{
    DeriveClass d;
    int *p = (int *)(&d);
    int *virtual_tableb = (int *)(*p);
    Func pFun = (Func)(*(virtual_tableb));
    pFun();
    pFun = (Func)(*(virtual_tableb + 2));
    pFun();
    pFun = (Func)(*(virtual_tableb + 4));
    pFun();
    pFun = (Func)(*(virtual_tableb + 6));
    pFun();
    pFun = (Func)(*(virtual_tableb + 8));
    pFun();
}

程序輸出

Derive::f
Base::g
Base::h
Derive::g2
Derive::h3

可見DeriveClass虛函數表裡存儲的f是DeriveClass的f。

(int *)(*p)表述取出p所指向的內存空間的內容,p指向的正好是虛函數表的地址,所以*p就是虛函數表的地址。

因為我們不知道虛函數表的具體類型,所以轉為int*類型,因為指針在64位機器上都是8字節,可以保證空間大小正確。

接下來就是尋址和函數調用的過程,這裡不再贅述。

多重繼承的虛函數表

上面的例子我們知道,如果類有虛函數,那麼編譯器會為該類的實例分配8字節存儲虛函數表的地址。

所有繼承該類的子類也會擁有8字節的空間存儲自己的虛函數表地址。

多重繼承的情況就是類對象空間裡存儲多張虛函數表地址。子類繼承於兩個基類,並且基類都有虛函數,那麼子類就有兩張虛函數表。

多態調用原理

當我們通過基類指針存儲子類對象時,調用虛函數,會調用子類的實現版本,這叫做多態。

通過前面的實驗和圖示,我們已經知道如果子類重寫瞭基類的虛函數,那麼他自己的虛函數表裡存儲的就是自己實現的版本。

通過基類指針存儲子類對象時,基類指針實際指向的是子類的空間,尋址也是找到子類的虛函數表,從虛函數表中找到子類實現的虛函數,

然後調用子類版本,從而達到多態效果。

對齊和補齊規則

在考察一個類對象所占空間時,虛函數、成員函數(包括靜態與非靜態)和靜態數據成員都是不占用類對象的存儲空間的。對象大小= vptr(虛函數表指針,可能不止一個) + 所有非靜態數據成員大小 + Aligin字節大小(依賴於不同的編譯器對齊和補齊)

對齊:類(結構體)對象每個成員分配內存的起始地址為其所占空間的整數倍。

補齊:類(結構體)對象所占用的總大小為其內部最大成員所占空間的整數倍。

下面我們先定義幾個類

namespace AligneTest
{
    class A
    {
    };
    class B
    {
        char ch;
        void func()
        {
        }
    };
    class C
    {
        char ch1; //占用1字節
        char ch2; //占用1字節
        virtual void func()
        {
        }
    };
    class D
    {
        int in;
        virtual void func()
        {
        }
    };
    class E
    {
        char m;
        int in;
    };
}

然後通過代碼測試他們的大小

extern void aligneTest()
{
    AligneTest::A a;
    AligneTest::B b;
    AligneTest::C c;
    AligneTest::D d;
    AligneTest::E e;
    cout << "sizeof(a): " << sizeof(a) << endl;
    cout << "sizeof(b): " << sizeof(b) << endl;
    cout << "sizeof(c): " << sizeof(c) << endl;
    cout << "sizeof(d): " << sizeof(d) << endl;
    cout << "sizeof(e): " << sizeof(e) << endl;
}

程序輸出

sizeof(a): 1
sizeof(b): 1
sizeof(c): 16
sizeof(d): 16
sizeof(e): 8

我們分別對每個類的大小做解釋

a 是A的對象,A是一個空類,編譯器為瞭區分不同的空類,所以為每個空類對象分配1字節的空間保存其信息,用來區別不同類對象。

b 是B的對象,因為B中定義瞭一個char成員變量和func函數,func函數不占用空間,所以b的大小為char的大小,也就是1字節。

c 是C的對象,因為C中包含虛函數,所以C的對象c中會分配8字節用來存儲虛函數表,虛函數表放在c內存的首地址,然後是ch1,

以及ch2。假設c的起始地址為0,那麼0~7字節存儲虛函數表地址,第8個字節是1的整數倍,所以不同對齊,第8個字節存儲ch1。

第9個字節是1的整數倍,所以第9個字節存儲ch2。那麼c的大小為8 + 2 = 10, 因為補齊規則要求c的大小為最大成員大小的整數

倍,最大成員為虛函數表地址8字節,所以要補齊6個字節,10+6 = 16,所以c的大小為16字節。

其內存分配如下圖

d 是D的對象,因為D中包含虛函數,所以D的對象d中會分配8字節空間存儲虛函數表地址,比如0~7字節存儲虛函數表地址,接下來第8個字節,

因為int為4字節,8是4的整數倍,所以不需要對齊,第8~11字節存儲in,這樣d的大小變為8+4= 12, 因為根據補齊規則需要補齊4字節,總共

大小為16字節剛好是最大成員大小8字節的整數倍。所以d為16字節

其內存分配圖如下

e 是E的對象,e會為m分配1字節空間,為in分配4字節空間,假設地址0存儲m,接下來地址1存儲in。

因為對齊規則要求類(結構體)對象每個成員分配內存的起始地址為其所占空間的整數倍,1不是4的整數倍,所以要對齊。

對齊的規則就是地址後移找到起始地址為4的整數倍,所以要移動3個字節,在地址為4的位置存儲in。

那麼e所占的空間就是 1(m占用) + 3(對齊規則) + 4(in占用) = 8 字節。

如下圖所示

為什麼要有對齊和補齊

這個要從計算機CPU存取指令說起,

上圖為32位機器內存模型,CPU通過地址總線和數據總線尋址讀寫數據。如果是64位機器,就是8列。

通過對齊和補齊規則,可以一次讀取內存中的數據,不需要切割和重組,是典型的用空間換取時間的策略。

比如有如下類

class Test{
    int m;
    int b;
}

我們用Test生成瞭兩個對象t1和t2,他們在內存中存儲如下,無色的表示t1的內存存儲,彩色的表示t2。

在不采用對齊和補齊策略的情況下

在采用對齊和補齊策略的情況下

可見不采用對齊和補齊策略,節省空間,但是要取三次能取完數據,取出後還要切割和拼接,最後才能使用。

采用對齊和補齊策略,犧牲瞭空間換取時間,讀取四次,但是不需要切割直接可以使用。

對於64位機器,采用對齊和補齊策略,隻需讀取兩次,每次取出的都是Test對象,效率非常高。

資源鏈接

本文模擬實現瞭vector的功能。

視頻鏈接

源碼鏈接

到此這篇關於C++虛函數表與類的內存分佈深入分析理解的文章就介紹到這瞭,更多相關C++虛函數表內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: