主要討論的是C++早期編譯器在處理對類相關成員在內存中的布局情況
1.加上封裝后的布局成本
首先說明C++在增加封裝特性后,簡單的類類型並不比C/C++結構體類型帶來的布局成本高。下面作簡單說明:
class base { virtual ~base(); static int print(); static float m_f; int m_i; };
靜態成員和非內聯函數(c++ 內聯函數不能成為虛函數)均不屬於對象,如果不存在虛函數則只有普通成員變量屬於對象,故而普通類類型和C結構體類型無異。
考慮到繼承和多態的情況下,早期的編譯器對類的對象模型衍生出幾種不同的布局形式,用以支持”虛”的特性。
關於虛函數和虛繼承后續說明。
先說明下早期的幾種對象模型:
(1)簡單對象模型
類只存儲指針,指向所有類成員實體。
(2)表格驅動的對象模型
類只存儲兩個表的指針,而兩個表實際存儲的也是一堆指針。
成員表中的各個指針指向各個成員變量實體的地址。
函數表中的各個指針則指向各個成員函數實體的首地址。
(3) C++對象模型
簡單對象模型已被丟棄,而現在的C++對象模型在出現虛函數或者虛繼承的情況下,對象模型和表格驅動的對象模型有幾分相似。
首先是虛函數表,其次是虛基類表。下面是包含虛函數表的情況。
此種模型在存在虛表時相對於普通類,多存儲了一個指針數據vptr,而在運行時增加了一種動態綁定機制需要根據偏移尋址函數實體,
這是間接性尋址之一,再加上虛繼承時針對虛基類的多層間接尋址,此差異才是C++和C在內存布局和運行效率上差異的主要原因。
針對不同的編譯器,對”虛”機制的有不同的處理方式,以上只是以C++發展過程中主流編譯器的內存模型作說明。
另外,關於虛表中存儲type_info指針的位置,即使現在的主流編譯器實現也不同。
VC放在虛表首地址前的位置,而在老編譯器CFront中可以看出放在虛表首地址,不必糾結。
以下就書中所講的一段形式代碼作解析:
- foobar如果是作為X的友元函數,則通過X對象調用foobar的時候等同於foobar(X x),則編譯時調用者X會作為首參傳遞到foobar的形參中。類似對象調用其成員函數的this傳遞形式。
- 至於其foobar的返回值X類型。此例之一可能是為了說明一種優化手段(現行編譯器如g++):即局部對象作為返回值時在函數執行結束不析構掉原局部對象,再重新構造一個臨時對象作為返回值對象,而是直接返回已有的臨時對象,從而減少一次對象構造開銷。
- 引用型參數同樣可以避免可能存在的一次無意義的構造(返回值對象)。
- 但作為引用型實參,是必須在定義的時候就初始化的,即其引用的對象實體其實是已經被構造出來了,意味着foobar內部的_result的再次調用構造會造成先前實體對象的重新初始化。
- px的構造說明了類的構造函數分為兩步,第一步即內存分配,第二步成員初始化。這也是一個考慮到餓漢模式,減少非空判斷,減少上鎖次數和臨界區大小且保證線程安全同時保證性能的完美的單例模式不好實現的原因之一。這里構造的兩步就可能影響到線程安全。
- 后面兩種調用X的foo,_result因為是對象而非指針函數的動態綁定,所以在使用時不表現多態性,直接調用基類的foo,而px如果是作為基類指針但實際上指向一個子類對象,而foo又在子類中重寫形成和父類X的foo形成有效覆蓋,則此種調用形式將形成多態調用,px雖然是基類指針,實際卻調用的是覆蓋版本即子類的foo。
- 多態情況下虛函數實體的調用通過虛表指針。通過虛表指針偏移,先調用覆蓋版本的foo后調用了覆蓋版本的析構。
- 這里說覆蓋版本的析構不太准確,但C++的機制是,一旦基類是虛析構,則可以通過delete基類指針來析構子類對象,而子對象的析構又會在子類析構完后自動調用其基類的析構從而完成整個子對象的析構,具體實現待研究。
- 關於構造和析構的順序,構造時先構造基類,再構造子類,而在析構時則是先析構子類中的非基類部分,再析構基類。
以上為個人關於這段形式代碼的分析。
2.關鍵詞差異
主要做兩個說明:
(1)一個是為了C++編譯器或者解析器對函數調用和函數聲明的額外處理
int (*pf)(124);
int (*pq)();
書中所說,第一個位函數調用(帶返回值類型,具體原因不懂),第二個為函數聲明。
但是以現在C++語法來看,首先函數聲明是不能帶實參的,函數調用是不能帶類型的。
#include <iostream> using namespace std; int (*ff)(int, int);//函數指針類型聲明 typedef int (*pff)(int, int); int mymax(int a,int b) { return a > b ? a : b; } int main() { int ret = -1; pff pf1 = &mymax;//等價pff pf1 = mymax;編譯器允許使用函數名稱作為函數首地址。 pff* ppf1 = &pf1; ret = pf1(123,456); ret = (*ppf1)(123,456); return 0; }
(2)第二個說明下結構體
首先,C++中為什么要有結構體類型?事實上,如果不用考慮C++對C的兼容,使用結構體的地方基本可以用類替換。
下面說明下結構體和類的差異:本質上來講,struct擁有class的區別僅僅在於默認的訪控屬性。
首選是成員的訪控屬性,struct默認公有,class默認私有。
其次是繼承時的子類對象對基類成員的訪控屬性,如果沒有顯式指定繼承方式,則由子類是結構體還是類決定。如下:
struct A:B{}; 不管B是class還是struct都是 公有繼承,
class A:B{}; 不管B是class還是struct都是 私有繼承。
除非顯式指定struct A:private B; 或者class A:public B; 即:默認的繼承屬性看子類/結構體的默認訪控屬性。
這也是一般類繼承的時候class A: public class B;要指定public繼承方式,因為默認是private的。
如果以默認方式繼承則在該類外部就無法訪問基類的所有成員,因為基類成員被私有繼承后在子類中將全部被私有化。
這一點可以參考 C++中結構體與類的區別(struct與class的區別)
以上來看,類和結構體確實是沒有多大差異,通常來講,如果僅僅是作為一種數據結構,使用結構體,注重對象功能則使用類。
但如果定義了一個strcut卻用class聲明,vc編譯器不報錯,但會給警告,反之也一樣。
通常容易忽略的是,C++中結構體同樣支持繼承,多態,虛析構。
struct A { int m_a; A(int a):m_a(a){} virtual void ptf()
{ cout <<"A::ptf" << endl; } virtual ~A()
{ cout <<"~A" << endl; } }; struct B :A { B(int b):A(b),m_b(b+1){}
virtual void ptf()
{ cout <<"B::ptf" << endl; }
virtual ~B()
{ cout <<"~B" << endl; } int m_b; }; int main (void) { B* pb = new B(10); cout << pb->m_a << pb->m_b << endl; pb->ptf(); delete pb; A* pa = new B(10); delete pa;; getchar(); return 0; }
輸出:
1011 B::ptf ~B ~A ~B ~A
書中接下來講的差異是從內存布局上來講的。
但個人不是很理解,因為就現在的編譯器來講,成員變量在內存中總是按照聲明順序來排列的,和訪控屬性並無關系。目前主流編譯器也是把基類成員放在前面,派生類的非基類成員放在后面,並不會改變基類本身的內存布局。
揣摩作者的表述,猜想可能是當初的C++對結構體成員不區分訪控屬性,而恰好當時的編譯器在處理類的成員變量時會考慮類成員變量的訪控屬性,將相同訪控屬性的按聲明順序放在一起。不再作深究。
比較明確的一點就是:不同的編譯器確實對於基類在子類的中位置以及虛表指針和type_info信息存放位置有不同的處理方式,了解目前主流編譯器的內存布局就行了。
結論:struct作為一個數據集合體使用,即純C結構體的用法。class來體現C++面向對象特性。將結構體作為一種純數據封裝體組合到類中。
在面向對象設計原則上,不管是類繼承結構體還是結構體繼承類,均按照優先使用組合而非繼承的原則。
但並非一定要用組合,也是要看使用場景的。組合通常是黑箱復用,繼承通常是白箱復用,has A和 Is A還是有區別的。尤其是在鑽石繼承中(暫不考慮虛繼承),可能最終子類實體四不像,耦合度更高,還更加重復。
附帶說下對齊和補齊:
結構體或者類的成員變量內存布局是按照聲明順序在內存中順序存儲的,考慮到CPU存取指令和內存數據存取效率問題,有對其和補齊。
實際上為了實現高效存取,損失了內存使用率,即在按照聲明順序存數據時,按照指定的對齊方式來對齊,並對不夠的部分進行補齊。
例如
struct st{ int a; char b; float c;
char d; };
實際上如果是4字節對齊,則sizeof(struct st) = 16,而如果不考慮對齊和補齊,10個字節就夠了。
C/C++均要求內存的對齊和補齊,lds指定align或默認對齊方式。
3.對象差異
可以先看下虛表及其指針的內存布局,虛基類中的虛表指針屬於代碼段,對應的虛函數表大小也是在編譯期就決定了。虛表,虛表指針的內存布局
子類繼承基類后,子類除了包含基類對象的虛函數表外還存在自己的虛函數表,如果多態實現的有效覆蓋,則實際上子類中的覆蓋版本虛函數函數地址覆蓋保存到基類子對象的虛表中,子類獨有的虛函數則保存在自己的虛函數表中。
(1)c++程序設計模型
- 程序模型--面向過程
基本C面向過程編程。
- 接口模型--面向抽象/接口,具體對象行為的抽象表示,OO
抽象即隱而未明的一組公有(私有或者保護,外部將無法訪問,接口也就無實際使用意義)接口,依賴具體實現,體現:繼承和多態
- 對象模型--面向對象
以對象為基本單位,體現:繼承和封裝,OB,ADT
形成多態的條件:多態性除了需要在子類和基類間形成有效的虛函數覆蓋外,還必須通過指針或引用去訪問虛函數。
對象一旦定義則其類型決定了對象大小是固定的,而指針和引用指向的對象實體(派生類對象)可能並非指針或者引用本身(基類對象)的類型,正是這種伸縮性才是形成多態的必要條件。
形成有效覆蓋的前提條件:
a.只有類的非靜態成員函數才能被聲明為虛函數,全局函數和類的靜態成員函數都不能是虛函數。
b.有在基類中被聲明為虛函數的成員函數才能在子類中覆蓋。
c.虛函數在子類中的覆蓋版本必須和該函數基類版本擁有完全相同的簽名,即函數名,形參表,常屬性嚴格一致。
d.如果基類中虛函數的返回類型為基本類型或類類型的對象,那么子類的覆蓋版本必須返回相同的類型。
基於覆蓋,順便提下如何區分重載、覆蓋和隱藏
重載必須在同一個作用域中
覆蓋必須是同型的虛函數
如果不是重載也不是覆蓋,而且函數名還是一樣,那就一定是隱藏
就書中講到的兩個sizeof作下記錄:
基本string類型包含一個指針和一個記錄指針數據實體的長度,大小8字節
引用,本質上也是指針,但是又和指針不同。指針和引用的區別:
a.指針可以不初始化,其目標可以在初始化后隨意變更。但是引用必須初始化,而且一旦初始化就無法變更其目標
b.可以定義空指針,即什么也不指向的指針,但是不能定義空引用,引用必須有所指引,否則引用將失去意義
c.可以定義指向指針的指針,但是沒法定義一個引用引用的引用。C++2011中類似"int&&"的類型是合法的,但是他表示右值引用,而非二級引用。
d.可以定義一個引用指針的引用,但是無法定義一個指向引用的指針。
e.可以定義存放指針的數組,但無法定義存放引用的數組.可以定義引用數組的引用
另外,引用的sizeof由引用的對象決定,指針的sizeof固定大小一般為4。
(2)指針的類型
指針本質上是沒有類型的,之所以我們定義指針的時候加上類型,是要確認指針指向的那塊數據的類型。
就像是函數要定義返回值類型一樣,只是告訴我們需要用什么類型來接收函數執行結果
(3)多態可能造成的對象切割
C++加上繼承之后,基類指針指向子類對象時如果不考慮多態,則使用基類指針操作子類對象,會發生切割,只會被當做一個獨立的基類對象,只允許訪問其中的基類成員部分。
如果考慮到多態,由於不同編譯器對虛表指針布局不同切割可能導致對象不完整,從而引發未知錯誤。
借助書中的圖,簡單看下對象的存儲。
先說明:
a.內存堆棧划分:堆低地址到高地址使用,棧由高地址向低地址使用(但不代表定義一個in a[4]后&a[0] > &a[3]),高地址為棧底,低地址為棧頂,程序運行由sp棧指針偏移訪問。
b.關於壓棧順序,不同的編譯器局部變量和函數實參的壓棧順序存在差異(MSVC函數 是從右向左,mingw從左向右,局部變量MSVC好像是按定義順序。。局部變量入棧順序與輸出關系)
再來說說上圖:
局部變量,包括指針都是存在棧中的,只有new,malloc/calloc/realloc出來對象的放在堆中。
按圖中,上半部為堆,下半部為棧。堆中只有Panda對象。按照圖中的排列,該編譯器局部變量的壓棧順序應該是先定義的后壓棧。za最后定義,卻在棧頂可以看出,至於其他變量不再贅述。
以上即個人對書中第一章關於C++對象的一個基本認識 附帶加上了部分C++個人儲備。如有錯誤,請指出,共同學習。