深度解讀《深度探索C++物件模型》之C++物件的記憶體佈局

iShare_爱分享發表於2024-04-15

在C語言中,資料和資料的處理操作(函式)是分開宣告的,在語言層面並沒有支援資料和函式的內在關聯性,我們稱之為程序式程式設計正規化或者程式性程式設計正規化。C++相容了C語言,當然也支援這種程式設計正規化。但C++更主要的特點在支援基於物件(object-based, OB)和麵向物件(object-oriented, OO),OB和OO的基礎是物件封裝,所謂封裝就是將資料和資料的操作(函式)組織在一起,在語言層面保證了資料的訪問和操作的一致性,這樣從程式碼上更能表現出資料和函式的關係。在這裡先不討論在軟體工程上這幾種程式設計正規化的優劣,我們先來分析物件加上封裝後的記憶體佈局,C++相對於C語言是否需要佔用更多的記憶體空間,如果有,那麼到底增加了多少記憶體成本?本文接下來將對各種情形進行分析。

空物件的記憶體佈局

請看下面的程式碼,你覺得答案應該輸出多少?

#include <iostream>
using namespace std;

class Object {
    // empty
};

int main() {
    Object object;
    cout << "The size of object is: " << sizeof(object) << endl;

    return 0;
}

答案是會輸出:The size of object is: 1,是的,答案是1位元組。在C++中,即使是空物件也會佔用一定的空間,通常是1個位元組。這個位元組用來確保每個物件都有唯一的地址,以便在程式中進行操作。

含有資料成員的物件的記憶體佈局

  • 非靜態資料成員

現在再往這個類裡面加入一些非靜態的資料成員,來看看加入非靜態的資料成員之後記憶體佈局佔用多少空間。

#include <iostream>
using namespace std;

class Object {
public:
    int a;
    int b;
};

int main() {
    Object object;
    cout << "The size of object is: " << sizeof(object) << endl;
    cout << "The address of object: " << &object << endl;
    cout << "The address of object.a: " << &object.a << endl;
    cout << "The address of object.b: " << &object.b << endl;

    return 0;
}

執行結果輸出的是:

The size of object is: 8
The address of object: 0x16f07f464
The address of object.a: 0x16f07f464
The address of object.b: 0x16f07f468

現在object物件總共佔用了8位元組。int型別在我測試的機器上佔用4位元組的空間,這個跟測試的機器有關,有的機器有可能是8位元組,在一些很老的機器上也有可能是2位元組。

看後面三行的地址,可以看出,資料成員a的地址跟物件的地址是一樣的,也就是說它是排列在物件的開始處,接下來是隔了4個位元組後的地址,也就是資料成員b的地址,這說明資料成員a和b是順序且緊密排列在一起的,並且是從物件的起始處開始的。結果表明,在這種情況下,C++的物件的記憶體佈局跟C語言的結構的記憶體佈局是一樣的,並不會比C語言多佔用一些記憶體空間。

  • 靜態資料成員

C++的類也支援在類裡面定義靜態資料成員,那麼定義了靜態資料成員之後類物件的記憶體佈局是怎麼樣的呢?在上面的類中加入一個靜態資料成員,如以下程式碼:

class Object {
public:
    int a;
    int b;
    static int static_a;
};

執行結果輸出:

The size of object is: 8
The address of object: 0x16b25f464
The address of object.a: 0x16b25f464
The address of object.b: 0x16b25f468
The address of object.static_a: 0x104ba8000

物件的大小結果還是8位元組,說明靜態成員變數並不會增加物件的記憶體佔用空間。看下它們各個的地址,從結果可以看出,靜態成員變數的地址跟非靜態成員變數的地址相差很大,推斷肯定不是和它們排列在一起的。在main函式中增加如下程式碼:

Object obj2;
cout << "The size of obj2 is: " << sizeof(obj2) << endl;
cout << "The address of obj2.static_a: " << &obj2.static_a << endl;

輸出結果為:

The size of obj2 is: 8
The address of obj2.static_a: 0x104ba8000

定義了第2個物件,這個物件的大小也還是8位元組,說明靜態物件不是儲存在每個物件中的,而是存在某個地方,由所有的同一個的類物件所共有的。從第2行輸出的地址可以看出來,它的地址和第1個物件輸出的地址是一樣的,說明它們指向的是同一個變數。其實類中的靜態資料成員是和全域性變數一樣存放在資料段中的,它的地址是在編譯的時候就已經確定的了,每次執行都是一樣的。它和全域性變數一樣,地址在編譯時確定,所以訪問它沒有任何效能損失,和全域性變數的區別是它的作用域不一樣,類的靜態資料成員的作用域只有在類中可見,訪問許可權受它在類中定義時的訪問許可權區段所控制。

