由結構體對齊所引發的對C++類物件記憶體模型的思考(一)

Editor發表於2017-12-11

(注:本文的實驗環境是在VS201X中進行的)

結構體對齊

一般而言,結構體變數記憶體中成員的排布如下:(從第一個成員依次向下排)

  由結構體對齊所引發的對C++類物件記憶體模型的思考(一) 

 結構體資料的地址開始於第一個宣告的成員的地址,結束於最後一個成員的地址。在他們中間,按照宣告的順序儲存著所有的資料成員,但真相遠非如此,還存在著記憶體對齊的問題。對於類似於下面這樣的結構體:


struct Test {

char a;

double b;

char c;

};

int _tmain(int argc, _TCHAR* argv[])

{

Test  obj;

obj.a = 'a';

obj.b = 1564654.325;

obj.c = 'b';

cout << sizeof(Test);

}


其輸出結果為:24有些剛學的童鞋可能會問,char佔一個位元組,double佔八個位元組,8+1+1不是等於10麼,難道電腦出問題了???好,為了一探究竟,下面我們檢視其記憶體:


由結構體對齊所引發的對C++類物件記憶體模型的思考(一)


0x0036FAC8是obj的起始地址,也就是char型別的成員a的地址,但是比較奇怪的是,在a後面並沒有僅接著就儲存b,而是空了7個位元組之後再儲存b,b佔了8個位元組,之後c佔據了一個位元組之後(在記憶體中已經顯示出b),整個結構體並未結束,在其後又空置了7個位元組,故整個結構體佔24個位元組.這對於不瞭解記憶體對齊規則的人來說是很困惑的。


記憶體對齊規則


通過查閱相關文獻,記憶體對齊規則主要為以下三點:


資料的第一個成員儲存在結構體的起始位置,偏移為0 資料的後續成員儲存的偏移位置為某一個數的整數倍1.上個規則中的某一個數為編譯器預設的對齊數與這個資料成員的位元組數這兩個數中的較小值。 整個結構體的尺寸為結構體中每一次安排的某一個數中的最大值的最小整數倍。

 

下面結合之前的那個程式碼對以上規則進行解釋: 第一個成員a為字元型,佔一個位元組,儲存的位置是0x0036FAC8,儲存之後,第二個成員b為雙精度浮點型,佔8個位元組,同時編譯器預設規定的對齊數為8,這兩個數取較小值,還是8,所以它儲存的位置偏移應該為8的整數倍,剛好0x0036FAC8-0x0036FAD0為8,因此在0x0036FAD0這個地方儲存b佔用8個位元組,之後準備開始儲存c,c是字元型,佔據一個位元組,系統預設對齊數為8,二者取其小,它儲存的地址應該是1的整數倍,很明顯,任何偏移都是1的整數倍,直接存在了b的後面。到這裡本該結束了,但是還有規則3,即在進行b的儲存的時候選出來的某個數是8,在儲存c的時候選出來的某個數是1,在8和1中選出一個最大值,結構體的大小應該為8的最小整數倍,現在的結構體大小為8+8+1也就是17,最小整數倍只能是24了。所以在c的後面又空置了7個位元組。促成了結構體的24個位元組的大小。 還有兩點要說明: 1 以上提到了一個編譯器預設的對齊數,這個數是可以更改的。使用#pragma pack (1//2//4//8//16)來更改,只能改為1,2,4,8,16中的一個值。 2 在網路傳輸,使用結構體指標指向某一個資料區獲取資料,讀取檔案等時候,經常由於結構體的對齊問題而出錯,是一個值得注意的問題。


實戰練習


希望通過以下的例子能夠再次熟悉結構體的對齊:


struct Test {

char a;

double b;

int  n;

};

int main()

{

Test obj;

obj.a = 'c';

obj.b = 889089;

obj.n = 1234;

cout << sizeof(Test);

return 0;

}

輸出結果:24


struct Test {

char a;

int  n;

double b;

};

int main()

{

Test obj;

obj.a = 'c';

obj.b = 889089;

obj.n = 1234;

cout << sizeof(Test);

return 0;

}


輸出結果:16不知各位讀者注意到沒,結構體中不同型別的資料排列不同,得到的結構體大小也將不同,就是由於記憶體對齊所造成的。


類物件記憶體模型


1.普通類的記憶體模型:


從一段簡單的程式碼看起:


class Test {

public:

char a;

double b;

char c;

static int d;

static int e;

void fun()

{

printf("Hello world!");

}

};

int main()

{

Test Teobj;

Teobj.a = 'a';

Teobj.b = 1564654.325;

Teobj.c = 'b';

cout << sizeof(Test);

return 0;

}


輸出結果為24; 我們把上一篇的例子中struct改成class關鍵字,再在class中新增pubilc屬性,保證在外部能訪問資料,又新增了兩個靜態成員和一個成員函式。顯示結果與結構體是一模一樣的。由此可以驗證:


