1. 程式人生 > >class 大小計算

class 大小計算

筆試面試時常碰到的問題,話不多說,如下:

class大小計算(visual studio下):

1、non-static data member的大小

2、virtual函式的vptr

3、virtual base class 的 virtual base class pointer

4、齊位造成的補足空間(可能補在成員間可能補在物件邊界)

一、non-static data member

非靜態成員的大小,各種內建型別的大小是多少就不列出了,略過。

個人覺得要注意的一種情況是:空物件,即無資料成員的物件;這種空物件作為獨立個體使用的時候是強制佔位1個char的

不過老實說,不是口味獨特得想要故意問你這樣一個問題,平時應該基本不會糾結一個沒有資料成員的物件的例項大小的,更多是作為一個基類來繼承。那麼此時出現第二個要注意的問題:繼承空的基類不會引起物件變大

沒錯,單獨使用它時,系統需要一個“此物件存在”的依據,這個物件要在記憶體留下一定痕跡,系統才能夠了解到“此物件存在”。所以強制放入一個char(啊,vs是這麼做的)來佔位 1,也就是sizeof()操作符取得的大小為1(對,sizeof()是操作符,不是庫函式)。

但是當這個物件不是獨立的個體的時候,當它成為了其他類的subobject部分(巢狀基類部分)的時候,它又不需要佔有位置了,因為它的派生類可以證明這個類存在,所以沒有理由為派生類產生一個額外的負擔。

這個時候出現一個很有意思,但我真的覺得沒什麼實際作用的問題:一個空的派生類繼承一個空的基類會怎麼樣?啊,是啊,值得思考,不過你到底是為什麼要用兩個空的類相互繼承?好吧,拋開這個,我們考慮一下實際的。

class empty1 {};
class empty2 :public empty1 {};
class ch :public empty1 { char a; };
int main()
{
	cout << sizeof(empty1) << endl;              //1
	cout << sizeof(empty2) << endl;              //?
	cout << sizeof(ch) << endl;                  //1
	system("pause");
}

好吧,empty2多大?劇透一下,是1 :)。所以很明顯了,只需要一個char證明整個類存在就ok了,不要每個subobject都放一個,沒意義。

接著,第二個有意思沒用途的問題,有虛指標但是沒有資料成員的類繼承空的基類要加上一個char的位置嗎?啊,先不吐槽這個有點奇怪的設計。

class empty1 {};
class empty2 :public empty1 {};
class vptr :public empty2 { virtual void fun() {} };//看這裡
class ch :public empty1 { char a; };
int main()
{
	cout << sizeof(empty1) << endl;
	cout << sizeof(empty2) << endl;
	cout << sizeof(ch) << endl;
	cout << sizeof(vptr) << endl;//還有這裡
	system("pause");
}

好的,vptr多大?嘛,和你想的一樣,4,並沒有加上一個char啦(或許有人會覺得是兩個?那請回去看上一段)。也就是說,不是顯示定義的資料成員才能證明一個類存在的,vptr(虛指標)一樣可以。另外,這裡把virtual函式改成virtual繼承一樣是4,它們帶來的vptr大小實在沒區別。

二、vitual函式的vptr

嘛有人會說:這一類沒什麼好說的,有virtual那就加一個vptr,沒有就沒有咯。

啊,大概還有補充版本:不過不是說只有一個vptr,計算vptr的時候要把每個subobject也獨立看待,所以每個含有vptr的subobject都會增加一個vptr。

這個問題看起來應該篇幅很短,很簡單就說的明白,不難理解。

首先,不知道vptr得先知道vptr:用來實現virtual函式的工作而加入到類當中的指標->虛指標(啊,虛繼承的指標之後再談)。

使用virtual函式才能夠實現到動態繫結所需的效果:在使用基類  指標/引用  去  指向/繫結  派生類物件的時候呼叫派生類版本的virtual函式。啊,具體怎麼實現,那大概會有另一篇博文吧(看我心情嘍)。總之你使用了virtual,那就要加一個vptr到類裡(啊,你用幾個virtual就不影響有幾個vptr了)。這麼說來的話,每個使用了virtual的巢狀基類為了符合“subobject應當能夠發揮完整實體的同樣功能”這一點,也需要自己的vptr來訪問自己的virtual函式,也就像上面一樣,每個subobject都要增加vptr。

理論上,是這樣的啊,很好理解,有virtual就要vptr,每個有virtual的基類都增加一個vptr,完全ok。

所以下面這個測試的結果應該是 4 ,4,12,12嘍(vs下執行)