含有成員函式的物件的記憶體佈局

上面所討論的都是類裡面只有資料成員的情況,如果在類裡再加上成員函式時,類物件的記憶體佈局會有什麼變化?在類中增加一個public的成員函式和一個靜態成員函式,程式碼修改如下:

#include <iostream>
#include <cstdio>
using namespace std;

class Object {
public:
    void print() {
        cout << "The address of a: " << &a << endl;
        cout << "The address of b: " << &b << endl;
        cout << "The address of static_a: " << &static_a << endl;
    }

    static void static_func() {
        cout << "This is a static member function.\n";
    }

private:
    int a;
    int b;
    static int static_a;
};

int Object::static_a = 1;

int main() {
    Object object;
    cout << "The size of object is: " << sizeof(object) << endl;
    printf("The address of print: %p\n", &Object::print);
    printf("The address of static_func: %p\n", &Object::static_func);
    object.print();
    object.static_func();

    return 0;
}

執行輸出結果如下:

The size of object is: 8
The address of print: 0x102d93120
The address of static_func: 0x102d931c4
The address of a: 0x16d06f464
The address of b: 0x16d06f468
The address of static_a: 0x102d98000
This is a static member function.

類物件的大小還是沒變,還是8位元組。說明增加成員函式並沒有增加類物件的記憶體佔用,無論是普通成員函式還是靜態成員函式都一樣。其實類中的成員函式並不儲存在每個類物件中的,而是跟類的定義相關的,它是存放在可執行二進位制檔案中的程式碼段裡的,由同一個類所產生出來的所有物件所共享。從上面輸出結果中兩個函式的地址來看,它們的地址很相近,說明普通成員函式和靜態成員函式都是一樣的,都存放在程式碼段中,地址在編譯時就已確定。呼叫它們跟呼叫一個普通的函式沒有什麼區別,不會有效能上的損失。

含有虛擬函式的物件的記憶體佈局

物件導向主要的特徵之一就是多型,而多型的基礎就是支援虛擬函式的機制。那麼虛擬函式的支援對物件的記憶體佈局會產生什麼影響呢?這裡先不分析虛擬函式的實現機制,我們先來分析記憶體佈局的成本。在上面的例子中加入兩個虛擬函式:一個普通的虛擬函式和虛解構函式,程式碼如下:

virtual ~Object() {
    cout << "Destructor...\n";
}

virtual void virtual_func() {
    cout << "Call virtual_func\n";
}

// 在main函式里增加兩行列印
printf("The address of object: %p\n", &object);
printf("The address of virtual_func: %p\n", &Object::virtual_func);

編譯執行,看看輸出:

The size of object is: 16
The address of object: 0x16f97f458
The address of print: 0x100482f74
The address of static_func: 0x10048301c
The address of virtual_func: 0x10
The address of a: 0x16f97f460
The address of b: 0x16f97f464
The address of static_a: 0x100488000
Destructor...

在沒有增加任何資料成員的情況下,物件的大小增加到了16位元組,這說明虛擬函式的加入改變了物件的記憶體佈局。那麼增加的內容是什麼呢?我們看到輸出的列印中物件的首地址為0x16f97f458,而資料成員a的地址為0x16f97f460,這中間剛好差了8位元組。而從上面的分析我們知道,原來a的地址是和物件的首地址是一樣的,也就是說物件的記憶體佈局是從a開始排列的,而現在在物件的起始地址和成員變數a之間空了8個位元組,那麼排在a之前的這8個位元組的內容是什麼呢?我們加點程式碼把它的內容輸出出來,在main函式中加入以下程式碼:

long* p =  (long*)&object;
long* vptr = (long*)*p;
printf("vptr is %p\n", vptr);

輸出結果:

The size of object is: 16
The address of object: 0x16b00f458
The address of print: 0x104df2f68
The address of static_func: 0x104df3010
The address of virtual_func: 0x10
The address of a: 0x16b00f460
The address of b: 0x16b00f464
The address of static_a: 0x104df8000
vptr is 0x104df4110
Destructor...

