1. 程式人生 > 其它 >C++類的大小

C++類的大小

技術標籤:C++

c++中類所佔的大小計算並沒有想象中那麼簡單,因為涉及到虛擬函式成員,靜態成員,虛繼承,多繼承以及空類等,不同情況有對應的計算方式,在此對各種情況進行總結。

首先要明確一個概念,平時所宣告的類只是一種型別定義,它本身是沒有大小可言的。 我們這裡指的類的大小,其實指的是類的物件所佔的大小。因此,如果用sizeof運算子對一個型別名操作,得到的是具有該型別實體的大小。

關於類/物件大小的計算

  1. 首先,類大小的計算遵循結構體的對齊原則
  2. 類的大小與普通資料成員有關,與成員函式和靜態成員無關。即普通成員函式,靜態成員函式,靜態資料成員,靜態常量資料成員均對類的大小無影響
  3. 虛擬函式對類的大小有影響,是因為虛擬函式表指標帶來的影響
  4. 虛繼承對類的大小有影響,是因為虛基表指標帶來的影響
  5. 空類的大小是一個特殊情況,空類的大小為1

解釋說明

靜態資料成員之所以不計算在類的物件大小內,是因為類的靜態資料成員被該類所有的物件所共享,並不屬於具體哪個物件,靜態資料成員定義在記憶體的全域性區。
空類的大小,以及含有虛擬函式,虛繼承,多繼承是特殊情況,接下來會一一舉例說明

循結構體的對齊原則

為了訪問速度和效率,需要各種型別資料按照一定的規則在空間上排列;不是所有的硬體平臺都能訪問任意地址上的任意資料的;某些硬體平臺只能在某些地址處取某些特定型別的資料,否則丟擲硬體異常。為了訪問未對⻬的記憶體,處理器需要作兩次記憶體訪問;⽽對⻬的記憶體訪問僅需要⼀次訪問。

於是有了位元組對齊,4個位元組是一個自然對齊
為什麼是4個位元組?
32位機,即計算機資料匯流排寬度為32個,一次可以處理32位bit(即4個位元組)64位機,就是8位元組;

舉個例子

