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引用傳遞代替按值進行函數參數傳遞了(const引用傳遞時,函數內部直接進行操做的是這個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。this

讓咱們想一想,當咱們將一個Derived1型的對象傳給一個接受非引用Base型參數的函數時會發生切割,那麼是怎麼切割的呢?相信如今你已經知道了,那就是僅僅取出了Derived1型的對象中的subobject,而忽略了全部Derived1自定義的其它數據成員,而後將這個subobject傳遞給函數(實際上,函數中使用的是這個subobject的拷貝)。設計

全部繼承Base類的派生類的對象都含有一個Base型的subobject(這是能用Base型指針指向一個Derived1對象的關鍵所在,天然也是多態的關鍵了),而全部的subobject和全部Base型的對象都共用同一個s_object對象,天然,從Base類派生的整個繼承體系中的類的實例都會共用同一個s_object對象了。



二.三種內存對象的比較

棧對象的優點是在適當的時候自動生成,又在適當的時候自動銷燬,不須要程序員操心;並且棧對象的建立速度通常較堆對象快,由於分配堆對象時,會調用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++對象模型》這本書。

相關文章
相關標籤/搜索