它的內容是0x104df4110,它其實是一個指標,在我的機器上佔用8位元組,在某些機器上可能是4位元組。這個指標指向的其實是一個虛擬函式表,虛擬函式表是一個表格,表格裡的每一項的內容存放的是每個虛擬函式的地址,這個地址指向虛擬函式真正的地址,在上面的列印中虛擬函式列印出來的地址是0x10,這個其實不是它的真正地址,是它在表格中的偏移地址。可以看到這個虛擬函式表地址和靜態成員static_a的地址非常相近,其實虛擬函式表也是存放在資料段裡面的,它在編譯的時候由編譯器確定好內容,並且編譯器會自動擴充一些程式碼,在構造物件的時候把虛擬函式表的首地址插入到物件的起始位置。虛擬函式的詳細分析在這裡先不展開,後面再詳細分析。從這裡的分析可以看到,類裡面增加虛擬函式,會在物件的起始位置上插入一個指標,物件的大小會增加一個指標的大小,為8位元組或者4位元組。如下面的示意圖:
image

繼承體系下的物件的記憶體佈局

繼承是C++中很重要的一個功能,按照不同的形式有單一繼承、多重繼承、虛繼承,按照繼承許可權有public、protected、private。下面我們一一來分析,為簡單起見,我們只分析public繼承。

  • 單一繼承
#include <iostream>
#include <cstdio>
using namespace std;

class point2d {
public:
    int x() { return x_; }
    int y() { return y_; }
protected:
    int x_;
    int y_;
};

class point3d: public point2d {
public:
    int z() { return z_; }

    void print() {
        printf("The address of x: %p\n", &x_);
        printf("The address of y: %p\n", &y_);
        printf("The address of z: %p\n", &z_);
    }
protected:
    int z_;
};

int main() {
    point2d p2d;
    point3d p3d;
    cout << "The size of p2d is: " << sizeof(p2d) << endl;
    cout << "The size of p3d is: " << sizeof(p3d) << endl;
    cout << "The address of p3d: " << &p3d << endl;
    p3d.print();

    return 0;
}

上面的程式碼編譯執行輸出:

The size of p2d is: 8
The size of p3d is: 12
The address of p3d: 0x16d2bb458
The address of x: 0x16d2bb458
The address of y: 0x16d2bb45c
The address of z: 0x16d2bb460

類point3d只有一個資料成員z_,但大小卻有12位元組,很明顯它的大小是加上父類point2d的大小8位元組的。從輸出的地址看,p3d的地址是0x16d2bb458,從父類繼承而來的x_的地址也是0x16d2bb458,這說明從父類繼承而來的資料成員排列在前面,從物件的首地址開始,按照它們在類中的宣告順序依次排序,接著是子類自己的資料成員,從上面的結果看起來物件中的資料成員在記憶體中是按照順序且緊湊的排列在一起的,如下圖所示:
image
我們再來驗證一下,把資料成員的宣告型別改為char型,修改後輸出結果:

The size of p2d is: 2
The size of p3d is: 3
The address of p3d: 0x16ba63467
The address of x: 0x16ba63467
The address of y: 0x16ba63468
The address of z: 0x16ba63469

看起來似乎我們的猜測是正確的,我們再繼續修改,把x_改為int型,其它兩個為char型,宣告順序還是跟之前一樣,這次的輸出結果:

The size of p2d is: 8
The size of p3d is: 12
The address of p3d: 0x16d033458
The address of x: 0x16d033458
The address of y: 0x16d03345c
The address of z: 0x16d033460

這次跟我們想要的結果不一樣了,p2d的大小不是5位元組而是8位元組,p3d的大小不是6位元組而是12位元組,看起來編譯器填充了記憶體空間使得他們的大小變大了。其實這時編譯器為了訪問效率選擇了對齊,為了讓變數的地址是4的倍數,它會填充中間的空擋,這些行為跟編譯器有很大的關係,不同的編譯器有不同的行為,類中資料成員的不同宣告順序和不同的資料型別可能就導致不同的結果。佈局示意圖如下:
image

  • 多重繼承

接下來看看一個類繼承了多個父類,它的記憶體佈局是怎麼樣的。請看下面的程式碼:

#include <iostream>
#include <cstdio>
using namespace std;

class Base1 {
public:
    int b1;
};

class Base2 {
public:
    int b2;
};

class Derived: public Base1, public Base2 {
public:
    int d;
    void print() {
        printf("The address of b1: %p\n", &b1);
        printf("The address of b2: %p\n", &b2);
        printf("The address of d: %p\n", &d);
    }
};