vs2015c++中結構體與類的區別只是內部預設訪問屬性的不同。 對於一個沒有繼承別的類的類,他的記憶體模型與結構體一模一樣,也就是說記憶體對齊也是一樣的。 靜態成員與成員函式不會對記憶體模型造成任何影響。

 

2.有繼承關係時物件的記憶體模型


這裡是需要我們重點研究的,在開始之前先思考這樣一個問題,請看圖:

由結構體對齊所引發的對C++類物件記憶體模型的思考(一)

類B派生自類A,類C除了沒有繼承自類A外,與類B一模一樣。所有的類沒有虛繼承與虛擬函式,此時:sizeof(類A)+sizeof(類C)與sizeof(類B)之間的大小關係如何呢?是不是相等的呢,為了解答心中的這個疑惑,好,首先讓我們看程式碼:


class  Base

{

public:

Base() :a(0x1), b(0x2), c(0x3),d('d'), e('e') {}

int a;

int b;

int c;

double d;

char e;

};

class  Inherit1 :public Base

{

public:

Inherit1() :m_Inherit1a(0xF) {}

int m_Inherit1a;

};

class  CTest

{

public:

CTest() :m_Inherit1a(0xF) {}

int m_Inherit1a;

};

int  main() {

Base     obj1;

CTest    obj2;

Inherit1 obj3;

cout << "基類大小  :" << sizeof(obj1) << endl;

cout << "派生類大小  :" << sizeof(obj3) << endl;

cout << "測試類大小:" << sizeof(obj2) << endl;

return 0;

}


其輸出結果為:


  由結構體對齊所引發的對C++類物件記憶體模型的思考(一)


我們發現結果出乎我們的意料,sizeof(基類)+sizeof(測試類)與sizeof(派生類)居然不相等!那是為什麼呢?下面我們來分析這個結果:首先看一看派生類物件,其記憶體模型圖如下:


  由結構體對齊所引發的對C++類物件記憶體模型的思考(一) 


 可以看出前三個值是1,2,3,也就是在基類建構函式中初始化的資料成員,可以驗證,在派生類物件中,開始存放的是基類的資料成員。


前3個int型成員佔據12個位元組後,之後是double型,依據之前的記憶體對齊規則,我們可以推測,成員d所在位置偏移需要為8的整數倍,故而再隔了4個位元組之後開始存放d,而上圖正好驗證了我們的推測!隨後存放成員e,此時基類大小已經為25了,但是基類總大小應該為8的整數倍(參見上面的記憶體規則),所以後面又空置了7個位元組(用cc來填充),隨後儲存的是派生類中的資料成員,僅有一個int型,總大小是36。


但是真相併非如此,後面又補了4個位元組,那是因為整個派生類物件的對齊同時考慮基類和派生類,故而大小為8的整數倍.我們單看基類和測試類,它們大小分別為32位元組和4位元組,它們之間是沒有繼承關係的,有了繼承關係組合到一起後的派生類竟然超過沒有繼承關係時兩個類大小的總和!


有點意思.既然基類和派生類會"擴充"變大,那麼它們會融合麼??下面看程式碼:


class  Base

{

public:

Base() :a(0x1), b(0x2) {}

int a;

char b;

};

class  Inherit1 :public Base

{

public:

Inherit1() :m_Inherit1a(0xF) {}

char m_Inherit1a;

};

class CTest

{

public:

CTest() :m_Inherit1a(0xF) {}

char m_Inherit1a;

};

int main()

{

Base     obj1;

CTest    obj2;

Inherit1 obj3;

cout << "基類大小  :" << sizeof(obj1) << endl;

cout << "測試類大小:" << sizeof(obj2) << endl;

cout << "派生類大小  :" << sizeof(obj3) << endl;

return 0;

}


輸出結果:


  由結構體對齊所引發的對C++類物件記憶體模型的思考(一)


派生類記憶體模型如下


:由結構體對齊所引發的對C++類物件記憶體模型的思考(一)


可以看出,臨近的兩個char型別資料並沒有融合到一起,基類與子類涇渭分明。並且又由於整體對齊的原因,又是繼承關係的子類大小大於基類與測試類大小的總和。下面,我們再看一段程式碼 :


class  Base

{

public:

Base() :a(0x1), b(0x2), c(0x3), d('a'), e('a') {}

int a;

int b;

int c;

int d;

char e;

};

class  Inherit1 :public Base

{

public:

Inherit1() :c1('A'), m_Inherit1a(0xF) {}

char c1;

double m_Inherit1a;

};

class  CTest

{

public:

CTest() :c('A'), m_Inherit1a(0xF) {}

char c;

double m_Inherit1a;

};

int main()

{

Base     obj1;

CTest    obj2;

Inherit1 obj3;

cout << "基類大小  :" << sizeof(obj1) << endl;

cout << "測試類大小:" << sizeof(obj2) << endl;

cout << "派生類大小  :" << sizeof(obj3) << endl;

return 0;

}


輸出結果:


  由結構體對齊所引發的對C++類物件記憶體模型的思考(一)


檢視派生類物件記憶體模型:


  由結構體對齊所引發的對C++類物件記憶體模型的思考(一)