class vptr1 { virtual void fun() {} };
class vptr2 { virtual void fun() {} };
class non_vptr { int elem; };
class vptr3 :public non_vptr,public vptr2 { virtual void fun() {} };
class vptr4 :public vptr1, public vptr2 { virtual void fun() {} };
int main()
{
	cout << sizeof(vptr1) << endl;
	cout << sizeof(vptr2) << endl;
	cout << sizeof(vptr3) << endl;
	cout << sizeof(vptr4) << endl;
	system("pause");
}

vptr1,2,3,4本身都有虛擬函式,所以他們自己至少要一個vptr,vptr3,4繼承了vptr1,2,所以他們分別得到1個、2個虛指標。

vptr3 = 自己的虛指標4 + non_vptr資料成員4 + vptr2的虛指標4   =  12;

vptr3 = 自己的虛指標4 + vptr1,2的兩個虛指標4 * 2 = 12;

對吧,不過結果是4,4,8,8.

emmmmmmm?

所以出現了偏差,啊,大概不是偏差,是錯誤。

錯誤的出現來自於一個優化,以及編譯器的實現方式。細說的話需要另一整篇啦,所以只是給出一,個結論:visual studio中,編譯器把新增的虛指標放在類的首部(或subobject的首部),並且最底層的派生類derived_bottom與最頂層的基類base_top共用一個虛指標,若按照繼承順序,擁有虛指標的首個基類將會被提前到頂部。那麼來看看這幾點如何得到我們之前的4,4,8,8的結果,但首先我得說,這兩點都不是強制的,只是說vs一般這麼做。

1、將虛指標放在首部

在vptr4中,vptr1在宣告順序中為最頂層的基類,vptr1部分的subobject將處於vptr4類的開始,vptr1的虛指標就是放在vptr4的開頭位置;那麼此時,vptr1與vptr2以subobject先後放置在vptr4開頭,vptr4本身是一個類(而不是subobject),其虛指標應該放在自己的開頭,也就意味著vptr4的虛指標和vptr1虛指標其實放在了同一個位置,當然,你也可以說vptr4的虛指標不是應該放在vptr4自己獨有部分的開頭嗎?沒錯,但這時便需要提到為了優化而實現的第二點。

2、最底層的派生類(其實也就是當前類,只不過派生類獨有的部分放在最底部)和最頂層的基類共享一個虛指標

由上一點,vptr4的虛指標和vptr1的虛指標放在同一位置,所以加上這一點,vptr4的虛指標和vptr1變成了同一個,這是一項優化,用以減少一個虛指標的負載,本來vptr1與vptr4都應該有獨立的虛指標,但通過調整到同一個位置我們減少了一個虛指標。為啥要讓他們共享嘞,前面我有提到呼叫虛擬函式時是通過移動物件的指標來指向不同部分的虛指標以呼叫不同的虛擬函式,那麼把虛指標放在首部,便省去了一次調整指標的操作,因為物件指標指向物件開始,物件的虛指標又放在物件開頭,那麼呼叫自己的版本時也就省去了一次移動;若要呼叫的是最頂層類的版本,那麼最頂層的類的虛指標就放在物件的開頭,也就是說,讓最頂層類與最底層部分共享一個虛指標就可以減少兩種呼叫情況的指標調整,還可以省去一個虛指標的負載,在規則上也符合“把虛指標放在開頭”的規則,那就這麼做吧!

編譯器當然也可以不這麼做,也可以像之前說的吧vptr4部分的虛指標放到vptr4部分的首部,而不是整個類的首部,不過多了一個虛指標,然後在呼叫虛擬函式的時候需要進行額外的指標偏移罷了(啊,說起來果然還是共享吧)。

總而言之,虛指標應該是n-1個,n為有虛指標的類。

class vptr1 { virtual void fun() {} };
class vptr2 { virtual void fun() {} };
class vptr3 { virtual void fun() {} };
class vptr4 :public vptr1, public vptr2, public vptr3 { virtual void fun() {} };
int main()
{
	cout << sizeof(vptr4) << endl;
	system("pause");
}

結果為12,(4-1) * 4

3、virtual base class帶來的虛指標

略,見下綜合virtual。

4、齊位造成的補足空間

計算機為了湊夠足夠大的記憶體碎片而為類強制加入的空間。規則是每個成員只能放在自己所佔位元組數的倍數的地址,並且類的總大小必須是最大內建型別成員的倍數,如果有其他物件存在,則把那個物件的成員分離成內建型別計算。文字描述的話很模糊,舉一些例子。

class add {
	char c;
	int i;
	char c2;
};
int main()
{
	cout << sizeof(add) << endl;
	system("pause");
}

add的大小是多少呢?1+4+1 = 6?

實際是12 -> 3*4