int main() {
    Derived obj;
    printf("The size of obj is: %lu\n", sizeof(obj));
    printf("The address of obj: %p\n", &obj);
    obj.print();

    return 0;
}

輸出結果:

The size of obj is: 12
The address of obj: 0x16f737460
The address of b1: 0x16f737460
The address of b2: 0x16f737464
The address of d: 0x16f737468

物件的總大小是12位元組,它是子類自身擁有的一個資料成員4位元組加上分別從兩個父類繼承而來的兩個資料成員共8位元組的總和。從輸出的地址可以看出來,從父類Base1繼承來的成員b1和物件的首地址相同,接著是從父類Base2繼承而來b2,最後是子類自己的成員d,說明物件的佈局是從b1開始,然後是b2,最後是d,這個跟繼承的順序有關,第一繼承而來的資料成員排在最前面,按照在類中宣告的順序依次排列,其次是第二繼承而來的資料成員,以此類推,最後是子類自己的資料成員。佈局示意圖如下:
image

  • 父類帶虛擬函式的繼承

如果父類中帶有虛擬函式,那麼對子類的記憶體佈局有何影響?在上面的程式碼中的兩個父類各加上一個虛擬函式,而子類暫時先不加虛擬函式,如下程式碼:

// 在class Base1中加入以下程式碼
virtual void virtual_func1() {
    printf("This is virtual_func1\n");
}

// 在class Base2中加入以下程式碼
virtual void virtual_func2() {
    printf("This is virtual_func2\n");
}

編譯執行,輸出結果:

The size of obj is: 32
The address of obj: 0x16b807448
The address of b1: 0x16b807450
The address of b2: 0x16b807460
The address of d: 0x16b807464

這次物件的大小竟然是32位元組,比上面的例子增加了20位元組,這裡並沒有增加任何資料成員,只是僅僅在父類增加了虛擬函式,根據上面的分析,增加虛擬函式會引入虛擬函式表指標,指標佔8位元組的大小,那為什麼會增加這麼多呢?我們可以藉助工具來分析一下,編譯器一般會提供一些輔助分析工具供開發人員使用,其中有一個功能是把每個類的佈局給列印出來,gcc、clang、vs都有類似的命令,clang可以使用下面的命令來檢視:

clang -Xclang -fdump-record-layouts -stdlib=libc++ -std=c++11 -c filename.cpp

輸出的結果很多,我擷取關鍵的一部分:
image
上圖中,左邊的數字就是物件的成員相對於物件的起始地址的偏移量。從上圖我們可以得出以下的結論:

1.父類中各有一個虛擬函式表以及一個指向它的虛擬函式表指標,子類分別從父類中繼承下來,父類有多少個虛擬函式表,子類就有多少個虛擬函式表。這裡額外插一句,子類雖然繼承了父類的虛擬函式表,但子類的虛擬函式表不會和父類的虛擬函式表是同一個,就運算元類沒有覆蓋父類的任何虛擬函式,編譯器也會複製多一份虛擬函式表出來,儘管它們的虛擬函式表的內容是一模一樣的,但是一般情況下子類都會覆蓋父類的虛擬函式,不然也沒有必要用虛擬函式了,虛擬函式具體的分析以後再講。

2.編譯器為了訪問效率選擇了8位元組的對齊,也就是說成員變數b1佔了8位元組,資料本身佔了4位元組,為了對齊填充了4位元組,使得下一個虛擬函式表指標可以對齊訪問。

所以,分析的結論就是子類物件的記憶體佈局是這樣的,首先是從Base1父類繼承來的虛擬函式表指標,佔用8位元組,接著是繼承來的b1成員變數,加上填充的4位元組共佔用了8位元組,再接著是從父類Base2繼承來的虛擬函式表指標,佔用8位元組,之後是繼承的b2成員變數,佔用4位元組,子類自己的成員變數d緊跟著排列在後面,總共32位元組。佈局示意圖如下:
image

虛繼承的物件的記憶體佈局

虛繼承是為了解決稜形繼承情形下重複繼承的問題提出來的解決辦法,如下面的程式碼:

#include <iostream>
#include <cstdio>
using namespace std;

class Grand {
    int a;
};

class Base1: public Grand {
};

class Base2: public Grand {
};

class Derived: public Base1, public Base2 {
};