struct MyStruct 
{ 
	char a;              
	//偏移量為0,滿足對齊方式,a佔用1個位元組,(最大型別位元組數為8,佔8位元組);
	double b;            
	//下一個可用的地址的偏移量為1,不是sizeof(double)=8的倍數,需要補足7個位元組才能使偏移量變為8(滿足對齊方式),因此自動填充7個位元組,b存放在偏移量為8的地址上,它佔用8個位元組。 
int c;              //下一個可用的地址的偏移量為16,是sizeof(int)=4的倍數,滿足int的對齊方式,所以不需要VC自動填充,c存放在偏移量為16的地址上,它佔用4個位元組。 }; //所有成員變數都分配了空間,空間總的大小為1+7+8+4=20,不是結構的節邊界數(即結構中佔用最大空間的型別所佔用的位元組數sizeof(double)=8)的倍數, //所以需要填充4個位元組,以滿足結構的大小為sizeof(double)=8的倍數,24位元組大小

一.簡單情況的計算


class Base
{
public:
	Base() {};
	~Base() {};

private:
	static int a;
	int b;
	char c;
};

計算結果:8
靜態變數a不計算在物件的大小內,由於位元組對齊,結果為4+4=8

二.空類的大小
C++的空類是指這個類不帶任何資料,即類中沒有非靜態(non-static)資料成員變數,沒有虛擬函式(virtual function),也沒有虛基類(virtual base class)。

直觀地看,空類物件不使用任何空間,因為沒有任何隸屬物件的資料需要儲存。然而,C++標準規定,凡是一個獨立的(非附屬)物件都必須具有非零大小。換句話說,c++空類的大小不為0

class Base
{
};

Base base;
cout << sizeof(base) << endl; // 1

C++標準指出,不允許一個物件(當然包括類物件)的大小為0,不同的物件不能具有相同的地址。這是由於:

new需要分配不同的記憶體地址,不能分配記憶體大小為0的空間
避免除以 sizeof(T)時得到除以0錯誤
故使用一個位元組來區分空類。

但是,有兩種情況值得我們注意
第一種情況,涉及到空類的繼承。
當派生類繼承空類後,派生類如果有自己的資料成員,而空基類的一個位元組並不會加到派生類中去。例如

class Empty {};
struct D : public Empty { int a;};

sizeof(D)4

第二種情況,一個類包含一個空類物件資料成員。

class Empty {};
class HoldsAnInt {
    int x;
    Empty e;
};

sizeof(HoldsAnInt)為8。
因為在這種情況下,空類的1位元組是會被計算進去的。而又由於位元組對齊的原則,所以結果為4+4=8。

繼承空類的派生類,如果派生類也為空類,大小也都為1

三.含有虛擬函式成員
//環境VS2015 x64

首先,要介紹一下虛擬函式的工作原理:

虛擬函式(Virtual Function)是通過一張虛擬函式表(Virtual Table)來實現的。編譯器必需要保證虛擬函式表的指標存在於物件例項中最前面的位置(這是為了保證正確取到虛擬函式的偏移量)。
  
每當建立一個包含有虛擬函式的類或從包含有虛擬函式的類派生一個類時,編譯器就會為這個類建立一個虛擬函式表(VTABLE)儲存該類所有虛擬函式的地址,其實這個VTABLE的作用就是儲存自己類中所有虛擬函式的地址,可以把VTABLE形象地看成一個函式指標陣列,這個陣列的每個元素存放的就是虛擬函式的地址。在每個帶有虛擬函式的類 中,編譯器祕密地置入一指標,稱為v p o i n t e r(縮寫為V P T R),指向這個物件的V TA B L E。 當構造該派生類物件時,其成員VPTR被初始化指向該派生類的VTABLE。所以可以認為VTABLE是該類的所有物件共有的,在定義該類時被初始化;而VPTR則是每個類物件都有獨立一份的,且在該類物件被構造時被初始化。

假設我們有這樣的一個類:

class Base {
public:

virtual void f() { cout << "Base::f" << endl; }

virtual void g() { cout << "Base::g" << endl; }

virtual void h() { cout << "Base::h" << endl; }

};

當我們定義一個這個類的例項,Base b時,其b中成員的存放如下:
在這裡插入圖片描述

指向虛擬函式表的指標在物件b的最前面。虛擬函式表的最後多加了一個結點,這是虛擬函式表的結束結點,就像字串的結束符”\0”一樣,其標誌了虛擬函式表的結束。這個結束標誌的值在不同的編譯器下是不同的。在vs下,這個值是NULL。而在linux下,這個值是如果1,表示還有下一個虛擬函式表,如果值是0,表示是最後一個虛擬函式表。

因為物件b中多了一個指向虛擬函式表的指標,而指標的sizeof是8,因此含有虛擬函式的類或例項最後的sizeof是實際的資料成員的sizeof加8。

例如:

class Base {
public:

int a;

virtual void f() { cout << "Base::f" << endl; }

virtual void g() { cout << "Base::g" << endl; }

virtual void h() { cout << "Base::h" << endl; }
};

sizeof(Base)為16。
vptr指標的大小為8,又因為物件中還包含一個int變數,位元組對齊得8+8=16。

下面將討論針對基類含有虛擬函式的繼承討論:

(1)在派生類中不對基類的虛擬函式進行覆蓋,同時派生類中還擁有自己的虛擬函式,比如有如下的派生類:

class Derived: public Base
{

public:

virtual void f1() { cout << "Derived::f1" << endl; }

virtual void g1() { cout << "Derived::g1" << endl; }

virtual void h1() { cout << "Derived::h1" << endl; }

};

基類和派生類的關係如下:
這裡寫圖片描述

當定義一個Derived的物件d後,其成員的存放如下:

在這裡插入圖片描述

可以發現:

1)虛擬函式按照其宣告順序放於表中。

