C++对象内存模型学习
本文原文出處為MSDN。如果你安裝了MSDN,可以搜索到C++ Under the Hood。否則也可在網(wǎng)站上找到http://msdn.microsoft.com/archive/default.asp?url=/archive/en-us/dnarvc/html/jangrayhood.asp 。
1 前言
本文著重回答這樣一些問題:
1* 類如何布局?
2* 成員變量如何訪問?
3* 成員函數(shù)如何訪問?
4* 所謂的“調(diào)整塊”(adjuster thunk)是怎么回事?
5* 使用如下機制時,開銷如何:
? * 單繼承、多重繼承、虛繼承
? * 虛函數(shù)調(diào)用
? * 強制轉(zhuǎn)換到基類,或者強制轉(zhuǎn)換到虛基類
? * 異常處理
首先,我們順次考察C兼容的結(jié)構(gòu)(struct)的布局,單繼承,多重繼承,以及虛繼承;
接著,我們講成員變量和成員函數(shù)的訪問,當(dāng)然,這里面包含虛函數(shù)的情況;
再接下來,我們考察構(gòu)造函數(shù),析構(gòu)函數(shù),以及特殊的賦值操作符成員函數(shù)是如何工作的,數(shù)組是如何動態(tài)構(gòu)造和銷毀的;
最后,簡單地介紹對異常處理的支持。
2 類布局
本節(jié)討論不同的繼承方式造成的不同內(nèi)存布局。
2.1 C結(jié)構(gòu)(struct)
由于C++基于C,所以C++也“基本上”兼容C。特別地,C++規(guī)范在“結(jié)構(gòu)”上使用了和C相同的,簡單的內(nèi)存布局原則:成員變量按其被聲明的順序排列,按具體實現(xiàn)所規(guī)定的對齊原則在內(nèi)存地址上對齊。
2.2 有C++特征的C結(jié)構(gòu)
C++不是復(fù)雜的C,C++本質(zhì)上是面向?qū)ο蟮恼Z言:包 含 繼承、封裝,以及多態(tài) 。原始的C結(jié)構(gòu)經(jīng)過改造,成了面向?qū)ο笫澜绲幕悺3顺蓡T變量外,C++類還可以封裝成員函數(shù)和其他東西。然而,有趣的是,除非 為了實現(xiàn)虛函數(shù)和虛繼承引入的隱藏成員變量外,C++類實例的大小完全取決于一個類及其基類的成員變量!成員函數(shù)基本上不影響類實例的大小。
這里提供的B是一個C結(jié)構(gòu),然而,該結(jié)構(gòu)有一些C++特征:控制成員可見性的“public/protected/private”關(guān)鍵字、成員函數(shù)、靜態(tài)成員,以及嵌套的類型聲明。
實際上,只有成員變量才占用類實例的空間 。
( 在VC++中,成員變量總是按照聲明時的順序排列)。
2.3 單繼承
C++ 提供繼承的目的是在不同的類型之間提取共性。
既然派生類要保留基類的所有屬性和行為,自然地,每個派生類的實例都包含了一份完整的基類實例數(shù)據(jù)。在D中,并不是說基類C的數(shù)據(jù)一定要放在D的數(shù)據(jù)之前,只不過這樣放的話,能夠保證D中的C對象地址,恰好是D對象地址的第一個字節(jié)。這種安排之下,有了派生類D的指針,要獲得基類C的指針,就不必要計算偏移量 了。
在單繼承類層次下,每一個新的派生類都簡單地把自己的成員變量添加到基類的成員變量之后 。?
2.4 多重繼承
結(jié)構(gòu)F從C和E多重繼承得來。與單繼承相同的是,F實例拷貝了每個基類的所有數(shù)據(jù)。 與單繼承不同的是,在多重繼承下,內(nèi)嵌的兩個基類的對象指針不可能全都與派生類對象指針相同;?
觀察類布局,可以看到F中內(nèi)嵌的E對象,其指針與F指針并不相同。
VC++ 按照基類的聲明順序 先排列基類實例數(shù)據(jù),最后才排列派生類數(shù)據(jù)。 當(dāng)然,派生類數(shù)據(jù)本身也是按照聲明順序布局的(本規(guī)則并非一成不變 ,我們會看到,當(dāng)一些基類有虛函數(shù)而另一些基類沒有時,內(nèi)存布局并非如此)。
2.5 虛繼承
虛繼承的語法很簡單,在指定基類時加上virtual關(guān)鍵字即可。
使用虛繼承,比起單繼承和多重繼承有更大的實現(xiàn)開銷、調(diào)用開銷。
?
在G對象中,內(nèi)嵌的C基類對象的數(shù)據(jù)緊跟在G的數(shù)據(jù)之后,在H對象中,內(nèi)嵌的C基類對象的數(shù)據(jù)也緊跟在H的數(shù)據(jù)之后。但是, 在I對象中,內(nèi)存布局就并非如此了。VC++實現(xiàn)的內(nèi)存布局中,G對象實例中G對象和C對象之間的偏移,不同于I對象實例中G對象和C對象之間的偏移。當(dāng) 使用指針訪問虛基類成員變量時,由于指針可以是指向派生類實例的基類指針,所以,編譯器不能根據(jù)聲明的指針類型計算偏移,而必須找到另一種間接的方法,從 派生類指針計算虛基類的位置。?
在VC++ 中,對每個繼承自虛基類的類實例,將增加一個隱藏的“虛基類表指針”(vbptr) 成員變量,從而達到間接計算虛基類位置的目的。該變量指向一個全類共享的偏移量表,表中項目記錄了對于該類 而言,“虛基類表指針”與虛基類之間的偏移量。?
可以得到如下關(guān)于VC++虛繼承下內(nèi)存布局的結(jié)論:
1 首先排列非虛繼承的基類實例;
2 有虛基類時,為每個基類增加一個隱藏的vbptr,除非已經(jīng)從非虛繼承的類那里繼承了一個vbptr;
3 排列派生類的新數(shù)據(jù)成員;
4 在實例最后,排列每個虛基類的一個實例。
3 成員變量
介紹了類布局之后,我們接著考慮對不同的繼承方式,訪問成員變量的開銷究竟如何。
沒有繼承: 沒有任何繼承關(guān)系時,訪問成員變量和C語言的情況完全一樣:從指向?qū)ο蟮闹羔?#xff0c;考慮一定的偏移量即可。
a. 當(dāng)訪問基類成員c1時,計算步驟本來應(yīng)該為“pd+dDC+dCc1”,即為先計算D對象和C對象之間的偏移,再在此基礎(chǔ)上加上C對象指針與成員變量c1 之間的偏移量。
b. 當(dāng)訪問派生類成員d1時,直接計算偏移量。
多重繼承 :雖然派生類與某個基類之間的偏移量可能不為0,然而,該偏移量總是一個常數(shù)。只要是個常數(shù),訪問成員變量,計算成員變量偏移時的計算就可以被簡化。可見即使對于多重繼承來說,訪問成員變量開銷仍然不大。
F繼承自C和E,pf是指向F對象的指針。
a. 訪問C類成員c1時,F對象與內(nèi)嵌C對象的相對偏移為0,可以直接計算F和c1的偏移;
b. 訪問E類成員e1時,F對象與內(nèi)嵌E對象的相對偏移是一個常數(shù),F和e1之間的偏移計算也可以被簡化;
c. 訪問F自己的成員f1時,直接計算偏移量。
虛繼承: 當(dāng)類有虛基類時,訪問非虛基類的成員仍然是計算固定偏移量的問題。然而,訪問虛基類的成員變量,開銷就增大了 , 因為必須經(jīng)過如下步驟才能獲得成員變量的地址:
1. 獲取“虛基類表指針”;
2. 獲取虛基類表中某一表項的內(nèi)容;
3. 把內(nèi)容中指出的偏移量加到“虛基類表指針”的地址上。
然而,事情并非永遠如此。正如下面訪問I對象的c1成員那樣,如果不是通過指針訪問,而是直接通過對象實例,則派生類的布局可以在編譯期間靜態(tài)獲得,偏移量也可以在編譯時計算,因此也就不必要根據(jù)虛基類表的表項來間接計算了。?
4 強制轉(zhuǎn)化
如果沒有虛基類的問題,將一個指針強制轉(zhuǎn)化為另一個類型的指針代價并不高昂。如果在要求轉(zhuǎn)化的兩個指針之間有“基類-派生類”關(guān)系,編譯器只需要簡單地在兩者之間加上或者減去一個偏移量即可(并且該量還往往為0)。
5 成員函數(shù)
一個C++成員函數(shù)只是類范圍內(nèi)的又一個成員。X類每一個非靜態(tài)的成員函數(shù)都會接受一個特殊的隱藏參數(shù)——this指針,類型為X* const。 該指針在后臺初始化為指向成員函數(shù)工作于其上的對象。同樣,在成員函數(shù)體內(nèi),成員變量的訪問是通過在后臺計算與this指針的偏移來進行。
?
P有一個非虛成員函數(shù)pf(),以及一個虛成員函數(shù)pvf()。很明顯,虛成員 函數(shù)造成對象實例占用更多內(nèi)存空間,因為虛成員函數(shù)需要虛函數(shù)表指針。
5.1 覆蓋成員函數(shù)
和成員變量一樣,成員函數(shù)也會被繼承。與成員變量不同的是,通過在派生類中重新定義基類函數(shù),一個派生類可以覆蓋,或者說替換掉基類的函數(shù)定義。覆蓋是靜態(tài) (根據(jù)成員函數(shù)的靜態(tài)類型在編譯時決定)還是動態(tài) (通過對象指針在運行時動態(tài)決定),依賴于成員函數(shù)是否被聲明為“虛函數(shù)”。
對于非虛 的成員函數(shù)來說,調(diào)用哪個成員函數(shù)是在編譯 時,根據(jù)“->”操作符左邊指針表達式的類型靜態(tài)決定 的。
VC++編譯器把隱藏的vfptr成員變量放在P和Q實例的開始處。這就使虛函數(shù)的調(diào)用能夠盡量快一些。實際上,VC++的實現(xiàn)方式是,保證任何有虛函數(shù)的類的第一項永遠是vfptr。?
5.2 多重繼承下的虛函數(shù)
如果從多個有虛函數(shù)的基類繼承,一個實例就有可能包含多個vfptr。
因為S從P和R多重繼承,S的實例內(nèi)嵌P和R的實例,以及S自身的數(shù)據(jù)成員S::s1。注意,在多重繼承下,靠右的基類R,其實例的地址和P與S不同。 S::pvf覆蓋了P::pvf()和R::pvf(),S::rvf()覆蓋了R::rvf()。
在微軟VC++實現(xiàn)中,對于有虛函數(shù)的多重繼承,只有當(dāng)派生類虛函數(shù)覆蓋了多個基類的虛函數(shù)時,才使用調(diào)整塊。 ?
5.3 地址點與“邏輯this調(diào)整”
考慮下一個虛函數(shù)S::rvf(),該函數(shù)覆蓋了R::rvf()。我們都知道S::rvf()必須有一個隱藏的S*類型的this參數(shù)。但是,因為也可以用R*來調(diào)用rvf(),也就是說,R的rvf虛函數(shù)槽可能以如下方式被用到:
當(dāng)覆蓋非最左邊的基類的虛函數(shù)時,MSC++一般不創(chuàng)建調(diào)整塊,也不增加額外的虛函數(shù)項。
5.4 調(diào)整塊
正如已經(jīng)描述的,有時需要調(diào)整塊來調(diào)整this指針的值(this指針通常位于 棧上返回地址之下,或者在寄存器中),在this指針上加或減去一個常量偏移,再調(diào)用虛函數(shù)。某些實現(xiàn)(尤其是基于cfront的)并不使用調(diào)整塊機制。 它們在每個虛函數(shù)表項中增加額外的偏移數(shù)據(jù)。每當(dāng)虛函數(shù)被調(diào)用時,該偏移數(shù)據(jù)(通常為0),被加到對象的地址上,然后對象的地址再作為this指針傳入。
5.5 虛繼承下的虛函數(shù)
T虛繼承P,覆蓋P的虛成員函數(shù),聲明了新的虛函數(shù)。如果采用在基類虛函數(shù)表末尾添加新項的方式,則訪問虛函數(shù)總要求訪問虛基類。在VC++中,為了避免獲取虛函數(shù)表時,轉(zhuǎn)換到虛基類P的高昂代價,T中的新虛函數(shù)通過一個新的虛函數(shù)表獲取 ,從而帶來了一個新的虛函數(shù)表指針。該指針放在T實例的頂端。
5.6 特殊成員函數(shù)
本節(jié)討論編譯器合成到特殊成員函數(shù)中的隱藏代碼。
5.6.1 構(gòu)造函數(shù)和析構(gòu)函數(shù)
在構(gòu)造和析構(gòu)過程中,有時需要初始化一些隱藏的成員變量。最壞的情況下,一個構(gòu)造函數(shù)要執(zhí)行如下操作:
1 * 如果是“最終派生類”,初始化vbptr成員變量,調(diào)用虛基類的構(gòu)造函數(shù);
2 * 調(diào)用非虛基類的構(gòu)造函數(shù)
3 * 調(diào)用成員變量的構(gòu)造函數(shù)
4 * 初始化虛函數(shù)表成員變量
5 * 執(zhí)行構(gòu)造函數(shù)體中,程序所定義的其他初始化代碼
(注意:一個“最終派生類”的實例,一定不是嵌套在其他派生類實例中的基類實例)
所以,如果你有一個包含虛函數(shù)的很深的繼承層次,即使該繼承層次由單繼承構(gòu)成,對象的構(gòu)造可能也需要很多針對虛函數(shù)表的初始化。
反之,析構(gòu)函數(shù)必須按照與構(gòu)造時嚴(yán)格相反的順序來“肢解”一個對象。
1 * 合成并初始化虛函數(shù)表成員變量
2 * 執(zhí)行析構(gòu)函數(shù)體中,程序定義的其他析構(gòu)代碼
3 * 調(diào)用成員變量的析構(gòu)函數(shù)(按照相反的順序)
4 * 調(diào)用直接非虛基類的析構(gòu)函數(shù)(按照相反的順序)
5 * 如果是“最終派生類”,調(diào)用虛基類的析構(gòu)函數(shù)(按照相反順序)
在VC++中,有虛基類的類的構(gòu)造函數(shù)接受一個隱藏的“最終派生類 標(biāo)志”,標(biāo)示虛基類是否需要初始化。對于析構(gòu)函數(shù),VC++采用“分層析構(gòu)模型”,代碼中加入一個隱藏的析構(gòu)函數(shù),該函數(shù)被用于析構(gòu)包含虛基類的類(對于 “最終派生類”實例而言);代碼中再加入另一個析構(gòu)函數(shù),析構(gòu)不包含虛基類的類。前一個析構(gòu)函數(shù)調(diào)用后一個。
5.6.2 虛析構(gòu)函數(shù)與delete操作符
假如A是B的父類, ?
A* p = new B(); ?
如果析構(gòu)函數(shù)不是虛擬的,那么,你后面就必須這樣才能安全的刪除這個指針: ?
delete (B*)p; ?
但如果構(gòu)造函數(shù)是虛擬的,就可以在運行時動態(tài)綁定到B類的析構(gòu)函數(shù),直接: ?
delete p; ?
就可以了。這就是虛析構(gòu)函數(shù)的作用。
實際上,很多人這樣總結(jié):當(dāng)且僅當(dāng)類里包含至少一個虛函數(shù)的時候才去聲明虛析構(gòu)函數(shù)。
VC++擴展了其“分層析構(gòu)模型”,從而自動創(chuàng)建另一個隱藏的析構(gòu)幫助函數(shù)——“deleting析構(gòu)函數(shù)”,然后,用該函數(shù)的地址來替 換虛函數(shù)表中“實際”虛析構(gòu)函數(shù)的地址。析構(gòu)幫助函數(shù)調(diào)用對該類合適的析構(gòu)函數(shù),然后為該類有選擇性地調(diào)用合適的delete操作符。
6 數(shù)組
堆上分配空間的數(shù)組使虛析構(gòu)函數(shù)進一步復(fù)雜化。問題變復(fù)雜的原因有兩個:
1、 堆上分配空間的數(shù)組,由于數(shù)組可大可小,所以,數(shù)組大小值應(yīng)該和數(shù)組一起保存。因此,堆上分配空間的數(shù)組會分配額外的空間來存儲數(shù)組元素的個數(shù);
2、 當(dāng)數(shù)組被刪除時,數(shù)組中每個元素都要被正確地釋放,即使當(dāng)數(shù)組大小不確定時也必須成功完成該操作。然而,派生類可能比基類占用更多的內(nèi)存空間,從而使正確釋放比較困難。
雖 然從嚴(yán)格意義上來說,數(shù)組delete的多態(tài)行為C++標(biāo)準(zhǔn)并未定義,然而,微軟有一些客戶要求實現(xiàn)該行為。因此,在MSC++中,該行為是用另一個編譯 器生成的虛析構(gòu)幫助函數(shù)來完成。該函數(shù)被稱為“向量delete析構(gòu)函數(shù)”(因其針對特定的類定制,比如WW,所以,它能夠遍歷數(shù)組的每個元素,調(diào)用對每 個元素適用的析構(gòu)函數(shù))。
7 異常處理
因 為C++是面向?qū)ο蟮恼Z言,很自然地,C++中用對象來表達異常狀態(tài)。并且,使用何種異常處理也是基于“拋出的”異常對象的靜態(tài)或動態(tài)類型來決定的。不光 如此,既然C++總是保證超出范圍的對象能夠被正確地銷毀,異常實現(xiàn)也必須保證當(dāng)控制從異常拋出點轉(zhuǎn)換到異常“捕獲”點時(棧展開),超出范圍的對象能夠 被自動、正確地銷毀。
談到異常處理的具體實現(xiàn)方式,一般情況下,在拋出點和捕 獲點都使用“表”來表述能夠捕獲異常對象的類型;并且,實現(xiàn)要保證能夠在特定的捕獲點真正捕獲特定的異常對象;一般地,還要運用拋出的對象來初始化捕獲語 句的“實參”。通過合理地選擇編碼方案,可以保證這些表格不會占用過多的內(nèi)存空間。
所有這些表,函數(shù)調(diào)用的準(zhǔn)備和善后工作,狀態(tài)變量的更新,都會使異常處理功能造成可觀的內(nèi)存空間和運行速度開銷。正如我們所見,即使在沒有使用異常處理的函數(shù)中,該開銷也會發(fā)生。
幸運的是,一些編譯器可以提供編譯選項,關(guān)閉異常處理機制。那些不需要異常處理機制的代碼,就可以避免這些額外的開銷了。
總結(jié)
以上是生活随笔為你收集整理的C++对象内存模型学习的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Java虚拟机垃圾收集器初步学习
- 下一篇: javascript闭包简单实例