int main() {
    Grand g;
    Base1 b1;
    Base2 b2;
    Derived obj;
    //obj.a = 1;	// 這行編譯不過。
    printf("The size of g is: %lu\n", sizeof(g));
    printf("The size of b1 is: %lu\n", sizeof(b1));
    printf("The size of b2 is: %lu\n", sizeof(b2));
    printf("The size of obj is: %lu\n", sizeof(obj));
    return 0;
}

上面的程式碼中如果不把第23行程式碼遮蔽掉是編譯不過的,因為Base1和Base2都繼承了Grand,Derived又繼承了Base1和Base2,Grand中的成員a將會被重複繼承兩次,這時在子類Derived中就存在了兩個成員a,這時從Derived訪問a就會出現錯誤,因為編譯器不知道你要訪問的是哪一個a,出現了名字衝突的問題。遮蔽掉第23行後編譯執行,看下輸出結果:

The size of g is: 4
The size of b1 is: 4
The size of b2 is: 4
The size of obj is: 8

從結果中也可以驗證,子類Derived佔了兩倍的大小。為了解決像這種重複繼承了兩次的問題,辦法是引入虛繼承,我們修改下程式碼繼續分析:

#include <iostream>
#include <cstdio>
using namespace std;

class Grand {
public:
    int a;
};

class Base1: virtual public Grand {
public:
    int b;
};

class Base2: virtual public Grand {
public:
    int c;
};

class Derived: public Base1, public Base2 {
public:
    int d;
};

int main() {
    Grand g;
    Base1 b1;
    Base2 b2;
    Derived obj;
    obj.a = 1;
    printf("The size of g is: %lu\n", sizeof(g));
    printf("The size of b1 is: %lu\n", sizeof(b1));
    printf("The size of b2 is: %lu\n", sizeof(b2));
    printf("The size of obj is: %lu\n", sizeof(obj));
    printf("The address of obj: %p\n", &obj);
    printf("The address of obj.a: %p\n", &obj.a);
    printf("The address of obj.b: %p\n", &obj.b);
    printf("The address of obj.c: %p\n", &obj.c);
    printf("The address of obj.d: %p\n", &obj.d);
    
    return 0;
}

這時訪問Derived類的物件中的成員變數a就沒有衝突了,如上面程式碼的第30行,上面程式碼的輸出結果:

The size of g is: 4
The size of b1 is: 16
The size of b2 is: 16
The size of obj is: 40
The address of obj: 0x16d70b420
The address of obj.a: 0x16d70b440
The address of obj.b: 0x16d70b428
The address of obj.c: 0x16d70b438
The address of obj.d: 0x16d70b43c

改為虛繼承後,obj.a = 1;這行程式碼能編譯透過了,不會出現名字衝突了。我們來看看孫子類Derived的物件的大小,竟然是40位元組,增大了這麼多,還是使用上面的命令來dump出物件的記憶體佈局,結果如下圖,擷取部分:
image
這裡先補充一點,虛繼承是藉助於虛基類表來實現,被虛繼承的父類的成員變數會放在虛基類表中,透過在物件中插入的虛基類表指標來訪問虛基類表,有點類似於虛擬函式表,實現方式不同的編譯器採用不一樣的方式,gcc和clang是虛擬函式表和虛基類表共用一個表,稱為虛表,所以只需要一個指標指向它,叫做虛表指標,而Windows平臺的Visual Studio是採用兩個表,所以Windows下物件裡會有兩個指標,一個虛擬函式表指標和一個虛基類表指標,虛基類的實現細節後面再詳細分析。

從上圖可以看到,孫子類Derived的物件的記憶體裡擁有兩個虛表指標,因為父類Base1和Base2分別虛繼承了爺爺類Grand,每一個虛繼承將會產生一個虛表指標,按照繼承的順序依次排列,首先是Base1子物件的內容,包含了一個虛表指標和成員變數b,b之後會填充4位元組到8位元組對齊,然後是Base2子物件的內容,同樣也包含了一個虛表指標和成員變數c,再之後是孫子類Derived自己的成員變數d,它是緊湊的排列在c之後的,最後是爺爺類Grand中的成員變數a,可以看到虛繼承下來的成員變數被安排到最後的位置了,從列印的地址也可以看出來。佈局示意圖如下:
image

此篇文章同步釋出於我的微信公眾號:C++物件封裝後的記憶體佈局

如果您感興趣這方面的內容,請在微信上搜尋公眾號iShare愛分享或者微訊號iTechShare並關注,以便在內容更新時直接向您推送。

相關文章