首頁 > 軟體

C++虛擬函式表與類的記憶體分佈深入分析理解

2022-08-08 22:03:04

不可定義為虛擬函式的函數

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

靜態函數的目的是通過類名+函數名存取類的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++虛擬函式表內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com