add有三個成員,對於c來說,c本身佔1位元組,c可以在任何地址存放;和他相鄰的i為4位元組,故只能在4的倍數開始的地址存放(當然這裡指的不是實際的地址,而是在類開始的一個偏移量),於是在c與i之間補上3位元組使得i存放在位置“4”。那麼僅按照這一點的話add大小應該為1+3+4+1 = 9;但是add中最大的內建成員為int i;所以add的大小需要是i的倍數,故在最後再補上3個位元組,得到sizeof(add) = 1+3+4+1+3 = 12

一個例子是不夠的,還需要其他的例子

class add {
	char c;
	double d;
	char i;
	char c2;
};
int main()
{
	cout << sizeof(add) << endl;
	system("pause");
}

修改add如上,結果變為24。

根據之前的方式得到 1+7+8+1+1+6 = 24;這便說明了最後是將add補足到最大數位double型的倍數。

那麼如果有其他的物件出現,並且物件的大小大於double呢?是否也補足到物件的倍數?

class test {
	int i1, i2, i3, i4;
};
class add {
	double d1, d2;
	test t1;
	char c1;
};
int main()
{
	cout << sizeof(add) << endl;
	system("pause");
}

做出如上的修改,得到40。此時類中最大的成員是test,16位元組。但是40並不是16的倍數,所以並不是補足到最大成員的倍數,而是最大內建型別的倍數,也就是說可以把類物件成員拆解成內建型別來比較。此處的add大小 = 8+8+4+4+4+4+1+7 = 40。

5、綜合virtual

那麼,我先略過了virtual base帶來的虛指標,其實分開也是可以的啦,只不過我堅持認為應該在齊位之後在來理解兩個虛擬混合的情況,而且需要虛擬繼承(說真的如果有虛擬繼承你應該考慮修改你的設計,特別是你的虛基類有資料成員的時候)的時候通常你的基類中都會有虛擬函式指標。

首先,由於 齊位 和 空物件補充char 這兩點會對物件大小造成影響,故先除去這兩個變數;辦法是為每個用來測試的物件增加一個大小為4(int)的成員,也就是大小等於虛擬指標的成員,來保證編譯器不需要為了保證物件存在插入char,也不必為了齊位而新增無意義的位元組,因為你所有的成員都一樣大。

先從簡單的開始,單一繼承

class vb1 { 
public:
	virtual void fun() {}
	int a;
};

class d1 :virtual vb1{
	virtual void fun() {}
	int b;
};

int main()
{
	cout << sizeof(d1) << endl;
	system("pause");
}

求d1大小,啊,不要說20,是16,說20的請回顧之前。

d1 = vb1的int成員大小4 + 自己的int成員大小4 + 共用的虛擬函式指標大小4 + 虛繼承產生的指標4 = 16

沒有任何問題,相當於複習。

接著是兩個虛繼承

class vb1 { 
public:
	virtual void fun() {}
	int a;
};
class vb2 { 
public:
	virtual void fun() {}
	int b;
};

class d1 :virtual vb1,virtual vb2{ //虛繼承兩個基類
	virtual void fun() {}
	int c;
};
int main()
{
	cout << sizeof(d1) << endl;
	system("pause");
}

那麼增加了什麼呢?看起來是vb2的int成員大小4 + vb2的虛擬函式指標4 + 指向vb2的虛繼承產生的指標大小4 = 12

故結果應該是16 + 12 = 28。

啊,結果是24,我們先不解釋,先看三個虛繼承

class vb1 { 
public:
	virtual void fun() {}
	int a;
};
class vb2 { 
public:
	virtual void fun() {}
	int b;
};
class vb3 {
public:
	virtual void fun() {}
	int c;
};

class d1 :virtual vb1,virtual vb2,virtual vb3{//繼承三個虛擬基類
	virtual void fun() {}
	int d;
};
int main()
{
	cout << sizeof(d1) << endl;
	system("pause");
}

啊,按照剛才的演算法d1的大小應該是16 + 12 + 12 = 40

不過結果是32,剩下的8呢?

那麼這裡出現了一個問題,“是不是每一個虛基類都需要在派生類中增加一個虛指標”。

顯然,一般成員帶來的負載是4,虛擬函式指標帶來的負載也是4,故每個虛基類的增加至少的負載是4,實際上也是這樣,每增加一個虛基類,派生類的大小增加8.vs如何做到把虛基類指標的負載“消除”呢?

這裡需要提到微軟的virtual base table,啊是不是有點眼熟。對,和virtual function table很像,事實上也真的很像,只是把儲存虛擬函式地址換成了儲存虛基類的地址。