可以看出,父類與子類,依然涇渭分明,派生類部分被擠榨的僅為12個位元組,整體上來說32位元組還是保證了為8的倍數。此時就出現了讓我們大跌眼鏡的一步,具有繼承關係的派生類的大小竟然小於父類與測試類大小的和。通過之前的分析,我們可以得出具有繼承關係的派生類的大小既可以大於也可以小於測試類大小的和,那麼可以等於麼?答案是顯而易見的。下面我們看程式碼:


class  Base

{

public:

Base() :a(0x1), b(0x2), c(0x3), d('a'), e('a') {}

int a;

int b;

int c;

int d;

int e;

};

class  Inherit1 :public Base

{

public:

Inherit1() :c1(0x8), m_Inherit1a(0xF) {}

int c1;

int  m_Inherit1a;

};

class  CTest

{

public:

CTest() :c(0x8), m_Inherit1a(0xF) {}

int  c;

int  m_Inherit1a;

};

int main()

{

Base     obj1;

CTest    obj2;

Inherit1 obj3;

cout << "基類大小  :" << sizeof(obj1) << endl;

cout << "測試類大小:" << sizeof(obj2) << endl;

cout << "派生類大小  :" << sizeof(obj3) << endl;

return 0;

}


輸出結果:


  由結構體對齊所引發的對C++類物件記憶體模型的思考(一)


至於這裡為什麼是等於的,我想通過之前的分析,大家應該明白其中的緣由,這裡就不多說了.


總結:


當為普通繼承關係時,基類成員在派生類成員的上面 基類與子類的成員不會融合,也就是說,會保證基類大小是對齊過的 無論是基類成員還是派生類成員,排布其所在位置的偏移都是相對於整個類物件的起始位置。 靜態成員與成員函式對於類物件的記憶體排布沒有任何影響.我們現在可以回答一開始提出的問題了,有可能sizeof(類A)+sizeof(類C)>sizeof(類B),也可能sizeof(類A)+sizeof(類C)<sizeof(類B),也可能sizeof(類A)+sizeof(類C)==sizeof(類B),這真是讓人驚訝不已!

3.派生類與基類之間的轉換


這裡緊接著上面探討一下子類與父類之間的關係,當我們按int型訪問一個地址中的值時,就會取出這個地址到這個地址加4這段區間儲存的資料,同樣,我們按一個類的型別去訪問一個地址中的值時,就會按照這個類的大小,去訪問以這個地址值為起點的一段連續的地址空間。


class  Base

{

public:

Base() :a(0x1), b(0x2) {}

int a;

char b;

};

class  Inherit1 :public Base

{

public:

Inherit1() :m_Inherit1a(0xF) {}

char m_Inherit1a;

};

class Inherit2 :Base

{

public:

int m_Inherit2a;

int m_Inherit2b;

};

int main()

{

Base     obj1;

Inherit1 obj2;

Inherit2 obj3;

Base*    pBase;

obj1 = obj2;         //不報錯

obj1 = obj3;         //報錯

obj1 = (Base)obj3;   //報錯

pBase = &obj2;       //不報錯

pBase = &obj3;       //報錯

pBase = (Base*)&obj3;//不報錯

return 0;

}


這段程式碼中,公有繼承的子類物件或者指標是可以直接賦值給父類的。Inherit2 是私有繼承自Base類的,當派生類是私有或者保護繼承於基類時,是不能直接把派生類的地址賦值給基類的,需要強制轉換。 一般我們使用的都是指標,當我們把派生類的地址賦值給一個基類的時候,基類就會按照基類的方式去訪問這段記憶體,不過,派生類的開頭儲存的正是由基類繼承來的資料,類物件的記憶體模型完美支援這一點。另外從物件導向的角度來看,這麼做是很合理的,比如動物類和兔子類,很明顯兔子是動物,自然能夠賦值,但是反過來,我說動物是兔子,這就未免會有問題了,下面探討一下基類向派生類的轉換。 當基類轉換為派生類的時候,就按照派生類的方式去訪問記憶體,在基類的記憶體中這塊區域中,應該沒有什麼問題,但是當越過基類記憶體的時候,能訪問到什麼,修改了什麼就很難說了。


int main()

{

Base     obj1;

Inherit1 obj2;

Inherit2 obj3;

Base*    pBase;

Inherit1* pInherit1;

Inherit2* pInherit2;

obj1 = obj2;         //不報錯

pBase = &obj2;       //不報錯

pBase = (Base*)&obj3;//不報錯

obj2 = (Inherit1)obj1; //報錯

pInherit1 = (Inherit1*)&obj1;

pInherit2 = (Inherit2*)&obj1;

return 0;

}


基類向派生類進行轉換需要強制轉換,並且也僅限於指標。



總結:


公有繼承的子類可以自由的向父類轉換,記憶體模型支援這一點,語法上也支援這麼做。 私有繼承的子類需要強制轉換。 父類不能隨意向子類轉換,一般來說這是不安全的,極有可能造成越界。

相關文章