C++ 內存對象大會戰
如果一個人自稱為程序高手,卻對內存一無所知,那么我可以告訴你,他一定在吹牛。用 C或C++寫程序,需要更多地關注內存,這不僅僅是因為內存的分配是否合理直接影響著程序的效率和性能,更為主要的是,當我們操作內存的時候一不小心就會出現問題,而且很多時候,這些問題都是不易發覺的,比如內存泄漏,比如懸掛指針。筆者今天在這里并不是要討論如何避免這些問題,而是想從另外一個角度來認識C++內存對象。
我們知道, C++ 將內存劃分為三個邏輯區域:堆、棧和靜態存儲區。既然如此,我稱位于它們之中的對象分別為堆對象,棧對象以及靜態對象。那么這些不同的內存對象有什么區別了?堆對象和棧對象各有什么優劣了?如何禁止創建堆對象或棧對象了?這些便是今天的主題。
一.基本概念
先來看看棧。棧,一般用于存放局部變量或對象,如我們 在函數定義中 用類似下面語句聲明的對象:
Type stack_object ;
stack_object 便是一個棧對象,它的生命期是從定義點開始,當所在函數返回時,生命結束。
另外,幾乎所有的臨時對象都是棧對象。比如,下面的函數定義:
Type fun ( Type object ) ;
這個函數至少產生兩個臨時對象,首先,參數是按值傳遞的,所以會調用拷貝構造函數生成一個臨時對象 object_copy1 ,在函數內部使用的不是使用的不是 object ,而是 object_copy1 ,自然, object_copy1 是一個棧對象,它在函數返回時被釋放;還有這個函數是值返回的,在函數返回時,如果我們不考慮返回值優化( NRV ),那么也會產生一個臨時對象 object_copy2 ,這個臨時對象會在函數返回后一段時間內被釋放。比如某個函數中有如下代碼:
Type tt ,result ; // 生成兩個棧對象
tt = fun ( tt ) ; // 函數返回時,生成的是一個臨時對象 object_copy2
上面的第二個語句的執行情況是這樣的,首先函數 fun 返回時生成一個臨時對象 object_copy2 ,然后再調用賦值運算符執行
tt = object_copy2 ; // 調用賦值運算符
看到了嗎?編譯器在我們毫無知覺的情況下,為我們生成了這么多臨時對象,而生成這些臨時對象的時間和空間的開銷可能是很大的,所以,你也許明白了,為什么對于“大”對象最好用 const 引用傳遞代替按值進行函數參數傳遞了。
接下來,看看堆。堆,又叫自由存儲區,它是在程序執行的過程中動態分配的,所以它最大的特性就是 動態性 。在 C++ 中,所有堆對象的創建和銷毀都要由程序員負責,所以,如果處理不好,就會發生內存問題。如果分配了堆對象,卻忘記了釋放,就會產生內存泄漏;而如果已釋放了對象,卻沒有將相應的指針置為 NULL ,該指針就是所謂的“懸掛指針”,再度使用此指針時,就會出現非法訪問,嚴重時就導致程序崩潰。
那么, C++ 中是怎樣分配堆對象的?唯一的方法就是用 new (當然,用類 malloc 指令也可獲得 C 式堆內存),只要使用 new ,就會在堆中分配一塊內存,并且返回指向該堆對象的指針。
再來看看靜態存儲區。所有的靜態對象、全局對象都于靜態存儲區分配。關于全局對象,是在 main() 函數執行前就分配好了的。其實,在 main() 函數中的顯示代碼執行之前,會調用一個由編譯器生成的 _main() 函數,而 _main() 函數會進行所有全局對象的的構造及初始化工作。而在 main() 函數結束之前,會調用由編譯器生成的 exit 函數,來釋放所有的全局對象。比如下面的代碼:
void main ( void )
{
… …// 顯式代碼
}
實際上,被轉化成這樣:
void main ( void )
{
_main () ; // 隱式代碼,由編譯器產生,用以構造所有全局對象
… … // 顯式代碼
… …
exit () ; // 隱式代碼,由編譯器產生,用以釋放所有全局對象
}
所以,知道了這個之后,便可以由此引出一些技巧,如,假設我們要在 main() 函數執行之前做某些準備工作,那么我們可以將這些準備工作寫到一個自定義的全局對象的構造函數中,這樣,在 main() 函數的顯式代碼執行之前,這個全局對象的構造函數會被調用,執行預期的動作,這樣就達到了我們的目的。
剛才講的是靜態存儲區中的全局對象,那么,局部靜態對象了?局部靜態對象通常也是在函數中定義的,就像棧對象一樣,只不過,其前面多了個 static 關鍵字。局部靜態對象的生命期是從其所在函數第一次被調用,更確切地說,是當第一次執行到該靜態對象的聲明代碼時,產生該靜態局部對象,直到整個程序結束時,才銷毀該對象。
還有一種靜態對象,那就是它作為 class 的靜態成員。考慮這種情況時,就牽涉了一些較復雜的問題。
第一個問題是 class 的靜態成員對象的生命期, class 的靜態成員對象隨著第一個 class object 的產生而產生,在整個程序結束時消亡。也就是有這樣的情況存在,在程序中我們定義了一個 class ,該類中有一個靜態對象作為成員,但是在程序執行過程中,如果我們沒有創建任何一個該 class object ,那么也就不會產生該 class 所包含的那個靜態對象。還有,如果創建了多個 class object ,那么所有這些 object 都共享那個靜態對象成員。
第二個問題是,當出現下列情況時:
class Base
{
public:
static Type s_object ;
}
class Derived1 : public Base / / 公共繼承
{
… …// other data
}
class Derived2 : public Base / / 公共繼承
{
… …// other data
}
Base example ;
Derivde1 example1 ;
Derivde2 example2 ;
example.s_object = …… ;
example1.s_object = …… ;
example2.s_object = …… ;
請注意上面標為黑體的三條語句,它們所訪問的 s_object 是同一個對象嗎?答案是肯定的,它們的確是指向同一個對象,這聽起來不像是真的,是嗎?但這是事實,你可以自己寫段簡單的代碼驗證一下。我要做的是來解釋為什么會這樣?
我們知道,當一個類比如 Derived1 ,從另一個類比如 Base 繼承時,那么,可以看作一個 Derived1 對象中含有一個 Base 型的對象,這就是一個 subobject 。一個 Derived1 對象的大致內存布局如下:
<shapetype id="_x0000_t75" stroked="f" filled="f" path="m@4@5l@4@11@9@11@9@5xe" o:preferrelative="t" o:spt="75" coordsize="21600,21600"><stroke joinstyle="miter"></stroke><formulas><f eqn="if lineDrawn pixelLineWidth 0"></f><f eqn="sum @0 1 0"></f><f eqn="sum 0 0 @1"></f><f eqn="prod @2 1 2"></f><f eqn="prod @3 21600 pixelWidth"></f><f eqn="prod @3 21600 pixelHeight"></f><f eqn="sum @0 0 1"></f><f eqn="prod @6 1 2"></f><f eqn="prod @7 21600 pixelWidth"></f><f eqn="sum @8 21600 0"></f><f eqn="prod @7 21600 pixelHeight"></f><f eqn="sum @10 21600 0"></f></formulas><path o:connecttype="rect" gradientshapeok="t" o:extrusionok="f"></path><lock aspectratio="t" v:ext="edit"></lock></shapetype><shape id="_x0000_i1025" style="WIDTH: 193.5pt; HEIGHT: 169.5pt" type="#_x0000_t75"><imagedata o:title="subobject" src="file:///C:/DOCUME~1/ADMINI~1/LOCALS~1/Temp/msohtml1/01/clip_image001.jpg"></imagedata></shape>
讓我們想想,當我們將一個 Derived1 型的對象傳給一個接受非引用 Base 型參數的函數時會發生切割,那么是怎么切割的呢?相信現在你已經知道了,那就是僅僅取出了 Derived1 型的對象中的 subobject ,而忽略了所有 Derived1 自定義的其它數據成員,然后將這個 subobject 傳遞給函數(實際上,函數中使用的是這個 subobject 的拷貝)。
所有繼承 Base 類的派生類的對象都含有一個 Base 型的 subobject (這是能用 Base 型指針指向一個 Derived1 對象的關鍵所在,自然也是多態的關鍵了),而所有的 subobject 和所有 Base 型的對象都共用同一個 s_object 對象,自然,從 Base 類派生的整個繼承體系中的類的實例都會共用同一個 s_object 對象了。上面提到的 example 、 example1 、 example2 的對象布局如下圖所示:
<shape id="_x0000_i1026" style="WIDTH: 405pt; HEIGHT: 198.75pt" type="#_x0000_t75"><imagedata o:title="subobject_share" src="file:///C:/DOCUME~1/ADMINI~1/LOCALS~1/Temp/msohtml1/01/clip_image002.jpg"><font size="3"></font></imagedata></shape>
二.三種內存對象的比較
棧對象的優勢是在適當的時候自動生成,又在適當的時候自動銷毀,不需要程序員操心;而且棧對象的創建速度一般較堆對象快,因為分配堆對象時,會調用 operator new 操作, operator new 會采用某種內存空間搜索算法,而該搜索過程可能是很費時間的,產生棧對象則沒有這么麻煩,它僅僅需要移動棧頂指針就可以了。但是要注意的是,通常棧空間容量比較小,一般是 1MB ~ 2MB ,所以體積比較大的對象不適合在棧中分配。特別要注意遞歸函數中最好不要使用棧對象,因為隨著遞歸調用深度的增加,所需的棧空間也會線性增加,當所需棧空間不夠時,便會導致棧溢出,這樣就會產生運行時錯誤。
堆對象,其產生時刻和銷毀時刻都要程序員精確定義,也就是說,程序員對堆對象的生命具有完全的控制權。我們常常需要這樣的對象,比如,我們需要創建一個對象,能夠被多個函數所訪問,但是又不想使其成為全局的,那么這個時候創建一個堆對象無疑是良好的選擇,然后在各個函數之間傳遞這個堆對象的指針,便可以實現對該對象的共享。另外,相比于棧空間,堆的容量要大得多。實際上,當物理內存不夠時,如果這時還需要生成新的堆對象,通常不會產生運行時錯誤,而是系統會使用虛擬內存來擴展實際的物理內存。
接下來看看 static 對象。
首先是全局對象。全局對象為類間通信和函數間通信提供了一種最簡單的方式,雖然這種方式并不優雅。一般而言,在完全的面向對象語言中,是不存在全局對象的,比如 C# ,因為全局對象意味著不安全和高耦合,在程序中過多地使用全局對象將大大降低程序的健壯性、穩定性、可維護性和可復用性。 C++ 也完全可以剔除全局對象,但是最終沒有,我想原因之一是為了兼容 C 。
其次是類的靜態成員,上面已經提到,基類及其派生類的所有對象都共享這個靜態成員對象,所以當需要在這些 class 之間或這些 class objects 之間進行數據共享或通信時,這樣的靜態成員無疑是很好的選擇。
接著是靜態局部對象,主要可用于保存該對象所在函數被屢次調用期間的中間狀態,其中一個最顯著的例子就是遞歸函數,我們都知道遞歸函數是自己調用自己的函數,如果在遞歸函數中定義一個 nonstatic 局部對象,那么當遞歸次數相當大時,所產生的開銷也是巨大的。這是因為 nonstatic 局部對象是棧對象,每遞歸調用一次,就會產生一個這樣的對象,每返回一次,就會釋放這個對象,而且,這樣的對象只局限于當前調用層,對于更深入的嵌套層和更淺露的外層,都是不可見的。每個層都有自己的局部對象和參數。
在遞歸函數設計中,可以使用 static 對象替代 nonstatic 局部對象(即棧對象),這不僅可以減少每次遞歸調用和返回時產生和釋放 nonstatic 對象的開銷,而且 static 對象還可以保存遞歸調用的中間狀態,并且可為各個調用層所訪問。
三.使用棧對象的意外收獲
前面已經介紹到,棧對象是在適當的時候創建,然后在適當的時候自動釋放的,也就是棧對象有自動管理功能。那么棧對象會在什么會自動釋放了?第一,在其生命期結束的時候;第二,在其所在的函數發生異常的時候。你也許說,這些都很正常啊,沒什么大不了的。是的,沒什么大不了的。但是只要我們再深入一點點,也許就有意外的收獲了。
棧對象,自動釋放時,會調用它自己的析構函數。如果我們在棧對象中封裝資源,而且在棧對象的析構函數中執行釋放資源的動作,那么就會使資源泄漏的概率大大降低,因為 棧對象可以自動的釋放資源,即使在所在函數發生異常的時候 。實際的過程是這樣的:函數拋出異常時,會發生所謂的 stack_unwinding (堆棧回滾),即堆棧會展開,由于是棧對象,自然存在于棧中,所以在堆棧回滾的過程中,棧對象的析構函數會被執行,從而釋放其所封裝的資源。除非,除非在析構函數執行的過程中再次拋出異常――而這種可能性是很小的,所以用棧對象封裝資源是比較安全的。基于此認識,我們就可以創建一個自己的句柄或代理來封裝資源了。智能指針( auto_ptr )中就使用了這種技術。在有這種需要的時候,我們就希望我們的資源封裝類只能在棧中創建,也就是要限制在堆中創建該資源封裝類的實例。
四.禁止產生堆對象
上面已經提到,你決定禁止產生某種類型的堆對象,這時你可以自己創建一個資源封裝類,該類對象只能在棧中產生,這樣就能在異常的情況下自動釋放封裝的資源。
那么怎樣禁止產生堆對象了?我們已經知道,產生堆對象的唯一方法是使用 new 操作,如果我們禁止使用 new 不就行了么。再進一步, new 操作執行時會調用 operator new ,而 operator new 是可以重載的。方法有了,就是使 new operator 為 private ,為了對稱,最好將 operator delete 也重載為 private 。現在,你也許又有疑問了 , 難道創建棧對象不需要調用 new 嗎?是的,不需要,因為創建棧對象不需要搜索內存,而是直接調整堆棧指針,將對象壓棧,而 operator new 的主要任務是搜索合適的堆內存,為堆對象分配空間,這在上面已經提到過了。好,讓我們看看下面的示例代碼:
#include <stdlib.h> // 需要用到 C 式內存分配函數
class Resource ; // 代表需要被封裝的資源類
class NoHashObject
{
private:
Resource* ptr ;// 指向被封裝的資源
... ... // 其它數據成員
void* operator new(size_t size) // 非嚴格實現,僅作示意之用
{
return malloc(size) ;
}
void operator delete(void* pp) // 非嚴格實現,僅作示意之用
{
free(pp) ;
}
public:
NoHashObject()
{
// 此處可以獲得需要封裝的資源,并讓 ptr 指針指向該資源
ptr = new Resource() ;
}
~NoHashObject()
{
delete ptr ; // 釋放封裝的資源
}
};
NoHashObject 現在就是一個禁止堆對象的類了,如果你寫下如下代碼:
NoHashObject* fp = new NoHashObject() ; // 編譯期錯誤!
delete fp ;
上面代碼會產生編譯期錯誤。好了,現在你已經知道了如何設計一個禁止堆對象的類了,你也許和我一樣有這樣的疑問,難道 在類 NoHashObject 的定義不能改變的情況下,就一定不能產生該類型的堆對象了嗎?不,還是有辦法的,我稱之為“暴力破解法”。 C++ 是如此地強大,強大到你可以用它做你想做的任何事情。這里主要用到的是技巧是指針類型的強制轉換。
void main(void)
{
char* temp = new char[sizeof(NoHashObject)] ;
// 強制類型轉換,現在 ptr 是一個指向 NoHashObject 對象的指針
NoHashObject* obj_ptr = (NoHashObject*)temp ;
temp = NULL ; // 防止通過 temp 指針修改 NoHashObject 對象
// 再一次強制類型轉換,讓 rp 指針指向堆中 NoHashObject 對象的 ptr 成員
Resource* rp = (Resource*)obj_ptr ;
// 初始化 obj_ptr 指向的 NoHashObject 對象的 ptr 成員
rp = new Resource() ;
// 現在可以通過使用 obj_ptr 指針使用堆中的 NoHashObject 對象成員了
... ...
delete rp ;// 釋放資源
temp = (char*)obj_ptr ;
obj_ptr = NULL ;// 防止懸掛指針產生
delete [] temp ;// 釋放 NoHashObject 對象所占的堆空間。
}
上面的實現是麻煩的,而且這種實現方式幾乎不會在實踐中使用,但是我還是寫出來路,因為理解它,對于我們理解 C++ 內存對象是有好處的。對于上面的這么多強制類型轉換,其最根本的是什么了?我們可以這樣理解:
某塊內存中的數據是不變的,而類型就是我們戴上的眼鏡,當我們戴上一種眼鏡后,我們就會用對應的類型來解釋內存中的數據,這樣不同的解釋就得到了不同的信息。
所謂強制類型轉換實際上就是換上另一副眼鏡后再來看同樣的那塊內存數據。
另外要提醒的是,不同的編譯器對對象的成員數據的布局安排可能是不一樣的,比如,大多數編譯器將 NoHashObject 的 ptr 指針成員安排在對象空間的頭 4 個字節,這樣才會保證下面這條語句的轉換動作像我們預期的那樣執行:
Resource* rp = (Resource*)obj_ptr ;
但是,并不一定所有的編譯器都是如此。
既然我們可以禁止產生某種類型的堆對象,那么可以設計一個類,使之不能產生棧對象嗎?當然可以。
五.禁止產生棧對象
前面已經提到了,創建棧對象時會移動棧頂指針以“挪出”適當大小的空間,然后在這個空間上直接調用對應的構造函數以形成一個棧對象,而當函數返回時,會調用其析構函數釋放這個對象,然后再調整棧頂指針收回那塊棧內存。在這個過程中是不需要 operator new/delete 操作的,所以將 operator new/delete 設置為 private 不能達到目的。當然從上面的敘述中,你也許已經想到了:將構造函數或析構函數設為私有的,這樣系統就不能調用構造 / 析構函數了,當然就不能在棧中生成對象了。
這樣的確可以,而且我也打算采用這種方案。但是在此之前,有一點需要考慮清楚 , 那就是,如果我們將構造函數設置為私有,那么我們也就不能用 new 來直接產生堆對象了,因為 new 在為對象分配空間后也會調用它的構造函數啊。所以,我打算只將析構函數設置為 private 。再進一步,將析構函數設為 private 除了會限制棧對象生成外,還有其它影響嗎?是的,這還會限制繼承。
如果一個類不打算作為基類,通常采用的方案就是將其析構函數聲明為 private 。
為了限制棧對象,卻不限制繼承,我們可以將析構函數聲明為 protected ,這樣就兩全其美了。如下代碼所示:
class NoStackObject
{
protected:
~NoStackObject() { }
public:
void destroy()
{
delete this ;// 調用保護析構函數
}
};
接著,可以像這樣使用 NoStackObject 類:
NoStackObject* hash_ptr = new NoStackObject() ;
... ... // 對 hash_ptr 指向的對象進行操作
hash_ptr->destroy() ;
呵呵,是不是覺得有點怪怪的,我們用 new 創建一個對象,卻不是用 delete 去刪除它,而是要用 destroy 方法。很顯然,用戶是不習慣這種怪異的使用方式的。所以,我決定將構造函數也設為 private 或 protected 。這又回到了上面曾試圖避免的問題,即不用 new ,那么該用什么方式來生成一個對象了? 我們可以用間接的辦法完成,即讓這個類提供一個 static 成員函數專門用于產生該類型的堆對象。(設計模式中的 singleton 模式就可以用這種方式實現。)讓我們來看看:
class NoStackObject
{
protected:
NoStackObject() { }
~NoStackObject() { }
public:
static NoStackObject* creatInstance()
{
return new NoStackObject() ;// 調用保護的構造函數
}
void destroy()
{
delete this ;// 調用保護的析構函數
}
};
現在可以這樣使用 NoStackObject 類了:
NoStackObject* hash_ptr = NoStackObject::creatInstance() ;
... ... // 對 hash_ptr 指向的對象進行操作
hash_ptr->destroy() ;
hash_ptr = NULL ; // 防止使用懸掛指針
現在感覺是不是好多了,生成對象和釋放對象的操作一致了。
ok ,講到這里,已經涉及了較多的東西,如果要把內存對象講得更深入更全面,那可能需要寫成一本書了,而就我自己的功力而言,可能是很難完全把握的。如果上面所寫的能使你有所收獲或啟發,我就滿足了。如果你要更進一步去了解內存對象方面的知識,那么我可以推薦你看看《深入探索 C++ 對象模型》這本書。
更多文章、技術交流、商務合作、聯系博主
微信掃碼或搜索:z360901061

微信掃一掃加我為好友
QQ號聯系: 360901061
您的支持是博主寫作最大的動力,如果您喜歡我的文章,感覺我的文章對您有幫助,請用微信掃描下面二維碼支持博主2元、5元、10元、20元等您想捐的金額吧,狠狠點擊下面給點支持吧,站長非常感激您!手機微信長按不能支付解決辦法:請將微信支付二維碼保存到相冊,切換到微信,然后點擊微信右上角掃一掃功能,選擇支付二維碼完成支付。
【本文對您有幫助就好】元