也就是說,出現虛擬繼承後,編譯器為這個物件增加一個指向virtual base table的vptr,每增加一個虛繼承基類就在這個table中增加一個slot(增加一格)用來儲存新的虛擬基類,這樣就解決了虛擬繼承帶來的大小膨脹,同時也擁有固定的存取速度(增加由vptr指向vbase class再取得成員的一層間接)。帶來的代價是多一層間接、對所有建構函式、解構函式、其他拷貝構造成員進行增強來支援這個機制。

所以結論是,虛擬繼承只會帶來一個vptr的負載,不管有多少個虛繼承的基類。

驗證:

//虛擬指標大小測試
class vb1 { 
public:
	virtual void fun() {}
	int a;
};
class vb2 { 
public:
	virtual void fun() {}
	int a;
};
class vb3 { int a; };
class vb4 { int a; };
class vb5 { int a; };
class d1 :virtual vb1,virtual vb2,virtual vb3,virtual vb4,virtual vb5{
	virtual void fun() {}
	int b;
};//5個虛擬繼承基類
int main()
{
	cout << sizeof(d1) << endl;
	system("pause");
}

結果是兩個虛擬基類結果的24加上vb3、vb4、vb5的整型成員大小4 * 3 即 24 +12 = 36(對,我真的沒騙你,沒有虛指標)

另一種情況:

啊,這破文章真的有點長了(並不),不過或許值得。

大概有人注意到了,我之前說了那麼多,結果把virtual base calss自己最主要的用處給省略了;為啥要使用virtual base class,為了省去不同直接基類繼承來的間接基類的負載。

舉慄:

class vb1 { 
public:
	//virtual void fun() {}
	int n1;
};
class vb2 :public virtual vb1 { int n2; };
class vb3 :public virtual vb1 { int n3; };//虛繼承vb1
class vb4 :vb1 { int n4; };
class vb5 :vb1 { int n5; };//一般繼承vb1

class d1 : vb2, vb3 {
	int n6;
};//虛擬繼承d1

class d2 : vb4, vb5 {
	int n6;
};//一般繼承d2
int main()
{
	cout << sizeof(vb2) << endl;
	cout << sizeof(d1) << endl;
	cout << sizeof(d2) << endl;
	system("pause");
}

為了消除齊位balabala的影響我只在物件裡放了一個int,也除去了virtual function。

vb2 = 12,d1 = 24,d2 = 20。

啊,結果虛擬繼承的d1反而增加了自己的體積,這也說明你真的應該慎重使用虛擬繼承。

那麼來看看為什麼它們各自是那麼大。

首先d1,vb2、vb3中放置著各自的virtual base table pointer,對,這倆傢伙都有一個(從vb2你可以得知這一點),但是d1自己這一部分是沒有的,d1是通過vb2,vb3來呼叫虛擬基類的。仔細想想這完全沒問題,可能你會想,vb2裡又沒有重複的vb1,為什麼要給它增加額外的負擔,應該把vptr放到d1中。啊,不錯的想法,那麼怎麼實現呢?若把vptr增加到d1中,如何區分要增加哪個vptr呢?d1繼承自vb2與vb3,vb2、vb3擁有共同的基類vb1,那麼是在繼承vb2時增加vptr還是vb3時增加vptr,如何區分vb2與vb3,甚至更多7、8、9、10的繼承呢?再者,如果d1虛擬繼承自vb2,vb3呢?你當然也應該為d1增加虛指標,可是d1和vb2、vb3一樣是虛擬繼承自某個類,你如何區分哪些虛擬繼承要增加虛指標,而哪些又不要?

追根究底你會找到某些方法,但是實際上廠商顯然沒有打算為了這樣一個優化付出相當複雜的邏輯判斷作為代價,它們決定簡單得給每個使用了virtual繼承的類增加一個vptr

vb2 = vb2自己的整型成員大小4 + 自己的vptr大小4 + vb1的資料成員大小4 = 12

d1 = vb2、vb3的int成員大小4*2 + vb2、vb3的vptr4*2 + d1自己的int成員大小4 + 不重複的vb1的int成員大小4= 24

d2 = vb2、vb3的int成員大小4*2 + d2自己的int成員大小4 + 加上重複的vb1的int成員4*2 = 20

啊,你肯定注意到我註釋了一行虛擬函式,那麼去掉註釋再執行一次,發現結果變成

vb2 = 16,d1 = 28、d2 = 28;

是不是在你意料之中呢?

解釋是對於d1:虛擬繼承消除了重複的vptr

對於d2:它增加了兩個vptr的負載

所以請在真的可以帶來很大記憶體影響的時候再使用虛擬繼承,因為它會打亂全部的記憶體佈置,真的很討厭。

(個人理解,請絕對不要完全相信我,有錯請不吝賜教)