2)基類的虛擬函式在派生類的虛擬函式前面。

此時基類和派生類的sizeof都是資料成員的大小+指標的大小8。

(2)在派生類中對基類的虛擬函式進行覆蓋,假設有如下的派生類:

class Derived: public Base
{

public:

virtual void f() { cout << "Derived::f" << endl; }

virtual void g1() { cout << "Derived::g1" << endl; }

virtual void h1() { cout << "Derived::h1" << endl; }

};

基類和派生類之間的關係:其中基類的虛擬函式f在派生類中被覆蓋了
這裡寫圖片描述

當我們定義一個派生類物件d後,其d的成員存放為:
這裡寫圖片描述

可以發現:

1)覆蓋的f()函式被放到了虛表中原來基類虛擬函式的位置。

2)沒有被覆蓋的函式依舊。

派生類的大小仍是基類和派生類的非靜態資料成員的大小+一個vptr指標的大小

這樣,我們就可以看到對於下面這樣的程式,

Base *b = new Derive();

b->f();

由b所指的記憶體中的虛擬函式表的f()的位置已經被Derive::f()函式地址所取代,於是在實際呼叫發生時,是Derive::f()被呼叫了。這就實現了多型。

(3)多繼承:無虛擬函式覆蓋
假設基類和派生類之間有如下關係:
這裡寫圖片描述

對於派生類例項中的虛擬函式表,是下面這個樣子:
這裡寫圖片描述

我們可以看到:

1) 每個基類都有自己的虛表。

2) 派生類的成員函式被放到了第一個基類的表中。(所謂的第一個基類是按照宣告順序來判斷的)

由於每個基類都需要一個指標來指向其虛擬函式表,因此d的sizeof等於d的資料成員加上二個指標的大小。

(4)多重繼承,含虛擬函式覆蓋
假設,基類和派生類又如下關係:派生類中覆蓋了基類的虛擬函式f
這裡寫圖片描述

下面是對於派生類例項中的虛擬函式表的圖:
這裡寫圖片描述

我們可以看見,三個基類虛擬函式表中的f()的位置被替換成了派生類的函式指標。這樣,我們就可以任一靜態型別的基類類來指向派生類,並呼叫派生類的f()了。如:

Derive d;
Base1 *b1 = &d;
Base2 *b2 = &d;
Base3 *b3 = &d;

b1->f(); //Derive::f()
b2->f(); //Derive::f()
b3->f(); //Derive::f()
b1->g(); //Base1::g()
b2->g(); //Base2::g()
b3->g(); //Base3::g()

此情況派生類的大小也是類的所有非靜態資料成員的大小+三個指標的大小

舉一個例子具體分析一下大小吧:

#include<iostream>
using namespace std;
class A     
{     
};    

class B     
{  
    char ch;     
    virtual void func0()  {  }   
};   

class C    
{  
    char ch1;  
    char ch2;  
    virtual void func()  {  }    
    virtual void func1()  {  }   
};  

class D: public A, public C  
{     
    int d;     
    virtual void func()  {  }   
    virtual void func1()  {  }  
};     
class E: public B, public C  
{     
    int e;     
    virtual void func0()  {  }   
    virtual void func1()  {  }  
};  

int main(void)  
{  
    cout<<"A="<<sizeof(A)<<endl;    //result=1  
    cout<<"B="<<sizeof(B)<<endl;    //result=16     
    cout<<"C="<<sizeof(C)<<endl;    //result=16 
    cout<<"D="<<sizeof(D)<<endl;    //result=24 
    cout<<"E="<<sizeof(E)<<endl;    //result=40  
    return 0;  
}  

