C++虛函數(shù)表與類的內存分布深入分析理解
不可定義為虛函數(shù)的函數(shù)
類的靜態(tài)函數(shù)和構造函數(shù)不可以定義為虛函數(shù):
靜態(tài)函數(shù)的目的是通過類名+函數(shù)名訪問類的static變量,或者通過對象調用staic函數(shù)實現(xiàn)對static成員變量的讀寫,要求內存中只有一份數(shù)據(jù)。而虛函數(shù)在子類中重寫,并且通過多態(tài)機制實現(xiàn)動態(tài)調用,在內存中需要保存不同的重寫版本。
構造函數(shù)的作用是構造對象,而虛函數(shù)的調用是在對象已經構造完成,并且通過調用時動態(tài)綁定。動態(tài)綁定是因為每個類對象內部都有一個指針,指向虛函數(shù)表的首地址。而且虛函數(shù),類的成員函數(shù),static成員函數(shù)都不是存儲在類對象中,而是在內存中只保留一份。
將析構函數(shù)定義為虛函數(shù)的作用
類的構造函數(shù)不能定義為虛函數(shù),析構函數(shù)可以定義為虛函數(shù),這樣當我們delete一個指向子類對象的基類指針時可以達到調用子類析構函數(shù)的作用,從而動態(tài)釋放內存。
如下我們先定義一個基類和子類
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; }
我們寫一個函數(shù)做測試
void destructVirtualTable() { VirtualTableA *pa = new VirtualTableB(); useTable(pa); delete pa; } void useTable(VirtualTableA *pa) { //實現(xiàn)動態(tài)調用 pa->print(); }
程序輸出
this is virtual table B
Desturct Virtual Table B
Desturct Virtual Table A
在上面的例子中我們先在destructVirtualTable函數(shù)中new了一個VirtualTableB類型對象,并用基類VirtualTableA的指針指向了這個對象。
然后將基類指針對象pa傳遞給useTable函數(shù),這樣會根據(jù)多態(tài)原理調用VirtualTableB的print函數(shù),然后再執(zhí)行delete pa操作。
此時如果pa的析構函數(shù)不寫成虛函數(shù),那么就只會調用VirtualTableA的析構函數(shù),不會調用子類VirtualTableB的析構函數(shù),導致內存泄露。
而我們將析構函數(shù)寫成虛析構之后,可以看到先調用了子類VirtualTableB的析構函數(shù),再調用了基類VirtualTableA的析構函數(shù),達到了釋放子類空間的目的。
有人會問?將析構函數(shù)不寫為虛函數(shù),直接delete子類對象VirtualTableB,調用子類的析構函數(shù)不可以嗎?比如,如下的調用
VirtualTableB *pb = new VirtualTableB(); delete pa;
上述調用沒有問題,無論析構函數(shù)是否為虛析構都可以成功釋放子類空間。但是項目編程中常常會編寫一些通用接口,比如上面的useTable函數(shù),
它只接受VirtualTableA類型的指針,所以我們常常會用基類指針接受子類對象來通過多態(tài)的方式調用子類函數(shù),為了方便delete基類指針也要釋放子類空間,
就要將析構函數(shù)設置為虛函數(shù)。
虛函數(shù)表原理
為了介紹虛函數(shù)表原理,我們先實現(xiàn)一個基類和子類
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(虛函數(shù)表空間) 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; } };
一個類對象其內存分布的基本結構為虛函數(shù)表地址+非靜態(tài)成員變量,類的成員函數(shù)不占用類對象的空間,他們分布在一片屬于類的共有區(qū)域。
類的靜態(tài)成員函數(shù)喝成員變量不占用類對象的空間,他們分配在靜態(tài)區(qū)。
虛函數(shù)表的地址存儲在類對象的起始位置。所以我們利用這個原理,通過尋址的方式訪問虛函數(shù)表里的函數(shù)
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字節(jié),因為我的機器是64位的,所以指針類型都占用8字節(jié),int 占用4字節(jié),但是要遵循補齊原則,結構體的大小要為最大成員大小的整數(shù)倍,所以要補齊4字節(jié),那么8+4+4 = 16 字節(jié),關于類對象對齊和補齊原則稍后再詳述。
b的內存分布如下圖
這個根據(jù)不同的機器所占的字節(jié)數(shù)不一樣,在32位機器上int為4字節(jié),虛函數(shù)表地址為4字節(jié),4+4 = 8字節(jié),這個再之后再說明對齊和補齊的原則。
&b表示取b的地址,因為虛函數(shù)表地址存儲在b的起始地址,所以&b也是虛函數(shù)表的地址的地址,我們通過int*
強轉是方便存儲b的地址,因為64位機器指針都是8字節(jié),32位機器指針是4字節(jié)。
p為虛函數(shù)表的地址的地址,p+1具體移動了4個字節(jié),因為p+1移動多少個字節(jié)取決于p所指向的數(shù)據(jù)類型int,int為4字節(jié),所以p+1在p的地址移動四個字節(jié),p+2在p的地址移動8個字節(jié)。
p只想虛函數(shù)表的地址,換句話說p存儲的是虛函數(shù)表的地址,虛函數(shù)表地址占用8字節(jié),p+2就是從p向后移動8字節(jié),這樣剛好找到a的地址。
那么*(p+2)
就是取a的數(shù)值。
int*(*p)
就是取虛函數(shù)表的地址,轉為int*是方便讀寫。
我們將b的內存分布以及虛函數(shù)表結構畫出來
上圖中可以看到虛函數(shù)表中存儲的是虛函數(shù)的地址,所以通過不斷位移虛函數(shù)表的指針就可以達到指向不同虛函數(shù)的目的。
Func pFun = (Func)(*(int *)(*p)); pFun();
*(int *)(*p)
就是取出虛函數(shù)表首地址指向的虛函數(shù),再通過Func轉化為函數(shù)類型,然后調用pFun即可調用虛函數(shù)f。
所以想調用第二個虛函數(shù)g,將(int*)(*p)
加2 位移8個字節(jié)即可
pFun = (Func) * ((int *)(*p) + 2); pFun();
同樣的道理調用h就不贅述了。
繼承關系中虛函數(shù)表結構
DeriveClass繼承了BaseTest類,子類如果重寫了虛函數(shù),則子類的虛函數(shù)表中存儲的虛函數(shù)為子類重寫的,否則為基類的。
我們畫一下DeriveClass的虛函數(shù)表結構
因為函數(shù)f被DeriveClass重寫,所以DeriveClass的虛函數(shù)表存儲的是自己重寫的f。
而虛函數(shù)g和h沒有被DeriveClass重寫,所以DeriveClass虛函數(shù)表存儲的是基類的g和h。
另外DeriveClass虛函數(shù)表里也存儲了自己特有的虛函數(shù)g2和h3.
下面我們還是利用尋址的方式調用虛函數(shù)
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虛函數(shù)表里存儲的f是DeriveClass的f。
(int *)(*p)
表述取出p所指向的內存空間的內容,p指向的正好是虛函數(shù)表的地址,所以*p
就是虛函數(shù)表的地址。
因為我們不知道虛函數(shù)表的具體類型,所以轉為int*
類型,因為指針在64位機器上都是8字節(jié),可以保證空間大小正確。
接下來就是尋址和函數(shù)調用的過程,這里不再贅述。
多重繼承的虛函數(shù)表
上面的例子我們知道,如果類有虛函數(shù),那么編譯器會為該類的實例分配8字節(jié)存儲虛函數(shù)表的地址。
所有繼承該類的子類也會擁有8字節(jié)的空間存儲自己的虛函數(shù)表地址。
多重繼承的情況就是類對象空間里存儲多張?zhí)摵瘮?shù)表地址。子類繼承于兩個基類,并且基類都有虛函數(shù),那么子類就有兩張?zhí)摵瘮?shù)表。
多態(tài)調用原理
當我們通過基類指針存儲子類對象時,調用虛函數(shù),會調用子類的實現(xiàn)版本,這叫做多態(tài)。
通過前面的實驗和圖示,我們已經知道如果子類重寫了基類的虛函數(shù),那么他自己的虛函數(shù)表里存儲的就是自己實現(xiàn)的版本。
通過基類指針存儲子類對象時,基類指針實際指向的是子類的空間,尋址也是找到子類的虛函數(shù)表,從虛函數(shù)表中找到子類實現(xiàn)的虛函數(shù),
然后調用子類版本,從而達到多態(tài)效果。
對齊和補齊規(guī)則
在考察一個類對象所占空間時,虛函數(shù)、成員函數(shù)(包括靜態(tài)與非靜態(tài))和靜態(tài)數(shù)據(jù)成員都是不占用類對象的存儲空間的。對象大小= vptr(虛函數(shù)表指針,可能不止一個) + 所有非靜態(tài)數(shù)據(jù)成員大小 + Aligin字節(jié)大?。ㄒ蕾囉诓煌木幾g器對齊和補齊)
對齊:類(結構體)對象每個成員分配內存的起始地址為其所占空間的整數(shù)倍。
補齊:類(結構體)對象所占用的總大小為其內部最大成員所占空間的整數(shù)倍。
下面我們先定義幾個類
namespace AligneTest { class A { }; class B { char ch; void func() { } }; class C { char ch1; //占用1字節(jié) char ch2; //占用1字節(jié) 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是一個空類,編譯器為了區(qū)分不同的空類,所以為每個空類對象分配1字節(jié)的空間保存其信息,用來區(qū)別不同類對象。
b 是B的對象,因為B中定義了一個char成員變量和func函數(shù),func函數(shù)不占用空間,所以b的大小為char的大小,也就是1字節(jié)。
c 是C的對象,因為C中包含虛函數(shù),所以C的對象c中會分配8字節(jié)用來存儲虛函數(shù)表,虛函數(shù)表放在c內存的首地址,然后是ch1,
以及ch2。假設c的起始地址為0,那么0~7
字節(jié)存儲虛函數(shù)表地址,第8個字節(jié)是1的整數(shù)倍,所以不同對齊,第8個字節(jié)存儲ch1。
第9個字節(jié)是1的整數(shù)倍,所以第9個字節(jié)存儲ch2。那么c的大小為8 + 2 = 10, 因為補齊規(guī)則要求c的大小為最大成員大小的整數(shù)
倍,最大成員為虛函數(shù)表地址8字節(jié),所以要補齊6個字節(jié),10+6 = 16,所以c的大小為16字節(jié)。
其內存分配如下圖
d 是D的對象,因為D中包含虛函數(shù),所以D的對象d中會分配8字節(jié)空間存儲虛函數(shù)表地址,比如0~7
字節(jié)存儲虛函數(shù)表地址,接下來第8個字節(jié),
因為int為4字節(jié),8是4的整數(shù)倍,所以不需要對齊,第8~11
字節(jié)存儲in,這樣d的大小變?yōu)?+4= 12, 因為根據(jù)補齊規(guī)則需要補齊4字節(jié),總共
大小為16字節(jié)剛好是最大成員大小8字節(jié)的整數(shù)倍。所以d為16字節(jié)
其內存分配圖如下
e 是E的對象,e會為m分配1字節(jié)空間,為in分配4字節(jié)空間,假設地址0存儲m,接下來地址1存儲in。
因為對齊規(guī)則要求類(結構體)對象每個成員分配內存的起始地址為其所占空間的整數(shù)倍,1不是4的整數(shù)倍,所以要對齊。
對齊的規(guī)則就是地址后移找到起始地址為4的整數(shù)倍,所以要移動3個字節(jié),在地址為4的位置存儲in。
那么e所占的空間就是 1(m占用) + 3(對齊規(guī)則) + 4(in占用) = 8 字節(jié)。
如下圖所示
為什么要有對齊和補齊
這個要從計算機CPU存取指令說起,
上圖為32位機器內存模型,CPU通過地址總線和數(shù)據(jù)總線尋址讀寫數(shù)據(jù)。如果是64位機器,就是8列。
通過對齊和補齊規(guī)則,可以一次讀取內存中的數(shù)據(jù),不需要切割和重組,是典型的用空間換取時間的策略。
比如有如下類
class Test{ int m; int b; }
我們用Test生成了兩個對象t1和t2,他們在內存中存儲如下,無色的表示t1的內存存儲,彩色的表示t2。
在不采用對齊和補齊策略的情況下
在采用對齊和補齊策略的情況下
可見不采用對齊和補齊策略,節(jié)省空間,但是要取三次能取完數(shù)據(jù),取出后還要切割和拼接,最后才能使用。
采用對齊和補齊策略,犧牲了空間換取時間,讀取四次,但是不需要切割直接可以使用。
對于64位機器,采用對齊和補齊策略,只需讀取兩次,每次取出的都是Test對象,效率非常高。
資源鏈接
本文模擬實現(xiàn)了vector的功能。
到此這篇關于C++虛函數(shù)表與類的內存分布深入分析理解的文章就介紹到這了,更多相關C++虛函數(shù)表內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
簡要對比C語言中的setgid()函數(shù)和setregid()函數(shù)
這篇文章主要介紹了C語言中的setgid()函數(shù)和setregid()函數(shù)的簡要對比,是C語言入門學習中的基礎知識,需要的朋友可以參考下2015-08-08Linux/Manjaro如何配置Vscode的C/C++編譯環(huán)境
這篇文章主要介紹了Linux/Manjaro配置Vscode的C/C++編譯環(huán)境,本文給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2023-05-05C++實現(xiàn)LeetCode(14.最長共同前綴)
這篇文章主要介紹了C++實現(xiàn)LeetCode(14.最長共同前綴),本篇文章通過簡要的案例,講解了該項技術的了解與使用,以下就是詳細內容,需要的朋友可以參考下2021-07-07