關於C++對象的內存模型,由於各家編譯器不一樣導致的結果也不盡相同,所以以下測試都是基於VS 2017為准。其指針大小為4個字節,為了避免對齊帶來的干擾,所有成員變量都為int類型。
1、非繼承下的對象模型
首先是最為簡單情況下的C++對象的內存模型,即不考慮任何繼承等情況。測試代碼如下:
class Point2d { public: Point2d(int x_, int y_) : x(x_), y(y_) {} virtual ~Point2d() {} virtual void draw() { std::cout << "Point2d::draw()" << std::endl; } virtual void draw2d() { std::cout << "Point2d::draw2d()" << std::endl; } private: int x, y; public: static int var; };
利用VS查看Point2d的對象的內存布局可以得到下圖:
由此可見,在VS中,非繼承的模型下,將虛指針放在了第一個元素。其余元素放在之后。
2、考慮一般繼承情況
如果考慮一般繼承情況(相對於虛繼承)的話,則需要分為三種,單繼承和多繼承,以及棱形繼承。
2.1:單繼承
在上一步的代碼基礎上加上如下測試代碼:
class Point3d : public Point2d { public: Point3d(int x_, int y_, int z_) : Point2d(x_, y_), z(z_) {} virtual ~Point3d() {} virtual void draw() { std::cout << "Point3d::draw()" << std::endl; } virtual void draw3d() { std::cout << "Point3d::draw3d()" << std::endl; } private: int z; };
查看內存布局:
對於對象本身的內存布局來說,先是基類部分,然后才是本類的部分。其中虛指針還是在第一個位置。
虛函數表部分發生了較大變化
- 子類如果重寫了父類的虛函數,則虛表中會只保存子類的版本。--draw() & dtor()
- 子類如果新添加了自己的虛函數,則在上面的虛表的基礎上會在后面加上一個slot來保存。--draw3d()
- 如果子類沒有重寫父類的虛函數,則原有的虛函數在表中保留。--draw2d()
2.2:多繼承
測試代碼如下:
class Base1 { public: Base1(int x_) : x(x_) {} virtual ~Base1() {} virtual void base1_func() { std::cout << "Base1::base1_func" << std::endl; } virtual void func() { std::cout << "func" << std::endl; } private: int x; }; class Base2 { public: Base2(int y_) : y(y_) {}; virtual ~Base2() {}; virtual void base2_func() { std::cout << "Base1::base2_func" << std::endl; } virtual void func() { std::cout << "func" << std::endl; } private: int y; }; class Derived : public Base1, public Base2 { public: Derived(int x_, int y_, int z_) : Base1(x_), Base2(y_), z(z_) {} virtual ~Derived() {} virtual void derived_func() { std::cout << "Derived::derived_func" << std::endl; } virtual void base1_func() { std::cout << "Derived::base1_func" << std::endl; } virtual void base2_func() { std::cout << "Derived::base2_func" << std::endl; } private: int z; };
查看內存布局如下圖所示:
從對象本身的內存布局來看,其中按照基類的聲明順序,依次包含了兩個基類的部分。每個基類部分都有一個虛指針指向各自虛表。也就是說,在這種情況下,一個對象可能含有多個虛指針指向不同的虛表。通常來講,排在對象內存最前面的基類部分所包含的虛指針指向的是“虛函數主表”。所以有如下幾個規律:
- 本類自身所添加的新虛函數的地址會添加在“虛函數主表”的后面。--derived_func()
- 如果重寫了基類的虛函數,則主虛函數表中只會保存重寫后的版本,其余虛函數表會通過thunk機制跳轉到主虛函數表中去。
- 如果基類的虛函數沒有重寫,則會原樣保留下來。--Base2::func()等
- 注意到第二個虛函數表最前面有一個thunk,這是為了調用如下兩句語句 Base2* pb = new Derived(); delete pb; 能夠正確調用到Derived::dtor。其實本質上是一段Assembly代碼。
2.3:棱形繼承
測試函數如下:
class Base { public: Base() {} virtual ~Base() {} virtual void overwrite_func() { std::cout << "Base::overwrite_func" << std::endl; } virtual void Base_func() { std::cout << "Base::base_func" << std::endl; } private: int x; }; class Base1: public Base { public: Base1(){} virtual ~Base1() {} virtual void overwrite_func() { std::cout << "Base1::overwrite_func" << std::endl; } virtual void base1_func() { std::cout << "Base1::base1_func" << std::endl; } }; class Base2 : public Base { public: Base2() {} virtual ~Base2() {} virtual void overwrite_func() { std::cout << "Base2::overwrite_func" << std::endl; } virtual void base2_func() { std::cout << "Base2::base2_func" << std::endl; } }; class Derived : public Base1, public Base2 { public: Derived() {} virtual ~Derived() {} virtual void overwrite_func() { std::cout << "Derived::overwrite()" << std::endl; } virtual void derived_func() { std::cout << "Derived::derived_func" << std::endl; } private: int z; };
查看內存布局如下:
可見內存布局與多繼承並無明顯差別,可以先按照單繼承規則來安排Base1類和Base2類的布局,之后再按照多繼承規則安排Derived類對象內存的布局。需要注意的是最終Derived對象的內存模型中會包含兩個Base祖父基類的部分。在這種情況下,如果想要使用祖父基類中的成員x就必須這么寫:derived_obj.Base1::x,或者derived_obj.Base2::x。否則會引發歧義。
3、考慮虛擬繼承
3.1:單繼承
測試代碼如下:
class Base1 { public: Base1(){} virtual ~Base1() {} virtual void overwrite_func() { std::cout << "Base1::overwrite_func" << std::endl; } virtual void base1_func() { std::cout << "Base1::base1_func" << std::endl; } private: int x; }; class Derived : virtual public Base1 { public: Derived() {} virtual ~Derived() {} virtual void overwrite_func() { std::cout << "Derived::overwrite()" << std::endl; } virtual void derived_func() { std::cout << "Derived::derived_func" << std::endl; } private: int z; };
觀察內存布局:
可以看出此時對象的內存布局大概分為三個部分,由上到下分別為:
- 本類部分,包含一個虛指針和緊接后面的虛基類指針和成員變量,虛指針指向的虛函數表只存放本類中新添加的虛函數地址。虛基類指針指向的是虛基類表,表中第一項存放的是虛基類指針和對象起始地址的偏移量,接下來各項分別是從左到右各個基類部分相對於對象起始地址的偏移量。
- 4個字節的空白部分,用於分割第一部分和第三部分。
- 第三部分是基類部分,首先是虛指針,這個虛指針指向的虛表存放着繼承下來和改寫過后的虛函數地址。之后是基類的成員變量。
所以由上看出,此時子類有着兩張表,子類內存部分虛指針指向的那張表存放新添加的虛函數地址,基類部分虛指針指向的那張表存放的是繼承下來和改寫過后的虛函數的地址。
3.2:多繼承
測試代碼如下:
class Base1 { public: Base1(){} virtual ~Base1() {} virtual void overwrite_func() { std::cout << "Base1::overwrite_func" << std::endl; } virtual void base1_func() { std::cout << "Base1::base1_func" << std::endl; } private: int x; }; class Base2 { public: Base2() {} virtual ~Base2() {} virtual void overwrite_func() { std::cout << "Base2::overwrite_func" << std::endl; } virtual void base2_func() { std::cout << "Base2::base2_func" << std::endl; } private: int y; }; class Derived : virtual public Base1, virtual public Base2 { public: Derived() {} virtual ~Derived() {} virtual void overwrite_func() { std::cout << "Derived::overwrite()" << std::endl; } virtual void derived_func() { std::cout << "Derived::derived_func" << std::endl; } private: int z; };
觀察內存布局:
可見此時內存布局情況和單虛擬繼承下的差不了多少
- 首先是本類的部分,包含虛指針和虛基類指針以及成員變量,本類部分中的虛指針指向的虛函數表存放着新添加的虛函數的地址。
- 之后是按照聲明順序的兩個基類的部分,第一個基類中虛指針指向的虛函數表是主表(存放重寫過后的虛函數的地址以及從第一個基類繼承下來的虛函數的地址),其它的虛函數表中只存放從對應的基類繼承下來的沒有改寫的虛函數的地址,如果要調用重寫過后的虛函數的話會利用thunk機制跳轉到主表中去。
- 各個部分之間以4個字節的全為0的項分隔。
3.3:棱形繼承
測試代碼如下:
class Base { public: Base() {} virtual ~Base() {} virtual void overwrite_func() { std::cout << "Base::overwrite_func" << std::endl; } virtual void Base_func() { std::cout << "Base::base_func" << std::endl; } private: int x; }; class Base1 : virtual public Base { public: Base1(){} virtual ~Base1() {} virtual void overwrite_func() { std::cout << "Base1::overwrite_func" << std::endl; } virtual void base1_func() { std::cout << "Base1::base1_func" << std::endl; } private: int z; }; class Base2 : virtual public Base { public: Base2() {} virtual ~Base2() {} virtual void overwrite_func() { std::cout << "Base2::overwrite_func" << std::endl; } virtual void base2_func() { std::cout << "Base2::base2_func" << std::endl; } private: int y; }; class Derived : public Base1, public Base2 { public: Derived() {} virtual ~Derived() {} virtual void overwrite_func() { std::cout << "Derived::overwrite()" << std::endl; } virtual void derived_func() { std::cout << "Derived::derived_func" << std::endl; } private: int a; };
觀察內存布局:
由上面的結果可以得出以下的一些信息:
- 把基類部分放在最前面,最左邊的基類最先安排。
- 祖父類放在最后面,在祖父類部分前面的是本類的數據成員。
- 基類部分都有各自的虛指針和虛基類指針,祖父類也有自己的虛指針。
- 本類部分沒有虛指針,而是占用了最左邊的基類的虛指針所指向的虛函數表,並將之擴充以用來保存本類新添加的虛函數的地址。
- 重寫的虛函數的地址保存在祖父類部分的虛指針指向的虛函數表中。未重寫的虛函數的地址各自保留在各自基類/祖父類對應的虛函數表中。
總結:
從新添加的虛函數,重寫的虛函數,繼承下來的虛函數的存放位置方面,來總結下VS 2017下C++對象內存模型的規律:
- 不考慮繼承情況下:
- 總是將虛指針安排在對象的起始地址處。
- 緊接着即為非靜態成員變量。
- 考慮一般性的繼承情況:
- 單繼承:首先安排基類對象,緊接着安排本類的部分。並直接擴展基類部分的函數表。
- 多繼承:按照繼承時候聲明的順序依次聲明的順序安排基類部分。此時會有多個虛函數表,將重寫后的虛函數地址以及新添加的虛函數地址都放在最左基類部分的虛表中。未重寫的還是放在自己所屬基類部分對應的虛表中。
- 菱形繼承:可首先按照單繼承規律和祖父類對象模型安排父類對象模型,再按照普通多繼承安排本類對象模型。
- 考慮虛擬繼承的情況:
- 虛擬單繼承:先安排本類部分,本類部分有自己的虛函數表用來存放本類新添加的虛函數地址。重寫/繼承下來的虛函數地址保存再基類部分對應的虛函數表中。
- 虛擬多繼承:本類部分會有自己的虛函數表來存儲新添加的虛函數地址。之后最左基類對應的虛函數表存儲重寫的虛函數地址,繼承下來的虛函數保留在各自對應基類的虛函數表中。
- 菱形虛擬繼承:首先會安排基類部分,最左基類的虛函數表會保存新添加的虛函數的地址。接下來是本類的數據部分,最后才是超類部分,其中超類部分中對應的虛函數表中保存的是重寫過后的虛函數的地址。繼承下來的虛函數的地址還是保存在各個基類對應的虛函數表中。