結果分析:
//Win64
1.A為空類,所以大小為1
2.B的大小為char資料成員大小+vptr指標大小。由於位元組對齊,大小為8+8=16
3.C的大小為兩個char資料成員大小+vptr指標大小。由於位元組對齊,大小為8+8=16
4.D為多繼承派生類,由於D有資料成員,所以繼承空類A時,空類A的大小1位元組並沒有計入當中,D繼承C,此情況D只需要一個vptr指標,所以大小為資料成員加一個指標大小。由於位元組對齊,大小為16+8=24
5.E為多繼承派生類,此情況為我們上面所講的多重繼承,含虛擬函式覆蓋的情況。此時大小計算為基類大小加本地資料
考慮位元組對齊,結果為16+16+8=40

四.虛繼承的情況

虛繼承和虛擬函式是完全無相關的兩個概念。
虛繼承是解決C++多重繼承問題的一種手段,從不同途徑繼承來的同一基類,會在子類中存在多份拷貝。這將存在兩個問題:其一,浪費儲存空間;第二,存在二義性問題,通常可以將派生類物件的地址賦值給基類物件,實現的具體方式是,將基類指標指向繼承類(繼承類有基類的拷貝)中的基類物件的地址,但是多重繼承可能存在一個基類的多份拷貝,這就出現了二義性。

虛繼承可以解決多種繼承前面提到的兩個問題:

虛繼承底層實現原理與編譯器相關,一般通過虛基類指標和虛基類表實現,每個虛繼承的子類都有一個虛基類指標(佔用一個指標的儲存空間,4位元組)和虛基類表(不佔用類物件的儲存空間)(需要強調的是,虛基類依舊會在子類裡面存在拷貝,只是僅僅最多存在一份而已,並不是不在子類裡面了);當虛繼承的子類被當做父類繼承時,虛基類指標也會被繼承。

實際上,vbptr指的是虛基類表指標(virtual base table pointer),該指標指向了一個虛基類表(virtual table),虛表中記錄了虛基類與本類的偏移地址;通過偏移地址,這樣就找到了虛基類成員,而虛繼承也不用像普通多繼承那樣維持著公共基類(虛基類)的兩份同樣的拷貝,節省了儲存空間。

在這裡我們可以對比虛擬函式的實現原理:他們有相似之處,都利用了虛指標(均佔用類的儲存空間)和虛表(均不佔用類的儲存空間)。

虛基類依舊存在繼承類中,只佔用儲存空間;虛擬函式不佔用儲存空間。

虛基類表儲存的是虛基類相對直接繼承類的偏移;而虛擬函式表儲存的是虛擬函式地址。

虛繼承(多重繼承和虛擬函式)
//win64

class CommonBase
{
    int co;
};// size = 4
 
class Base1: virtual public CommonBase          
{
public:
    virtual void print1() {  }
    virtual void print2() {  }
private:
    int b1;
};//8副本+8虛指標+8自身+8(虛繼承+虛擬函式構成指標多一個)=32
 //此時位元組向8對齊,所以基類和自身int此時佔8位元組,
 //位元組對齊詳細看最開頭部分
 
class Base2: virtual public CommonBase        
{
public:
    virtual void dump1() {  }
    virtual void dump2() {  }
private:
    int b2;
};//同理32
 
class Derived: public Base1, public Base2     
{
public:
    void print2() {  }
    void dump2() {  }
private:
    int d;
};//32+32-8+8

解析:如果不是虛繼承的類,即便有虛擬函式也不會因此增加儲存空間,如果是虛繼承的類,沒有虛擬函式就新增一個虛指標空間,有虛擬函式不論多少個,就新增兩個虛指標空間

虛繼承與虛擬函式

class A
{
public:
    virtual void aa() {  }
    virtual void aa2() {  }
private:
    char ch[3];
}; // 8+8(虛指標) = 補齊 = 16
 
class B: virtual public A
{
public:
    virtual void bb() {  }
    virtual void bb2() {  }
}; // 16(副本)+8(虛繼承)+8(虛指標) = 32
 
int main(void)
{
    cout<<"A's size is "<<sizeof(A)<<endl;//        16
    cout<<"B's size is "<<sizeof(B)<<endl;//       32
    return 0;
}