特別警告:本文非本人所寫,但閱讀後真的受益不淺,心裏有激昂澎湃之勢,特以「原創」之由分享於此,感謝原做者的無私奉獻!因爲最近在研究《Effective C++》等書籍的緣由,真是發自心裏的感覺到了C++的博大精深,以致與做者共鳴,特記於此!ios
原文連接:http://blog.csdn.net/zhaozhao531322/article/details/13628963c++
[導語]程序員
內存管理是C++最使人切齒痛恨的問題,也是C++最有爭議的問題,C++高手從中得到了更好的性能,更大的自由,C++菜鳥的收穫則是一遍一遍的檢查代碼和對C++的痛恨,但內存管理在C++中無處不在,內存泄漏幾乎在每一個C++程序中都會發生,所以要想成爲C++高手,內存管理一關是必需要過的,除非放棄C++,轉到Java或者.NET,他們的內存管理基本是自動的,固然你也放棄了自由和對內存的支配權,還放棄了C++超絕的性能。本期專題將從內存管理、內存泄漏、內存回收這三個方面來探討C++內存管理問題。算法
偉大的Bill Gates 曾經失言:編程
640K ought to be enough for everybody — Bill Gates 1981小程序
程序員們常常編寫內存管理程序,每每提心吊膽。若是不想觸雷,惟一的解決辦法就是發現全部潛伏的地雷而且排除它們,躲是躲不了的。本文的內容比通常教科書的要深刻得多,讀者需細心閱讀,作到真正地通曉內存管理。設計模式
在C++中,內存分紅5個區,他們分別是堆、棧、自由存儲區、全局/靜態存儲區和常量存儲區。數組
棧,在執行函數時,函數內局部變量的存儲單元均可以在棧上建立,函數執行結束時這些存儲單元自動被釋放。棧內存分配運算內置於處理器的指令集中,效率很高,可是分配的內存容量有限。安全
堆,就是那些由new分配的內存塊,他們的釋放編譯器不去管,由咱們的應用程序去控制,通常一個new就要對應一個delete。若是程序員沒有釋放掉,那麼在程序結束後,操做系統會自動回收。服務器
自由存儲區,就是那些由malloc等分配的內存塊,他和堆是十分類似的,不過它是用free來結束本身的生命的。
全局/靜態存儲區,全局變量和靜態變量被分配到同一塊內存中,在之前的C語言中,全局變量又分爲初始化的和未初始化的,在C++裏面沒有這個區分了,他們共同佔用同一塊內存區。
常量存儲區,這是一塊比較特殊的存儲區,他們裏面存放的是常量,不容許修改。
在bbs上,堆與棧的區分問題,彷佛是一個永恆的話題,因而可知,初學者對此每每是混淆不清的,因此我決定拿他第一個開刀。
首先,咱們舉一個例子:
void f() { int* p=new int[5]; } |
這條短短的一句話就包含了堆與棧,看到new,咱們首先就應該想到,咱們分配了一塊堆內存,那麼指針p呢?他分配的是一塊棧內存,因此這句話的意思就是:在棧內存中存放了一個指向一塊堆內存的指針p。在程序會先肯定在堆中分配內存的大小,而後調用operator new分配內存,而後返回這塊內存的首地址,放入棧中,他在VC6下的彙編代碼以下:
00401028 push 14h 0040102A call operator new (00401060) 0040102F add esp,4 00401032 mov dword ptr [ebp-8],eax 00401035 mov eax,dword ptr [ebp-8] 00401038 mov dword ptr [ebp-4],eax |
這裏,咱們爲了簡單並無釋放內存,那麼該怎麼去釋放呢?是delete p麼?澳,錯了,應該是delete []p,這是爲了告訴編譯器:我刪除的是一個數組,VC6就會根據相應的Cookie信息去進行釋放內存的工做。
好了,咱們回到咱們的主題:堆和棧究竟有什麼區別?
主要的區別由如下幾點:
1、管理方式不一樣;
2、空間大小不一樣;
3、可否產生碎片不一樣;
4、生長方向不一樣;
5、分配方式不一樣;
6、分配效率不一樣;
管理方式:對於棧來說,是由編譯器自動管理,無需咱們手工控制;對於堆來講,釋放工做由程序員控制,容易產生memory leak。
空間大小:通常來說在32位系統下,堆內存能夠達到4G的空間,從這個角度來看堆內存幾乎是沒有什麼限制的。可是對於棧來說,通常都是有必定的空間大小的,例如,在VC6下面,默認的棧空間大小是1M(好像是,記不清楚了)。固然,咱們能夠修改:
打開工程,依次操做菜單以下:Project->Setting->Link,在Category 中選中Output,而後在Reserve中設定堆棧的最大值和commit。
注意:reserve最小值爲4Byte;commit是保留在虛擬內存的頁文件裏面,它設置的較大會使棧開闢較大的值,可能增長內存的開銷和啓動時間。
碎片問題:對於堆來說,頻繁的new/delete勢必會形成內存空間的不連續,從而形成大量的碎片,使程序效率下降。對於棧來說,則不會存在這個問題,由於棧是先進後出的隊列,他們是如此的一一對應,以致於永遠都不可能有一個內存塊從棧中間彈出,在他彈出以前,在他上面的後進的棧內容已經被彈出,詳細的能夠參考數據結構,這裏咱們就再也不一一討論了。
生長方向:對於堆來說,生長方向是向上的,也就是向着內存地址增長的方向;對於棧來說,它的生長方向是向下的,是向着內存地址減少的方向增加。
分配方式:堆都是動態分配的,沒有靜態分配的堆。棧有2種分配方式:靜態分配和動態分配。靜態分配是編譯器完成的,好比局部變量的分配。動態分配由alloca函數進行分配,可是棧的動態分配和堆是不一樣的,他的動態分配是由編譯器進行釋放,無需咱們手工實現。
分配效率:棧是機器系統提供的數據結構,計算機會在底層對棧提供支持:分配專門的寄存器存放棧的地址,壓棧出棧都有專門的指令執行,這就決定了棧的效率比較高。堆則是C/C++函數庫提供的,它的機制是很複雜的,例如爲了分配一塊內存,庫函數會按照必定的算法(具體的算法能夠參考數據結構/操做系統)在堆內存中搜索可用的足夠大小的空間,若是沒有足夠大小的空間(多是因爲內存碎片太多),就有可能調用系統功能去增長程序數據段的內存空間,這樣就有機會分到足夠大小的內存,而後進行返回。顯然,堆的效率比棧要低得多。
從這裏咱們能夠看到,堆和棧相比,因爲大量new/delete的使用,容易形成大量的內存碎片;因爲沒有專門的系統支持,效率很低;因爲可能引起用戶態和核心態的切換,內存的申請,代價變得更加昂貴。因此棧在程序中是應用最普遍的,就算是函數的調用也利用棧去完成,函數調用過程當中的參數,返回地址,EBP和局部變量都採用棧的方式存放。因此,咱們推薦你們儘可能用棧,而不是用堆。
雖然棧有如此衆多的好處,可是因爲和堆相比不是那麼靈活,有時候分配大量的內存空間,仍是用堆好一些。
不管是堆仍是棧,都要防止越界現象的發生(除非你是故意使其越界),由於越界的結果要麼是程序崩潰,要麼是摧毀程序的堆、棧結構,產生以想不到的結果,就算是在你的程序運行過程當中,沒有發生上面的問題,你仍是要當心,說不定何時就崩掉,那時候debug但是至關困難的:)
在嵌入式系統中使用C++的一個常見問題是內存分配,即對new 和 delete 操做符的失控。
具備諷刺意味的是,問題的根源倒是C++對內存的管理很是的容易並且安全。具體地說,當一個對象被消除時,它的析構函數可以安全的釋放所分配的內存。
這固然是個好事情,可是這種使用的簡單性使得程序員們過分使用new 和 delete,而不注意在嵌入式C++環境中的因果關係。而且,在嵌入式系統中,因爲內存的限制,頻繁的動態分配不定大小的內存會引發很大的問題以及堆破碎的風險。
做爲忠告,保守的使用內存分配是嵌入式環境中的第一原則。
但當你必需要使用new 和delete時,你不得不控制C++中的內存分配。你須要用一個全局的new 和delete來代替系統的內存分配符,而且一個類一個類的重載new 和delete。
一個防止堆破碎的通用方法是從不一樣固定大小的內存持中分配不一樣類型的對象。對每一個類重載new 和delete就提供了這樣的控制。
能夠很容易地重載new 和 delete 操做符,以下所示:
void * operator new(size_t size) { void *p = malloc(size); return (p); } void operator delete(void *p); { free(p); } |
這段代碼能夠代替默認的操做符來知足內存分配的請求。出於解釋C++的目的,咱們也能夠直接調用malloc()和free()。
也能夠對單個類的new 和 delete 操做符重載。這是你能靈活的控制對象的內存分配。
class TestClass { public: void * operator new(size_t size); void operator delete(void *p); // .. other members here ... }; void *TestClass::operator new(size_t size) { void *p = malloc(size); // Replace this with alternative allocator return (p); } void TestClass::operator delete(void *p) { free(p); // Replace this with alternative de-allocator } |
全部TestClass 對象的內存分配都採用這段代碼。更進一步,任何從TestClass 繼承的類也都採用這一方式,除非它本身也重載了new 和 delete 操做符。經過重載new 和 delete 操做符的方法,你能夠自由地採用不一樣的分配策略,從不一樣的內存池中分配不一樣的類對象。
必須當心對象數組的分配。你可能但願調用到被你重載過的new 和 delete 操做符,但並不如此。內存的請求被定向到全局的new[ ]和delete[ ] 操做符,而這些內存來自於系統堆。
C++將對象數組的內存分配做爲一個單獨的操做,而不一樣於單個對象的內存分配。爲了改變這種方式,你一樣須要重載new[ ] 和 delete[ ]操做符。
class TestClass { public: void * operator new[ ](size_t size); void operator delete[ ](void *p); // .. other members here .. }; void *TestClass::operator new[ ](size_t size) { void *p = malloc(size); return (p); } void TestClass::operator delete[ ](void *p) { free(p); } int main(void) { TestClass *p = new TestClass[10]; // ... etc ... delete[ ] p; } |
可是注意:對於多數C++的實現,new[]操做符中的個數參數是數組的大小加上額外的存儲對象數目的一些字節。在你的內存分配機制重要考慮的這一點。你應該儘可能避免分配對象數組,從而使你的內存分配策略簡單。
發生內存錯誤是件很是麻煩的事情。編譯器不能自動發現這些錯誤,一般是在程序運行時才能捕捉到。而這些錯誤大多沒有明顯的症狀,時隱時現,增長了改錯的難度。有時用戶怒氣衝衝地把你找來,程序卻沒有發生任何問題,你一走,錯誤又發做了。 常見的內存錯誤及其對策以下:
* 內存分配未成功,卻使用了它。
編程新手常犯這種錯誤,由於他們沒有意識到內存分配會不成功。經常使用解決辦法是,在使用內存以前檢查指針是否爲NULL。若是指針p是函數的參數,那麼在函數的入口處用assert(p!=NULL)進行
檢查。若是是用malloc或new來申請內存,應該用if(p==NULL) 或if(p!=NULL)進行防錯處理。
* 內存分配雖然成功,可是還沒有初始化就引用它。
犯這種錯誤主要有兩個原由:一是沒有初始化的觀念;二是誤覺得內存的缺省初值全爲零,致使引用初值錯誤(例如數組)。 內存的缺省初值到底是什麼並無統一的標準,儘管有些時候爲零值,咱們寧肯信其無不可信其有。因此不管用何種方式建立數組,都別忘了賦初值,即使是賦零值也不可省略,不要嫌麻煩。
* 內存分配成功而且已經初始化,但操做越過了內存的邊界。
例如在使用數組時常常發生下標「多1」或者「少1」的操做。特別是在for循環語句中,循環次數很容易搞錯,致使數組操做越界。
* 忘記了釋放內存,形成內存泄露。
含有這種錯誤的函數每被調用一次就丟失一塊內存。剛開始時系統的內存充足,你看不到錯誤。終有一次程序忽然死掉,系統出現提示:內存耗盡。
動態內存的申請與釋放必須配對,程序中malloc與free的使用次數必定要相同,不然確定有錯誤(new/delete同理)。
* 釋放了內存卻繼續使用它。
有三種狀況:
(1)程序中的對象調用關係過於複雜,實在難以搞清楚某個對象到底是否已經釋放了內存,此時應該從新設計數據結構,從根本上解決對象管理的混亂局面。
(2)函數的return語句寫錯了,注意不要返回指向「棧內存」的「指針」或者「引用」,由於該內存在函數體結束時被自動銷燬。
(3)使用free或delete釋放了內存後,沒有將指針設置爲NULL。致使產生「野指針」。
【規則1】用malloc或new申請內存以後,應該當即檢查指針值是否爲NULL。防止使用指針值爲NULL的內存。
【規則2】不要忘記爲數組和動態內存賦初值。防止將未被初始化的內存做爲右值使用。
【規則3】避免數組或指針的下標越界,特別要小心發生「多1」或者「少1」操做。
【規則4】動態內存的申請與釋放必須配對,防止內存泄漏。
【規則5】用free或delete釋放了內存以後,當即將指針設置爲NULL,防止產生「野指針」。
C++/C程序中,指針和數組在很多地方能夠相互替換着用,讓人產生一種錯覺,覺得二者是等價的。
數組要麼在靜態存儲區被建立(如全局數組),要麼在棧上被建立。數組名對應着(而不是指向)一塊內存,其地址與容量在生命期內保持不變,只有數組的內容能夠改變。
指針能夠隨時指向任意類型的內存塊,它的特徵是「可變」,因此咱們經常使用指針來操做動態內存。指針遠比數組靈活,但也更危險。
下面以字符串爲例比較指針與數組的特性。
下面示例中,字符數組a的容量是6個字符,其內容爲hello。a的內容能夠改變,如a[0]= ‘X’。指針p指向常量字符串「world」(位於靜態存儲區,內容爲world),常量字符串的內容是不能夠被修改的。從語法上看,編譯器並不以爲語句p[0]= ‘X’有什麼不妥,可是該語句企圖修改常量字符串的內容而致使運行錯誤。
char a[] = 「hello」; a[0] = ‘X’; cout << a << endl; char *p = 「world」; // 注意p指向常量字符串 p[0] = ‘X’; // 編譯器不能發現該錯誤 cout << p << endl; |
不能對數組名進行直接複製與比較。若想把數組a的內容複製給數組b,不能用語句 b = a ,不然將產生編譯錯誤。應該用標準庫函數strcpy進行復制。同理,比較b和a的內容是否相同,不能用if(b==a) 來判斷,應該用標準庫函數strcmp進行比較。
語句p = a 並不能把a的內容複製指針p,而是把a的地址賦給了p。要想複製a的內容,能夠先用庫函數malloc爲p申請一塊容量爲strlen(a)+1個字符的內存,再用strcpy進行字符串複製。同理,語句if(p==a) 比較的不是內容而是地址,應該用庫函數strcmp來比較。
// 數組… char a[] = "hello"; char b[10]; strcpy(b, a); // 不能用 b = a; if(strcmp(b, a) == 0) // 不能用 if (b == a) … // 指針… int len = strlen(a); char *p = (char *)malloc(sizeof(char)*(len+1)); strcpy(p,a); // 不要用 p = a; if(strcmp(p, a) == 0) // 不要用 if (p == a) … |
用運算符sizeof能夠計算出數組的容量(字節數)。以下示例中,sizeof(a)的值是12(注意別忘了’’)。指針p指向a,可是sizeof(p)的值倒是4。這是由於sizeof(p)獲得的是一個指針變量的字節數,至關於sizeof(char*),而不是p所指的內存容量。C++/C語言沒有辦法知道指針所指的內存容量,除非在申請內存時記住它。
char a[] = "hello world"; char *p = a; cout<< sizeof(a) << endl; // 12字節 cout<< sizeof(p) << endl; // 4字節 |
注意當數組做爲函數的參數進行傳遞時,該數組自動退化爲同類型的指針。以下示例中,不論數組a的容量是多少,sizeof(a)始終等於sizeof(char *)。
void Func(char a[100]) { cout<< sizeof(a) << endl; // 4字節而不是100字節 } |
若是函數的參數是一個指針,不要期望用該指針去申請動態內存。以下示例中,Test函數的語句GetMemory(str, 200)並無使str得到指望的內存,str依舊是NULL,爲何?
void GetMemory(char *p, int num) { p = (char *)malloc(sizeof(char) * num); } void Test(void) { char *str = NULL; GetMemory(str, 100); // str 仍然爲 NULL strcpy(str, "hello"); // 運行錯誤 } |
毛病出在函數GetMemory中。編譯器老是要爲函數的每一個參數製做臨時副本,指針參數p的副本是 _p,編譯器使 _p = p。若是函數體內的程序修改了_p的內容,就致使參數p的內容做相應的修改。這就是指針能夠用做輸出參數的緣由。在本例中,_p申請了新的內存,只是把_p所指的內存地址改變了,可是p絲毫未變。因此函數GetMemory並不能輸出任何東西。事實上,每執行一次GetMemory就會泄露一塊內存,由於沒有用free釋放內存。
若是非得要用指針參數去申請內存,那麼應該改用「指向指針的指針」,見示例:
void GetMemory2(char **p, int num) { *p = (char *)malloc(sizeof(char) * num); } void Test2(void) { char *str = NULL; GetMemory2(&str, 100); // 注意參數是 &str,而不是str strcpy(str, "hello"); cout<< str << endl; free(str); } |
因爲「指向指針的指針」這個概念不容易理解,咱們能夠用函數返回值來傳遞動態內存。這種方法更加簡單,見示例:
char *GetMemory3(int num) { char *p = (char *)malloc(sizeof(char) * num); return p; } void Test3(void) { char *str = NULL; str = GetMemory3(100); strcpy(str, "hello"); cout<< str << endl; free(str); } |
用函數返回值來傳遞動態內存這種方法雖然好用,可是經常有人把return語句用錯了。這裏強調不要用return語句返回指向「棧內存」的指針,由於該內存在函數結束時自動消亡,見示例:
char *GetString(void) { char p[] = "hello world"; return p; // 編譯器將提出警告 } void Test4(void) { char *str = NULL; str = GetString(); // str 的內容是垃圾 cout<< str << endl; } |
用調試器逐步跟蹤Test4,發現執行str = GetString語句後str再也不是NULL指針,可是str的內容不是「hello world」而是垃圾。
若是把上述示例改寫成以下示例,會怎麼樣?
char *GetString2(void) { char *p = "hello world"; return p; } void Test5(void) { char *str = NULL; str = GetString2(); cout<< str << endl; } |
函數Test5運行雖然不會出錯,可是函數GetString2的設計概念倒是錯誤的。由於GetString2內的「hello world」是常量字符串,位於靜態存儲區,它在程序生命期內恆定不變。不管何時調用GetString2,它返回的始終是同一個「只讀」的內存塊。
「野指針」不是NULL指針,是指向「垃圾」內存的指針。人們通常不會錯用NULL指針,由於用if語句很容易判斷。可是「野指針」是很危險的,if語句對它不起做用。 「野指針」的成因主要有兩種:
(1)指針變量沒有被初始化。任何指針變量剛被建立時不會自動成爲NULL指針,它的缺省值是隨機的,它會亂指一氣。因此,指針變量在建立的同時應當被初始化,要麼將指針設置爲NULL,要麼讓它指向合法的內存。例如
char *p = NULL; char *str = (char *) malloc(100); |
(2)指針p被free或者delete以後,沒有置爲NULL,讓人誤覺得p是個合法的指針。
(3)指針操做超越了變量的做用域範圍。這種狀況讓人防不勝防,示例程序以下:
class A { public: void Func(void){ cout << 「Func of class A」 << endl; } }; void Test(void) { A *p; { A a; p = &a; // 注意 a 的生命期 } p->Func(); // p是「野指針」 } |
函數Test在執行語句p->Func()時,對象a已經消失,而p是指向a的,因此p就成了「野指針」。但奇怪的是我運行這個程序時竟然沒有出錯,這可能與編譯器有關。
malloc與free是C++/C語言的標準庫函數,new/delete是C++的運算符。它們均可用於申請動態內存和釋放內存。
對於非內部數據類型的對象而言,光用maloc/free沒法知足動態對象的要求。對象在建立的同時要自動執行構造函數,對象在消亡以前要自動執行析構函數。因爲malloc/free是庫函數而不是運算符,不在編譯器控制權限以內,不可以把執行構造函數和析構函數的任務強加於malloc/free。
所以C++語言須要一個能完成動態內存分配和初始化工做的運算符new,以及一個能完成清理與釋放內存工做的運算符delete。注意new/delete不是庫函數。咱們先看一看malloc/free和new/delete如何實現對象的動態內存管理,見示例:
class Obj { public : Obj(void){ cout << 「Initialization」 << endl; } ~Obj(void){ cout << 「Destroy」 << endl; } void Initialize(void){ cout << 「Initialization」 << endl; } void Destroy(void){ cout << 「Destroy」 << endl; } }; void UseMallocFree(void) { Obj *a = (obj *)malloc(sizeof(obj)); // 申請動態內存 a->Initialize(); // 初始化 //… a->Destroy(); // 清除工做 free(a); // 釋放內存 } void UseNewDelete(void) { Obj *a = new Obj; // 申請動態內存而且初始化 //… delete a; // 清除而且釋放內存 } |
類Obj的函數Initialize模擬了構造函數的功能,函數Destroy模擬了析構函數的功能。函數UseMallocFree中,因爲malloc/free不能執行構造函數與析構函數,必須調用成員函數Initialize和Destroy來完成初始化與清除工做。函數UseNewDelete則簡單得多。
因此咱們不要企圖用malloc/free來完成動態對象的內存管理,應該用new/delete。因爲內部數據類型的「對象」沒有構造與析構的過程,對它們而言malloc/free和new/delete是等價的。
既然new/delete的功能徹底覆蓋了malloc/free,爲何C++不把malloc/free淘汰出局呢?這是由於C++程序常常要調用C函數,而C程序只能用malloc/free管理動態內存。
若是用free釋放「new建立的動態對象」,那麼該對象因沒法執行析構函數而可能致使程序出錯。若是用delete釋放「malloc申請的動態內存」,結果也會致使程序出錯,可是該程序的可讀性不好。因此new/delete必須配對使用,malloc/free也同樣。
若是在申請動態內存時找不到足夠大的內存塊,malloc和new將返回NULL指針,宣告內存申請失敗。一般有三種方式處理「內存耗盡」問題。
(1)判斷指針是否爲NULL,若是是則立刻用return語句終止本函數。例如:
void Func(void) { A *a = new A; if(a == NULL) { return; } … } |
(2)判斷指針是否爲NULL,若是是則立刻用exit(1)終止整個程序的運行。例如:
void Func(void) { A *a = new A; if(a == NULL) { cout << 「Memory Exhausted」 << endl; exit(1); } … } |
(3)爲new和malloc設置異常處理函數。例如Visual C++能夠用_set_new_hander函數爲new設置用戶本身定義的異常處理函數,也可讓malloc享用與new相同的異常處理函數。詳細內容請參考C++使用手冊。
上述(1)(2)方式使用最廣泛。若是一個函數內有多處須要申請動態內存,那麼方式(1)就顯得力不從心(釋放內存很麻煩),應該用方式(2)來處理。
不少人不忍心用exit(1),問:「不編寫出錯處理程序,讓操做系統本身解決行不行?」
不行。若是發生「內存耗盡」這樣的事情,通常說來應用程序已經無藥可救。若是不用exit(1) 把壞程序殺死,它可能會害死操做系統。道理如同:若是不把歹徒擊斃,歹徒在老死以前會犯下更多的罪。
有一個很重要的現象要告訴你們。對於32位以上的應用程序而言,不管怎樣使用malloc與new,幾乎不可能致使「內存耗盡」。我在Windows 98下用Visual C++編寫了測試程序,見示例7。這個程序會無休止地運行下去,根本不會終止。由於32位操做系統支持「虛存」,內存用完了,自動用硬盤空間頂替。我只聽到硬盤嘎吱嘎吱地響,Window 98已經累得對鍵盤、鼠標毫無反應。
我能夠得出這麼一個結論:對於32位以上的應用程序,「內存耗盡」錯誤處理程序毫無用處。這下可把Unix和Windows程序員們樂壞了:反正錯誤處理程序不起做用,我就不寫了,省了不少麻煩。
我不想誤導讀者,必須強調:不加錯誤處理將致使程序的質量不好,千萬不可因小失大。
void main(void) { float *p = NULL; while(TRUE) { p = new float[1000000]; cout << 「eat memory」 << endl; if(p==NULL) exit(1); } } |
函數malloc的原型以下:
void * malloc(size_t size); |
用malloc申請一塊長度爲length的整數類型的內存,程序以下:
int *p = (int *) malloc(sizeof(int) * length); |
咱們應當把注意力集中在兩個要素上:「類型轉換」和「sizeof」。
* malloc返回值的類型是void *,因此在調用malloc時要顯式地進行類型轉換,將void * 轉換成所須要的指針類型。
* malloc函數自己並不識別要申請的內存是什麼類型,它只關心內存的總字節數。咱們一般記不住int, float等數據類型的變量的確切字節數。例如int變量在16位系統下是2個字節,在32位下是4個字節;而float變量在16位系統下是4個字節,在32位下也是4個字節。最好用如下程序做一次測試:
cout << sizeof(char) << endl; cout << sizeof(int) << endl; cout << sizeof(unsigned int) << endl; cout << sizeof(long) << endl; cout << sizeof(unsigned long) << endl; cout << sizeof(float) << endl; cout << sizeof(double) << endl; cout << sizeof(void *) << endl; |
在malloc的「()」中使用sizeof運算符是良好的風格,但要小心有時咱們會昏了頭,寫出 p = malloc(sizeof(p))這樣的程序來。
函數free的原型以下:
void free( void * memblock ); |
爲何free函數不象malloc函數那樣複雜呢?這是由於指針p的類型以及它所指的內存的容量事先都是知道的,語句free(p)能正確地釋放內存。若是p是NULL指針,那麼free對p不管操做多少次都不會出問題。若是p不是NULL指針,那麼free對p連續操做兩次就會致使程序運行錯誤。
運算符new使用起來要比函數malloc簡單得多,例如:
int *p1 = (int *)malloc(sizeof(int) * length); int *p2 = new int[length]; |
這是由於new內置了sizeof、類型轉換和類型安全檢查功能。對於非內部數據類型的對象而言,new在建立動態對象的同時完成了初始化工做。若是對象有多個構造函數,那麼new的語句也能夠有多種形式。例如
class Obj { public : Obj(void); // 無參數的構造函數 Obj(int x); // 帶一個參數的構造函數 … } void Test(void) { Obj *a = new Obj; Obj *b = new Obj(1); // 初值爲1 … delete a; delete b; } |
若是用new建立對象數組,那麼只能使用對象的無參數構造函數。例如:
Obj *objects = new Obj[100]; // 建立100個動態對象 |
不能寫成:
Obj *objects = new Obj[100](1);// 建立100個動態對象的同時賦初值1 |
在用delete釋放對象數組時,留意不要丟了符號‘[]’。例如:
delete []objects; // 正確的用法 delete objects; // 錯誤的用法 |
後者有可能引發程序崩潰和內存泄漏。
我最喜歡的對資源的定義是:"任何在你的程序中得到並在此後釋放的東西?quot;內存是一個至關明顯的資源的例子。它須要用new來得到,用delete來釋放。同時也有許多其它類型的資源文件句柄、重要的片段、Windows中的GDI資源,等等。將資源的概念推廣到程序中建立、釋放的全部對象也是十分方便的,不管對象是在堆中分配的仍是在棧中或者是在全局做用於內生命的。
對於給定的資源的擁有着,是負責釋放資源的一個對象或者是一段代碼。全部權分立爲兩種級別——自動的和顯式的(automatic and explicit),若是一個對象的釋放是由語言自己的機制來保證的,這個對象的就是被自動地全部。例如,一個嵌入在其餘對象中的對象,他的清除須要其餘對象來在清除的時候保證。外面的對象被看做嵌入類的全部者。 相似地,每一個在棧上建立的對象(做爲自動變量)的釋放(破壞)是在控制流離開了對象被定義的做用域的時候保證的。這種狀況下,做用於被看做是對象的全部者。注意全部的自動全部權都是和語言的其餘機制相容的,包括異常。不管是如何退出做用域的——正常流程控制退出、一個break語句、一個return、一個goto、或者是一個throw——自動資源均可以被清除。
到目前爲止,一切都很好!問題是在引入指針、句柄和抽象的時候產生的。若是經過一個指針訪問一個對象的話,好比對象在堆中分配,C++不自動地關注它的釋放。程序員必須明確的用適當的程序方法來釋放這些資源。好比說,若是一個對象是經過調用new來建立的,它須要用delete來回收。一個文件是用CreateFile(Win32 API)打開的,它須要用CloseHandle來關閉。用EnterCritialSection進入的臨界區(Critical Section)須要LeaveCriticalSection退出,等等。一個"裸"指針,文件句柄,或者臨界區狀態沒有全部者來確保它們的最終釋放。基本的資源管理的前提就是確保每一個資源都有他們的全部者。
一個指針,一個句柄,一個臨界區狀態只有在咱們將它們封裝入對象的時候纔會擁有全部者。這就是咱們的第一規則:在構造函數中分配資源,在析構函數中釋放資源。
當你按照規則將全部資源封裝的時候,你能夠保證你的程序中沒有任何的資源泄露。這點在當封裝對象(Encapsulating Object)在棧中創建或者嵌入在其餘的對象中的時候很是明顯。可是對那些動態申請的對象呢?不要急!任何動態申請的東西都被看做一種資源,而且要按照上面提到的方法進行封裝。這一對象封裝對象的鏈不得不在某個地方終止。它最終終止在最高級的全部者,自動的或者是靜態的。這些分別是對離開做用域或者程序時釋放資源的保證。
下面是資源封裝的一個經典例子。在一個多線程的應用程序中,線程之間共享對象的問題是經過用這樣一個對象聯繫臨界區來解決的。每個須要訪問共享資源的客戶須要得到臨界區。例如,這多是Win32下臨界區的實現方法。
class CritSect { friend class Lock; public: CritSect () { InitializeCriticalSection (&_critSection); } ~CritSect () { DeleteCriticalSection (&_critSection); } private: void Acquire () { EnterCriticalSection (&_critSection); } void Release () { LeaveCriticalSection (&_critSection); } private: CRITICAL_SECTION _critSection; }; |
這裏聰明的部分是咱們確保每個進入臨界區的客戶最後均可以離開。"進入"臨界區的狀態是一種資源,並應當被封裝。封裝器一般被稱做一個鎖(lock)。
class Lock { public: Lock (CritSect& critSect) : _critSect (critSect) { _critSect.Acquire (); } ~Lock () { _critSect.Release (); } private CritSect & _critSect; }; |
鎖通常的用法以下:
void Shared::Act () throw (char *) { Lock lock (_critSect); // perform action —— may throw // automatic destructor of lock } |
注意不管發生什麼,臨界區都會藉助於語言的機制保證釋放。
還有一件須要記住的事情——每一種資源都須要被分別封裝。這是由於資源分配是一個很是容易出錯的操做,是要資源是有限提供的。咱們會假設一個失敗的資源分配會致使一個異常——事實上,這會常常的發生。因此若是你想試圖用一個石頭打兩隻鳥的話,或者在一個構造函數中申請兩種形式的資源,你可能就會陷入麻煩。只要想一想在一種資源分配成功但另外一種失敗拋出異常時會發生什麼。由於構造函數尚未所有完成,析構函數不可能被調用,第一種資源就會發生泄露。
這種狀況能夠很是簡單的避免。不管什麼時候你有一個須要兩種以上資源的類時,寫兩個小的封裝器將它們嵌入你的類中。每個嵌入的構造均可以保證刪除,即便包裝類沒有構造完成。
咱們至今尚未討論最多見類型的資源——用操做符new分配,此後用指針訪問的一個對象。咱們須要爲每一個對象分別定義一個封裝類嗎?(事實上,C++標準模板庫已經有了一個模板類,叫作auto_ptr,其做用就是提供這種封裝。咱們一下子在回到auto_ptr。)讓咱們從一個極其簡單、呆板但安全的東西開始。看下面的Smart Pointer模板類,它十分堅固,甚至沒法實現。
template <class T> class SmartPointer { public: ~SmartPointer () { delete _p; } T * operator->() { return _p; } T const * operator->() const { return _p; } protected: SmartPointer (): _p (0) {} explicit SmartPointer (T* p): _p (p) {} T * _p; }; |
爲何要把SmartPointer的構造函數設計爲protected呢?若是我須要遵照第一條規則,那麼我就必須這樣作。資源——在這裏是class T的一個對象——必須在封裝器的構造函數中分配。可是我不能只簡單的調用new T,由於我不知道T的構造函數的參數。由於,在原則上,每個T都有一個不一樣的構造函數;我須要爲他定義個另一個封裝器。模板的用處會很大,爲每個新的類,我能夠經過繼承SmartPointer定義一個新的封裝器,而且提供一個特定的構造函數。
class SmartItem: public SmartPointer<Item> { public: explicit SmartItem (int i) : SmartPointer<Item> (new Item (i)) {} }; |
爲每個類提供一個Smart Pointer真的值得嗎?說實話——不!他頗有教學的價值,可是一旦你學會如何遵循第一規則的話,你就能夠放鬆規則並使用一些高級的技術。這一技術是讓SmartPointer的構造函數成爲public,可是隻是是用它來作資源轉換(Resource Transfer)個人意思是用new操做符的結果直接做爲SmartPointer的構造函數的參數,像這樣:
SmartPointer<Item> item (new Item (i)); |
這個方法明顯更須要自控性,不僅是你,並且包括你的程序小組的每一個成員。他們都必須發誓出了做資源轉換外不把構造函數用在人以其餘用途。幸運的是,這條規矩很容易得以增強。只須要在源文件中查找全部的new便可。
到目前爲止,咱們所討論的一直是生命週期在一個單獨的做用域內的資源。如今咱們要解決一個困難的問題——如何在不一樣的做用域間安全的傳遞資源。這一問題在當你處理容器的時候會變得十分明顯。你能夠動態的建立一串對象,將它們存放至一個容器中,而後將它們取出,而且在最終安排它們。爲了可以讓這安全的工做——沒有泄露——對象須要改變其全部者。
這個問題的一個很是顯而易見的解決方法是使用Smart Pointer,不管是在加入容器前仍是還找到它們之後。這是他如何運做的,你加入Release方法到Smart Pointer中:
template <class T> T * SmartPointer<T>::Release () { T * pTmp = _p; _p = 0; return pTmp; } |
注意在Release調用之後,Smart Pointer就再也不是對象的全部者了——它內部的指針指向空。如今,調用了Release都必須是一個負責的人而且迅速隱藏返回的指針到新的全部者對象中。在咱們的例子中,容器調用了Release,好比這個Stack的例子:
void Stack::Push (SmartPointer <Item> & item) throw (char *) { if (_top == maxStack) throw "Stack overflow"; _arr [_top++] = item.Release (); }; |
一樣的,你也能夠再你的代碼中用增強Release的可靠性。
相應的Pop方法要作些什麼呢?他應該釋放了資源並祈禱調用它的是一個負責的人並且當即做一個資源傳遞它到一個Smart Pointer?這聽起來並很差。
資源管理在內容索引(Windows NT Server上的一部分,如今是Windows 2000)上工做,而且,我對這十分滿意。而後我開始想……這一方法是在這樣一個完整的系統中造成的,若是能夠把它內建入語言的自己豈不是一件很是好?我提出了強指針(Strong Pointer)和弱指針(Weak Pointer)。一個Strong Pointer會在許多地方和咱們這個SmartPointer類似--它在超出它的做用域後會清除他所指向的對象。資源傳遞會以強指針賦值的形式進行。也能夠有Weak Pointer存在,它們用來訪問對象而不須要全部對象--好比可賦值的引用。
任何指針都必須聲明爲Strong或者Weak,而且語言應該來關注類型轉換的規定。例如,你不能夠將Weak Pointer傳遞到一個須要Strong Pointer的地方,可是相反卻能夠。Push方法能夠接受一個Strong Pointer而且將它轉移到Stack中的Strong Pointer的序列中。Pop方法將會返回一個Strong Pointer。把Strong Pointer的引入語言將會使垃圾回收成爲歷史。
這裏還有一個小問題--修改C++標準幾乎和競選美國總統同樣容易。當我將個人注意告訴給Bjarne Stroutrup的時候,他看個人眼神好像是我剛剛要向他借一千美圓同樣。
而後我忽然想到一個念頭。我能夠本身實現Strong Pointers。畢竟,它們都很想Smart Pointers。給它們一個拷貝構造函數並重載賦值操做符並非一個大問題。事實上,這正是標準庫中的auto_ptr有的。重要的是對這些操做給出一個資源轉移的語法,可是這也不是很難。
template <class T> SmartPointer<T>::SmartPointer (SmartPointer<T> & ptr) { _p = ptr.Release (); } template <class T> void SmartPointer<T>::operator = (SmartPointer<T> & ptr) { if (_p != ptr._p) { delete _p; _p = ptr.Release (); } } |
使這整個想法迅速成功的緣由之一是我能夠以值方式傳遞這種封裝指針!我有了個人蛋糕,而且也能夠吃了。看這個Stack的新的實現:
class Stack { enum { maxStack = 3 }; public: Stack () : _top (0) {} void Push (SmartPointer<Item> & item) throw (char *) { if (_top >= maxStack) throw "Stack overflow"; _arr [_top++] = item; } SmartPointer<Item> Pop () { if (_top == 0) return SmartPointer<Item> (); return _arr [--_top]; } private int _top; SmartPointer<Item> _arr [maxStack]; }; |
Pop方法強制客戶將其返回值賦給一個Strong Pointer,SmartPointer<Item>。任何試圖將他對一個普通指針的賦值都會產生一個編譯期錯誤,由於類型不匹配。此外,由於Pop以值方式返回一個Strong Pointer(在Pop的聲明時SmartPointer<Item>後面沒有&符號),編譯器在return時自動進行了一個資源轉換。他調用了operator =來從數組中提取一個Item,拷貝構造函數將他傳遞給調用者。調用者最後擁有了指向Pop賦值的Strong Pointer指向的一個Item。
我立刻意識到我已經在某些東西之上了。我開始用了新的方法重寫原來的代碼。
我過去有一個老的算術操做分析器,是用老的資源管理的技術寫的。分析器的做用是在分析樹中生成節點,節點是動態分配的。例如分析器的Expression方法生成一個表達式節點。我沒有時間用Strong Pointer去重寫這個分析器。我令Expression、Term和Factor方法以傳值的方式將Strong Pointer返回到Node中。看下面的Expression方法的實現:
SmartPointer<Node> Parser::Expression() { // Parse a term SmartPointer<Node> pNode = Term (); EToken token = _scanner.Token(); if ( token == tPlus || token == tMinus ) { // Expr := Term { ('+' | '-') Term } SmartPointer<MultiNode> pMultiNode = new SumNode (pNode); do { _scanner.Accept(); SmartPointer<Node> pRight = Term (); pMultiNode->AddChild (pRight, (token == tPlus)); token = _scanner.Token(); } while (token == tPlus || token == tMinus); pNode = up_cast<Node, MultiNode> (pMultiNode); } // otherwise Expr := Term return pNode; // by value! } |
最開始,Term方法被調用。他傳值返回一個指向Node的Strong Pointer而且馬上把它保存到咱們本身的Strong Pointer,pNode中。若是下一個符號不是加號或者減號,咱們就簡單的把這個SmartPointer以值返回,這樣就釋放了Node的全部權。另一方面,若是下一個符號是加號或者減號,咱們建立一個新的SumMode而且馬上(直接傳遞)將它儲存到MultiNode的一個Strong Pointer中。這裏,SumNode是從MultiMode中繼承而來的,而MulitNode是從Node繼承而來的。原來的Node的全部權轉給了SumNode。
只要是他們在被加號和減號分開的時候,咱們就不斷的建立terms,咱們將這些term轉移到咱們的MultiNode中,同時MultiNode獲得了全部權。最後,咱們將指向MultiNode的Strong Pointer向上映射爲指向Mode的Strong Pointer,而且將他返回調用着。
咱們須要對Strong Pointers進行顯式的向上映射,即便指針是被隱式的封裝。例如,一個MultiNode是一個Node,可是相同的is-a關係在SmartPointer<MultiNode>和SmartPointer<Node>之間並不存在,由於它們是分離的類(模板實例)並不存在繼承關係。up-cast模板是像下面這樣定義的:
template<class To, class From> inline SmartPointer<To> up_cast (SmartPointer<From> & from) { return SmartPointer<To> (from.Release ()); } |
若是你的編譯器支持新加入標準的成員模板(member template)的話,你能夠爲SmartPointer<T>定義一個新的構造函數用來從接受一個class U。
template <class T> template <class U> SmartPointer<T>::SmartPointer (SPrt<U> & uptr) : _p (uptr.Release ()) {} |
這裏的這個花招是模板在U不是T的子類的時候就不會編譯成功(換句話說,只在U is-a T的時候纔會編譯)。這是由於uptr的緣故。Release()方法返回一個指向U的指針,並被賦值爲_p,一個指向T的指針。因此若是U不是一個T的話,賦值會致使一個編譯時刻錯誤。
std::auto_ptr |
後來我意識到在STL中的auto_ptr模板,就是個人Strong Pointer。在那時候還有許多的實現差別(auto_ptr的Release方法並不將內部的指針清零--你的編譯器的庫極可能用的就是這種陳舊的實現),可是最後在標準被普遍接受以前都被解決了。
目前爲止,咱們一直在討論在C++程序中資源管理的方法。宗旨是將資源封裝到一些輕量級的類中,並由類負責它們的釋放。特別的是,全部用new操做符分配的資源都會被儲存並傳遞進Strong Pointer(標準庫中的auto_ptr)的內部。
這裏的關鍵詞是傳遞(passing)。一個容器能夠經過傳值返回一個Strong Pointer來安全的釋放資源。容器的客戶只可以經過提供一個相應的Strong Pointer來保存這個資源。任何一個將結果賦給一個"裸"指針的作法都當即會被編譯器發現。
auto_ptr<Item> item = stack.Pop (); // ok Item * p = stack.Pop (); // Error! Type mismatch. |
以傳值方式被傳遞的對象有value semantics 或者稱爲 copy semantics。Strong Pointers是以值方式傳遞的--可是咱們能說它們有copy semantics嗎?不是這樣的!它們所指向的對象確定沒有被拷貝過。事實上,傳遞事後,源auto_ptr不在訪問原有的對象,而且目標auto_ptr成爲了對象的惟一擁有者(可是每每auto_ptr的舊的實現即便在釋放後仍然保持着對對象的全部權)。天然而然的咱們能夠將這種新的行爲稱做Transfer Semantics。
拷貝構造函數(copy construcor)和賦值操做符定義了auto_ptr的Transfer Semantics,它們用了非const的auto_ptr引用做爲它們的參數。
auto_ptr (auto_ptr<T> & ptr); auto_ptr & operator = (auto_ptr<T> & ptr); |
這是由於它們確實改變了他們的源--剝奪了對資源的全部權。
經過定義相應的拷貝構造函數和重載賦值操做符,你能夠將Transfer Semantics加入到許多對象中。例如,許多Windows中的資源,好比動態創建的菜單或者位圖,能夠用有Transfer Semantics的類來封裝。
標準庫只在auto_ptr中支持資源管理。甚至連最簡單的容器也不支持ownership semantics。你可能想將auto_ptr和標準容器組合到一塊兒可能會管用,可是並非這樣的。例如,你可能會這樣作,可是會發現你不可以用標準的方法來進行索引。
vector< auto_ptr<Item> > autoVector; |
這種建造不會編譯成功;
Item * item = autoVector [0]; |
另外一方面,這會致使一個從autoVect到auto_ptr的全部權轉換:
auto_ptr<Item> item = autoVector [0]; |
咱們沒有選擇,只可以構造咱們本身的Strong Vector。最小的接口應該以下:
template <class T> class auto_vector { public: explicit auto_vector (size_t capacity = 0); T const * operator [] (size_t i) const; T * operator [] (size_t i); void assign (size_t i, auto_ptr<T> & p); void assign_direct (size_t i, T * p); void push_back (auto_ptr<T> & p); auto_ptr<T> pop_back (); }; |
你也許會發現一個很是防護性的設計態度。我決定不提供一個對vector的左值索引的訪問,取而代之,若是你想設定(set)一個值的話,你必須用assign或者assign_direct方法。個人觀點是,資源管理不該該被忽視,同時,也不該該在全部的地方濫用。在個人經驗裏,一個strong vector常常被許多push_back方法充斥着。
Strong vector最好用一個動態的Strong Pointers的數組來實現:
template <class T> class auto_vector { private void grow (size_t reqCapacity); auto_ptr<T> *_arr; size_t _capacity; size_t _end; }; |
grow方法申請了一個很大的auto_ptr<T>的數組,將全部的東西從老的書組類轉移出來,在其中交換,而且刪除原來的數組。
auto_vector的其餘實現都是十分直接的,由於全部資源管理的複雜度都在auto_ptr中。例如,assign方法簡單的利用了重載的賦值操做符來刪除原有的對象並轉移資源到新的對象:
void assign (size_t i, auto_ptr<T> & p) { _arr [i] = p; } |
我已經討論了push_back和pop_back方法。push_back方法傳值返回一個auto_ptr,由於它將全部權從auto_vector轉換到auto_ptr中。
對auto_vector的索引訪問是藉助auto_ptr的get方法來實現的,get簡單的返回一個內部指針。
T * operator [] (size_t i) { return _arr [i].get (); } |
沒有容器能夠沒有iterator。咱們須要一個iterator讓auto_vector看起來更像一個普通的指針向量。特別是,當咱們廢棄iterator的時候,咱們須要的是一個指針而不是auto_ptr。咱們不但願一個auto_vector的iterator在無心中進行資源轉換。
template<class T> class auto_iterator: public iterator<random_access_iterator_tag, T *> { public: auto_iterator () : _pp (0) {} auto_iterator (auto_ptr<T> * pp) : _pp (pp) {} bool operator != (auto_iterator<T> const & it) const { return it._pp != _pp; } auto_iterator const & operator++ (int) { return _pp++; } auto_iterator operator++ () { return ++_pp; } T * operator * () { return _pp->get (); } private auto_ptr<T> * _pp; }; |
咱們給auto_vect提供了標準的begin和end方法來找回iterator:
class auto_vector { public: typedef auto_iterator<T> iterator; iterator begin () { return _arr; } iterator end () { return _arr + _end; } }; |
你也許會問咱們是否要利用資源管理從新實現每個標準的容器?幸運的是,不;事實是strong vector解決了大部分全部權的需求。當你把你的對象都安全的放置到一個strong vector中,你能夠用全部其它的容器來從新安排(weak)pointer。
設想,例如,你須要對一些動態分配的對象排序的時候。你將它們的指針保存到一個strong vector中。而後你用一個標準的vector來保存從strong vector中得到的weak指針。你能夠用標準的算法對這個vector進行排序。這種中介vector叫作permutation vector。類似的,你也能夠用標準的maps, priority queues, heaps, hash tables等等。
若是你嚴格遵守資源管理的條款,你就不會再資源泄露或者兩次刪除的地方遇到麻煩。你也下降了訪問野指針的概率。一樣的,遵循原有的規則,用delete刪除用new申請的德指針,不要兩次刪除一個指針。你也不會遇到麻煩。可是,那個是更好的注意呢?
這兩個方法有一個很大的不一樣點。就是和尋找傳統方法的bug相比,找到違反資源管理的規定要容易的多。後者僅須要一個代碼檢測或者一個運行測試,而前者則在代碼中隱藏得很深,並須要很深的檢查。
設想你要作一段傳統的代碼的內存泄露檢查。第一件事,你要作的就是grep全部在代碼中出現的new,你須要找出被分配空間地指針都做了什麼。你須要肯定致使刪除這個指針的全部的執行路徑。你須要檢查break語句,過程返回,異常。原有的指針可能賦給另外一個指針,你對這個指針也要作相同的事。
相比之下,對於一段用資源管理技術實現的代碼。你也用grep檢查全部的new,可是此次你只須要檢查鄰近的調用:
● 這是一個直接的Strong Pointer轉換,仍是咱們在一個構造函數的函數體中?
● 調用的返回知是否當即保存到對象中,構造函數中是否有能夠產生異常的代碼。?
● 若是這樣的話析構函數中時候有delete?
下一步,你須要用grep查找全部的release方法,並實施相同的檢查。
不一樣點是須要檢查、理解單個執行路徑和只須要作一些本地的檢驗。這難道不是提醒你非結構化的和結構化的程序設計的不一樣嗎?原理上,你能夠認爲你能夠應付goto,而且跟蹤全部的可能分支。另外一方面,你能夠將你的懷疑本地化爲一段代碼。本地化在兩種狀況下都是關鍵所在。
在資源管理中的錯誤模式也比較容易調試。最多見的bug是試圖訪問一個釋放過的strong pointer。這將致使一個錯誤,而且很容易跟蹤。
爲每個程序中的資源都找出或者指定一個全部者是一件很容易的事情嗎?答案是出乎意料的,是!若是你發現了一些問題,這可能說明你的設計上存在問題。還有另外一種狀況就是共享全部權是最好的甚至是惟一的選擇。
共享的責任分配給被共享的對象和它的客戶(client)。一個共享資源必須爲它的全部者保持一個引用計數。另外一方面,全部者再釋放資源的時候必須通報共享對象。最後一個釋放資源的須要在最後負責free的工做。
最簡單的共享的實現是共享對象繼承引用計數的類RefCounted:
class RefCounted { public: RefCounted () : _count (1) {} int GetRefCount () const { return _count; } void IncRefCount () { _count++; } int DecRefCount () { return --_count; } private int _count; }; |
按照資源管理,一個引用計數是一種資源。若是你遵照它,你須要釋放它。當你意識到這一事實的時候,剩下的就變得簡單了。簡單的遵循規則--再構造函數中得到引用計數,在析構函數中釋放。甚至有一個RefCounted的smart pointer等價物:
template <class T> class RefPtr { public: RefPtr (T * p) : _p (p) {} RefPtr (RefPtr<T> & p) { _p = p._p; _p->IncRefCount (); } ~RefPtr () { if (_p->DecRefCount () == 0) delete _p; } private T * _p; }; |
注意模板中的T不比成爲RefCounted的後代,可是它必須有IncRefCount和DecRefCount的方法。固然,一個便於使用的RefPtr須要有一個重載的指針訪問操做符。在RefPtr中加入轉換語義學(transfer semantics)是讀者的工做。
鏈表是資源管理分析中的一個頗有意思的例子。若是你選擇表成爲鏈(link)的全部者的話,你會陷入實現遞歸的全部權。每個link都是它的繼承者的全部者,而且,相應的,餘下的鏈表的全部者。下面是用smart pointer實現的一個表單元:
class Link { // ... private auto_ptr<Link> _next; }; 最好的方法是,將鏈接控制封裝到一個弄構進行資源轉換的類中。 對於雙鏈表呢?安全的作法是指明一個方向,如forward: class DoubleLink { // ... private DoubleLink *_prev; auto_ptr<DoubleLink> _next; }; |
注意不要建立環形鏈表。
這給咱們帶來了另一個有趣的問題--資源管理能夠處理環形的全部權嗎?它能夠,用一個mark-and-sweep的算法。這裏是實現這種方法的一個例子:
template<class T> class CyclPtr { public: CyclPtr (T * p) :_p (p), _isBeingDeleted (false) {} ~CyclPtr () { _isBeingDeleted = true; if (!_p->IsBeingDeleted ()) delete _p; } void Set (T * p) { _p = p; } bool IsBeingDeleted () const { return _isBeingDeleted; } private T * _p; bool _isBeingDeleted; }; |
注意咱們須要用class T來實現方法IsBeingDeleted,就像從CyclPtr繼承。對特殊的全部權網絡普通化是十分直接的。
將原有代碼轉換爲資源管理代碼
若是你是一個經驗豐富的程序員,你必定會知道找資源的bug是一件浪費時間的痛苦的經歷。我沒必要說服你和你的團隊花費一點時間來熟悉資源管理是十分值得的。你能夠當即開始用這個方法,不管你是在開始一個新項目或者是在一個項目的中期。轉換沒必要當即所有完成。下面是步驟。
(1) 首先,在你的工程中創建基本的Strong Pointer。而後經過查找代碼中的new來開始封裝裸指針。
(2) 最早封裝的是在過程當中定義的臨時指針。簡單的將它們替換爲auto_ptr而且刪除相應的delete。若是一個指針在過程當中沒有被刪除而是被返回,用auto_ptr替換並在返回前調用release方法。在你作第二次傳遞的時候,你須要處理對release的調用。注意,即便是在這點,你的代碼也可能更加"精力充沛"--你會移出代碼中潛在的資源泄漏問題。
(3) 下面是指向資源的裸指針。確保它們被獨立的封裝到auto_ptr中,或者在構造函數中分配在析構函數中釋放。若是你有傳遞全部權的行爲的話,須要調用release方法。若是你有容器全部對象,用Strong Pointers從新實現它們。
(4) 接下來,找到全部對release的方法調用而且盡力清除全部,若是一個release調用返回一個指針,將它修改傳值返回一個auto_ptr。
(5) 重複着一過程,直到最後全部new和release的調用都在構造函數或者資源轉換的時候發生。這樣,你在你的代碼中處理了資源泄漏的問題。對其餘資源進行類似的操做。
(6) 你會發現資源管理清除了許多錯誤和異常處理帶來的複雜性。不只僅你的代碼會變得精力充沛,它也會變得簡單並容易維護。
假設咱們要開發一個String類,它能夠方便地處理字符串數據。咱們能夠在類中聲明一個數組,考慮到有時候字符串極長,咱們能夠把數組大小設爲200,但通常的狀況下又不須要這麼多的空間,這樣是浪費了內存。對了,咱們能夠使用new操做符,這樣是十分靈活的,但在類中就會出現許多意想不到的問題,本文就是針對這一現象而寫的。如今,咱們先來開發一個String類,但它是一個不完善的類。的確,咱們要刻意地使它出現各類各樣的問題,這樣纔好對症下藥。好了,咱們開始吧!
/* String.h */ #ifndef STRING_H_ #define STRING_H_ class String { private: char * str; //存儲數據 int len; //字符串長度 public: String(const char * s); //構造函數 String(); // 默認構造函數 ~String(); // 析構函數 friend ostream & operator<<(ostream & os,const String& st); }; #endif /*String.cpp*/ #include <iostream> #include <cstring> #include "String.h" using namespace std; String::String(const char * s) { len = strlen(s); str = new char[len + 1]; strcpy(str, s); }//拷貝數據 String::String() { len =0; str = new char[len+1]; str[0]='"0'; } String::~String() { cout<<"這個字符串將被刪除:"<<str<<'"n';//爲了方便觀察結果,特留此行代碼。 delete [] str; } ostream & operator<<(ostream & os, const String & st) { os << st.str; return os; } /*test_right.cpp*/ #include <iostream> #include <stdlib.h> #include "String.h" using namespace std; int main() { String temp("天極網"); cout<<temp<<'"n'; system("PAUSE"); return 0; } |
運行結果:
天極網 請按任意鍵繼續. . . |
你們能夠看到,以上程序十分正確,並且也是十分有用的。但是,咱們不能被表面現象所迷惑!下面,請你們用test_String.cpp文件替換test_right.cpp文件進行編譯,看看結果。有的編譯器可能就是根本不能進行編譯!
test_String.cpp:
#include <iostream> #include <stdlib.h> #include "String.h" using namespace std; void show_right(const String&); void show_String(const String);//注意,參數非引用,而是按值傳遞。 int main() { String test1("第一個範例。"); String test2("第二個範例。"); String test3("第三個範例。"); String test4("第四個範例。"); cout<<"下面分別輸入三個範例:"n"; cout<<test1<<endl; cout<<test2<<endl; cout<<test3<<endl; String* String1=new String(test1); cout<<*String1<<endl; delete String1; cout<<test1<<endl; //在Dev-cpp上沒有任何反應。 cout<<"使用正確的函數:"<<endl; show_right(test2); cout<<test2<<endl; cout<<"使用錯誤的函數:"<<endl; show_String(test2); cout<<test2<<endl; //這一段代碼出現嚴重的錯誤! String String2(test3); cout<<"String2: "<<String2<<endl; String String3; String3=test4; cout<<"String3: "<<String3<<endl; cout<<"下面,程序結束,析構函數將被調用。"<<endl; return 0; } void show_right(const String& a) { cout<<a<<endl; } void show_String(const String a) { cout<<a<<endl; } |
運行結果:
下面分別輸入三個範例: 第一個範例。 第二個範例。 第三個範例。 第一個範例。 這個字符串將被刪除:第一個範例。 使用正確的函數:
第二個範例。 第二個範例。 使用錯誤的函數: 第二個範例。 這個字符串將被刪除:第二個範例。 這個字符串將被刪除:?= ?= String2: 第三個範例。 String3: 第四個範例。 下面,程序結束,析構函數將被調用。 這個字符串將被刪除:第四個範例。 這個字符串將被刪除:第三個範例。 這個字符串將被刪除:?= 這個字符串將被刪除:x = 這個字符串將被刪除:?= 這個字符串將被刪除: |
如今,請你們本身試試運行結果,或許會更加慘不忍睹呢!下面,我爲你們一一分析緣由。
首先,你們要知道,C++類有如下這些極爲重要的函數:
一:複製構造函數。
二:賦值函數。
咱們先來說複製構造函數。什麼是複製構造函數呢?好比,咱們能夠寫下這樣的代碼:String test1(test2);這是進行初始化。咱們知道,初始化對象要用構造函數。可這兒呢?按理說,應該有聲明爲這樣的構造函數:String(const String &);但是,咱們並無定義這個構造函數呀?答案是,C++提供了默認的複製構造函數,問題也就出在這兒。
(1):何時會調用複製構造函數呢?(以String類爲例。)
在咱們提供這樣的代碼:String test1(test2)時,它會被調用;當函數的參數列表爲按值傳遞,也就是沒有用引用和指針做爲類型時,如:void show_String(const String),它會被調用。其實,還有一些狀況,但在這兒就不列舉了。
(2):它是什麼樣的函數。
它的做用就是把兩個類進行復制。拿String類爲例,C++提供的默認複製構造函數是這樣的:
String(const String& a) { str=a.str; len=a.len; } |
在平時,這樣並不會有任何的問題出現,但咱們用了new操做符,涉及到了動態內存分配,咱們就不得不談談淺複製和深複製了。以上的函數就是實行的淺複製,它只是複製了指針,而並無複製指針指向的數據,可謂一點兒用也沒有。打個比方吧!就像一個朋友讓你把一個程序經過網絡發給他,而你大大咧咧地把快捷方式發給了他,有什麼用處呢?咱們來具體談談:
假如,A對象中存儲了這樣的字符串:「C++」。它的地址爲2000。如今,咱們把A對象賦給B對象:String B=A。如今,A和B對象的str指針均指向2000地址。看似能夠使用,但若是B對象的析構函數被調用時,則地址2000處的字符串「C++」已經被從內存中抹去,而A對象仍然指向地址2000。這時,若是咱們寫下這樣的代碼:cout<<A<<endl;或是等待程序結束,A對象的析構函數被調用時,A對象的數據可否顯示出來呢?只會是亂碼。並且,程序還會這樣作:連續對地址2000處使用兩次delete操做符,這樣的後果是十分嚴重的!
本例中,有這樣的代碼:
String* String1=new String(test1); cout<<*String1<<endl; delete String1; |
假設test1中str指向的地址爲2000,而String中str指針一樣指向地址2000,咱們刪除了2000處的數據,而test1對象呢?已經被破壞了。你們從運行結果上能夠看到,咱們使用cout<<test1時,一點反應也沒有。而在test1的析構函數被調用時,顯示是這樣:「這個字符串將被刪除:」。
再看看這段代碼:
cout<<"使用錯誤的函數:"<<endl; show_String(test2); cout<<test2<<endl;//這一段代碼出現嚴重的錯誤! |
show_String函數的參數列表void show_String(const String a)是按值傳遞的,因此,咱們至關於執行了這樣的代碼:String a=test2;函數執行完畢,因爲生存週期的緣故,對象a被析構函數刪除,咱們立刻就能夠看到錯誤的顯示結果了:這個字符串將被刪除:?=。固然,test2也被破壞了。解決的辦法很簡單,固然是手工定義一個複製構造函數嘍!人力能夠勝天!
String::String(const String& a) |
咱們執行的是深複製。這個函數的功能是這樣的:假設對象A中的str指針指向地址2000,內容爲「I am a C++ Boy!」。咱們執行代碼String B=A時,咱們先開闢出一塊內存,假設爲3000。咱們用strcpy函數將地址2000的內容拷貝到地址3000中,再將對象B的str指針指向地址3000。這樣,就互不干擾了。
你們把這個函數加入程序中,問題就解決了大半,但尚未徹底解決,問題在賦值函數上。咱們的程序中有這樣的段代碼:
String String3; String3=test4; |
通過我前面的講解,你們應該也會對這段代碼進行尋根摸底:憑什麼能夠這樣作:String3=test4???緣由是,C++爲了用戶的方便,提供的這樣的一個操做符重載函數:operator=。因此,咱們能夠這樣作。你們應該猜獲得,它一樣是執行了淺複製,出了一樣的毛病。好比,執行了這段代碼後,析構函數開始大展神威^_^。因爲這些變量是後進先出的,因此最後的String3變量先被刪除:這個字符串將被刪除:第四個範例。很正常。最後,刪除到test4的時候,問題來了:這個字符串將被刪除:?=。緣由我不用贅述了,只是這個賦值函數怎麼寫,還有一點兒學問呢!你們請看:
平時,咱們能夠寫這樣的代碼:x=y=z。(均爲整型變量。)而在類對象中,咱們一樣要這樣,由於這很方便。而對象A=B=C就是A.operator=(B.operator=(c))。而這個operator=函數的參數列表應該是:const String& a,因此,你們不難推出,要實現這樣的功能,返回值也要是String&,這樣才能實現A=B=C。咱們先來寫寫看:
String& String::operator=(const String& a) { delete [] str;//先刪除自身的數據 len=a.len; str=new char[len+1]; strcpy(str,a.str);//此三行爲進行拷貝 return *this;//返回自身的引用 } |
是否是這樣就好了呢?咱們假如寫出了這種代碼:A=A,那麼你們看看,豈不是把A對象的數據給刪除了嗎?這樣可謂引起一系列的錯誤。因此,咱們還要檢查是否爲自身賦值。只比較兩對象的數據是不行了,由於兩個對象的數據頗有可能相同。咱們應該比較地址。如下是無缺的賦值函數:
String& String::operator=(const String& a) { if(this==&a) return *this; delete [] str; len=a.len; str=new char[len+1]; strcpy(str,a.str); return *this; } |
把這些代碼加入程序,問題就徹底解決,下面是運行結果:
下面分別輸入三個範例: 第一個範例 第二個範例 第三個範例 第一個範例 這個字符串將被刪除:第一個範例。 第一個範例 使用正確的函數: 第二個範例。 第二個範例。 使用錯誤的函數: 第二個範例。 這個字符串將被刪除:第二個範例。 第二個範例。 String2: 第三個範例。 String3: 第四個範例。 下面,程序結束,析構函數將被調用。 這個字符串將被刪除:第四個範例。 這個字符串將被刪除:第三個範例。 這個字符串將被刪除:第四個範例。 這個字符串將被刪除:第三個範例。 這個字符串將被刪除:第二個範例。 這個字符串將被刪除:第一個範例。 |
寫出那些不會致使任何內存泄漏的代碼。很明顯,當你的代碼中處處充滿了new 操做、delete操做和指針運算的話,你將會在某個地方搞暈了頭,致使內存泄漏,指針引用錯誤,以及諸如此類的問題。這和你如何當心地對待內存分配工做其實徹底沒有關係:代碼的複雜性最終老是會超過你可以付出的時間和努力。因而隨後產生了一些成功的技巧,它們依賴於將內存分配(allocations)與從新分配(deallocation)工做隱藏在易於管理的類型以後。標準容器(standard containers)是一個優秀的例子。它們不是經過你而是本身爲元素管理內存,從而避免了產生糟糕的結果。想象一下,沒有string和vector的幫助,寫出這個:
#include<vector> #include<string> #include<iostream> #include<algorithm> using namespace std; int main() // small program messing around with strings { cout << "enter some whitespace-separated words:"n"; vector<string> v; string s; while (cin>>s) v.push_back(s); sort(v.begin(),v.end()); string cat; typedef vector<string>::const_iterator Iter; for (Iter p = v.begin(); p!=v.end(); ++p) cat += *p+"+"; cout << cat << ’"n’; } |
你有多少機會在第一次就獲得正確的結果?你又怎麼知道你沒有致使內存泄漏呢?
注意,沒有出現顯式的內存管理,宏,造型,溢出檢查,顯式的長度限制,以及指針。經過使用函數對象和標準算法(standard algorithm),我能夠避免使用指針——例如使用迭代子(iterator),不過對於一個這麼小的程序來講有點小題大做了。
這些技巧並不完美,要系統化地使用它們也並不老是那麼容易。可是,應用它們產生了驚人的差別,並且經過減小顯式的內存分配與從新分配的次數,你甚至能夠使餘下的例子更加容易被跟蹤。早在1981年,我就指出,經過將我必須顯式地跟蹤的對象的數量從幾萬個減小到幾打,爲了使程序正確運行而付出的努力從可怕的苦工,變成了應付一些可管理的對象,甚至更加簡單了。
若是你的程序尚未包含將顯式內存管理減小到最小限度的庫,那麼要讓你程序完成和正確運行的話,最快的途徑也許就是先創建一個這樣的庫。
模板和標準庫實現了容器、資源句柄以及諸如此類的東西,更早的使用甚至在多年之前。異常的使用使之更加完善。
若是你實在不能將內存分配/從新分配的操做隱藏到你須要的對象中時,你能夠使用資源句柄(resource handle),以將內存泄漏的可能性降至最低。這裏有個例子:我須要經過一個函數,在空閒內存中創建一個對象並返回它。這時候可能忘記釋放這個對象。畢竟,咱們不能說,僅僅關注當這個指針要被釋放的時候,誰將負責去作。使用資源句柄,這裏用了標準庫中的auto_ptr,使須要爲之負責的地方變得明確了。
#include<memory> #include<iostream> using namespace std; struct S { S() { cout << "make an S"n"; } ~S() { cout << "destroy an S"n"; } S(const S&) { cout << "copy initialize an S"n"; } S& operator=(const S&) { cout << "copy assign an S"n"; } }; S* f() { return new S; // 誰該負責釋放這個S? }; auto_ptr<S> g() { return auto_ptr<S>(new S); // 顯式傳遞負責釋放這個S } int main() { cout << "start main"n"; S* p = f(); cout << "after f() before g()"n"; // S* q = g(); // 將被編譯器捕捉 auto_ptr<S> q = g(); cout << "exit main"n"; // *p產生了內存泄漏 // *q被自動釋放 } |
在更通常的意義上考慮資源,而不只僅是內存。
若是在你的環境中不能系統地應用這些技巧(例如,你必須使用別的地方的代碼,或者你的程序的另外一部分簡直是原始人類(譯註:原文是Neanderthals,尼安德特人,舊石器時代普遍分佈在歐洲的猿人)寫的,如此等等),那麼注意使用一個內存泄漏檢測器做爲開發過程的一部分,或者插入一個垃圾收集器(garbage collector)。
對於一個c/c++程序員來講,內存泄漏是一個常見的也是使人頭疼的問題。已經有許多技術被研究出來以應對這個問題,好比Smart Pointer,Garbage Collection等。Smart Pointer技術比較成熟,STL中已經包含支持Smart Pointer的class,可是它的使用彷佛並不普遍,並且它也不能解決全部的問題;Garbage Collection技術在Java中已經比較成熟,可是在c/c++領域的發展並不暢,雖然很早就有人思考在C++中也加入GC的支持。現實世界就是這樣的,做爲一個c/c++程序員,內存泄漏是你心中永遠的痛。不過好在如今有許多工具可以幫助咱們驗證內存泄漏的存在,找出發生問題的代碼。
通常咱們常說的內存泄漏是指堆內存的泄漏。堆內存是指程序從堆中分配的,大小任意的(內存塊的大小能夠在程序運行期決定),使用完後必須顯示釋放的內存。應用程序通常使用malloc,realloc,new等函數從堆中分配到一塊內存,使用完後,程序必須負責相應的調用free或delete釋放該內存塊,不然,這塊內存就不能被再次使用,咱們就說這塊內存泄漏了。如下這段小程序演示了堆內存發生泄漏的情形:
void MyFunction(int nSize) { char* p= new char[nSize]; if( !GetStringFrom( p, nSize ) ){ MessageBox(「Error」); return; } …//using the string pointed by p; delete p; } |
當函數GetStringFrom()返回零的時候,指針p指向的內存就不會被釋放。這是一種常見的發生內存泄漏的情形。程序在入口處分配內存,在出口處釋放內存,可是c函數能夠在任何地方退出,因此一旦有某個出口處沒有釋放應該釋放的內存,就會發生內存泄漏。
廣義的說,內存泄漏不只僅包含堆內存的泄漏,還包含系統資源的泄漏(resource leak),好比核心態HANDLE,GDI Object,SOCKET, Interface等,從根本上說這些由操做系統分配的對象也消耗內存,若是這些對象發生泄漏最終也會致使內存的泄漏。並且,某些對象消耗的是核心態內存,這些對象嚴重泄漏時會致使整個操做系統不穩定。因此相比之下,系統資源的泄漏比堆內存的泄漏更爲嚴重。
GDI Object的泄漏是一種常見的資源泄漏:
void CMyView::OnPaint( CDC* pDC ) { CBitmap bmp; CBitmap* pOldBmp; bmp.LoadBitmap(IDB_MYBMP); pOldBmp = pDC->SelectObject( &bmp ); … if( Something() ){ return; } pDC->SelectObject( pOldBmp ); return; } |
當函數Something()返回非零的時候,程序在退出前沒有把pOldBmp選回pDC中,這會致使pOldBmp指向的HBITMAP對象發生泄漏。這個程序若是長時間的運行,可能會致使整個系統花屏。這種問題在Win9x下比較容易暴露出來,由於Win9x的GDI堆比Win2k或NT的要小不少。
以發生的方式來分類,內存泄漏能夠分爲4類:
1. 常發性內存泄漏。發生內存泄漏的代碼會被屢次執行到,每次被執行的時候都會致使一塊內存泄漏。好比例二,若是Something()函數一直返回True,那麼pOldBmp指向的HBITMAP對象老是發生泄漏。
2. 偶發性內存泄漏。發生內存泄漏的代碼只有在某些特定環境或操做過程下才會發生。好比例二,若是Something()函數只有在特定環境下才返回True,那麼pOldBmp指向的HBITMAP對象並不老是發生泄漏。常發性和偶發性是相對的。對於特定的環境,偶發性的也許就變成了常發性的。因此測試環境和測試方法對檢測內存泄漏相當重要。
3. 一次性內存泄漏。發生內存泄漏的代碼只會被執行一次,或者因爲算法上的缺陷,致使總會有一塊僅且一塊內存發生泄漏。好比,在類的構造函數中分配內存,在析構函數中卻沒有釋放該內存,可是由於這個類是一個Singleton,因此內存泄漏只會發生一次。另外一個例子:
char* g_lpszFileName = NULL; void SetFileName( const char* lpcszFileName ) { if( g_lpszFileName ){ free( g_lpszFileName ); } g_lpszFileName = strdup( lpcszFileName ); } |
若是程序在結束的時候沒有釋放g_lpszFileName指向的字符串,那麼,即便屢次調用SetFileName(),總會有一塊內存,並且僅有一塊內存發生泄漏。
4. 隱式內存泄漏。程序在運行過程當中不停的分配內存,可是直到結束的時候才釋放內存。嚴格的說這裏並無發生內存泄漏,由於最終程序釋放了全部申請的內存。可是對於一個服務器程序,須要運行幾天,幾周甚至幾個月,不及時釋放內存也可能致使最終耗盡系統的全部內存。因此,咱們稱這類內存泄漏爲隱式內存泄漏。舉一個例子:
class Connection { public: Connection( SOCKET s); ~Connection(); … private: SOCKET _socket; … }; class ConnectionManager { public: ConnectionManager(){} ~ConnectionManager(){ list::iterator it; for( it = _connlist.begin(); it != _connlist.end(); ++it ){ delete (*it); } _connlist.clear(); } void OnClientConnected( SOCKET s ){ Connection* p = new Connection(s); _connlist.push_back(p); } void OnClientDisconnected( Connection* pconn ){ _connlist.remove( pconn ); delete pconn; } private: list _connlist; }; |
假設在Client從Server端斷開後,Server並無呼叫OnClientDisconnected()函數,那麼表明那次鏈接的Connection對象就不會被及時的刪除(在Server程序退出的時候,全部Connection對象會在ConnectionManager的析構函數裏被刪除)。當不斷的有鏈接創建、斷開時隱式內存泄漏就發生了。
從用戶使用程序的角度來看,內存泄漏自己不會產生什麼危害,做爲通常的用戶,根本感受不到內存泄漏的存在。真正有危害的是內存泄漏的堆積,這會最終消耗盡系統全部的內存。從這個角度來講,一次性內存泄漏並無什麼危害,由於它不會堆積,而隱式內存泄漏危害性則很是大,由於較之於常發性和偶發性內存泄漏它更難被檢測到。
檢測內存泄漏的關鍵是要能截獲住對分配內存和釋放內存的函數的調用。截獲住這兩個函數,咱們就能跟蹤每一塊內存的生命週期,好比,每當成功的分配一塊內存後,就把它的指針加入一個全局的list中;每當釋放一塊內存,再把它的指針從list中刪除。這樣,當程序結束的時候,list中剩餘的指針就是指向那些沒有被釋放的內存。這裏只是簡單的描述了檢測內存泄漏的基本原理,詳細的算法能夠參見Steve Maguire的<<Writing Solid Code>>。
若是要檢測堆內存的泄漏,那麼須要截獲住malloc/realloc/free和new/delete就能夠了(其實new/delete最終也是用malloc/free的,因此只要截獲前面一組便可)。對於其餘的泄漏,能夠採用相似的方法,截獲住相應的分配和釋放函數。好比,要檢測BSTR的泄漏,就須要截獲SysAllocString/SysFreeString;要檢測HMENU的泄漏,就須要截獲CreateMenu/ DestroyMenu。(有的資源的分配函數有多個,釋放函數只有一個,好比,SysAllocStringLen也能夠用來分配BSTR,這時就須要截獲多個分配函數)
在Windows平臺下,檢測內存泄漏的工具經常使用的通常有三種,MS C-Runtime Library內建的檢測功能;外掛式的檢測工具,諸如,Purify,BoundsChecker等;利用Windows NT自帶的Performance Monitor。這三種工具各有優缺點,MS C-Runtime Library雖然功能上較以外掛式的工具要弱,可是它是免費的;Performance Monitor雖然沒法標示出發生問題的代碼,可是它能檢測出隱式的內存泄漏的存在,這是其餘兩類工具無能爲力的地方。
如下咱們詳細討論這三種檢測工具:
用MFC開發的應用程序,在DEBUG版模式下編譯後,都會自動加入內存泄漏的檢測代碼。在程序結束後,若是發生了內存泄漏,在Debug窗口中會顯示出全部發生泄漏的內存塊的信息,如下兩行顯示了一塊被泄漏的內存塊的信息:
E:"TestMemLeak"TestDlg.cpp(70) : {59} normal block at 0x00881710, 200 bytes long.
Data: <abcdefghijklmnop> 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F 70
第一行顯示該內存塊由TestDlg.cpp文件,第70行代碼分配,地址在0x00881710,大小爲200字節,{59}是指調用內存分配函數的Request Order,關於它的詳細信息能夠參見MSDN中_CrtSetBreakAlloc()的幫助。第二行顯示該內存塊前16個字節的內容,尖括號內是以ASCII方式顯示,接着的是以16進制方式顯示。
通常你們都誤覺得這些內存泄漏的檢測功能是由MFC提供的,其實否則。MFC只是封裝和利用了MS C-Runtime Library的Debug Function。非MFC程序也能夠利用MS C-Runtime Library的Debug Function加入內存泄漏的檢測功能。MS C-Runtime Library在實現malloc/free,strdup等函數時已經內建了內存泄漏的檢測功能。
注意觀察一下由MFC Application Wizard生成的項目,在每個cpp文件的頭部都有這樣一段宏定義:
#ifdef _DEBUG #define new DEBUG_NEW #undef THIS_FILE static char THIS_FILE[] = __FILE__; #endif |
有了這樣的定義,在編譯DEBUG版時,出如今這個cpp文件中的全部new都被替換成DEBUG_NEW了。那麼DEBUG_NEW是什麼呢?DEBUG_NEW也是一個宏,如下摘自afx.h,1632行
#define DEBUG_NEW new(THIS_FILE, __LINE__) |
因此若是有這樣一行代碼:
char* p = new char[200]; |
通過宏替換就變成了:
char* p = new( THIS_FILE, __LINE__)char[200]; |
根據C++的標準,對於以上的new的使用方法,編譯器會去找這樣定義的operator new:
void* operator new(size_t, LPCSTR, int) |
咱們在afxmem.cpp 63行找到了一個這樣的operator new 的實現
void* AFX_CDECL operator new(size_t nSize, LPCSTR lpszFileName, int nLine) { return ::operator new(nSize, _NORMAL_BLOCK, lpszFileName, nLine); } void* __cdecl operator new(size_t nSize, int nType, LPCSTR lpszFileName, int nLine) { … pResult = _malloc_dbg(nSize, nType, lpszFileName, nLine); if (pResult != NULL) return pResult; … } |
第二個operator new函數比較長,爲了簡單期間,我只摘錄了部分。很顯然最後的內存分配仍是經過_malloc_dbg函數實現的,這個函數屬於MS C-Runtime Library 的Debug Function。這個函數不但要求傳入內存的大小,另外還有文件名和行號兩個參數。文件名和行號就是用來記錄這次分配是由哪一段代碼形成的。若是這塊內存在程序結束以前沒有被釋放,那麼這些信息就會輸出到Debug窗口裏。
這裏順便提一下THIS_FILE,__FILE和__LINE__。__FILE__和__LINE__都是編譯器定義的宏。當碰到__FILE__時,編譯器會把__FILE__替換成一個字符串,這個字符串就是當前在編譯的文件的路徑名。當碰到__LINE__時,編譯器會把__LINE__替換成一個數字,這個數字就是當前這行代碼的行號。在DEBUG_NEW的定義中沒有直接使用__FILE__,而是用了THIS_FILE,其目的是爲了減少目標文件的大小。假設在某個cpp文件中有100處使用了new,若是直接使用__FILE__,那編譯器會產生100個常量字符串,這100個字符串都是飧?/SPAN>cpp文件的路徑名,顯然十分冗餘。若是使用THIS_FILE,編譯器只會產生一個常量字符串,那100處new的調用使用的都是指向常量字符串的指針。
再次觀察一下由MFC Application Wizard生成的項目,咱們會發如今cpp文件中只對new作了映射,若是你在程序中直接使用malloc函數分配內存,調用malloc的文件名和行號是不會被記錄下來的。若是這塊內存發生了泄漏,MS C-Runtime Library仍然能檢測到,可是當輸出這塊內存塊的信息,不會包含分配它的的文件名和行號。
要在非MFC程序中打開內存泄漏的檢測功能很是容易,你只要在程序的入口處加入如下幾行代碼:
int tmpFlag = _CrtSetDbgFlag( _CRTDBG_REPORT_FLAG ); tmpFlag |= _CRTDBG_LEAK_CHECK_DF; _CrtSetDbgFlag( tmpFlag ); |
這樣,在程序結束的時候,也就是winmain,main或dllmain函數返回以後,若是還有內存塊沒有釋放,它們的信息會被打印到Debug窗口裏。
若是你試着建立了一個非MFC應用程序,並且在程序的入口處加入了以上代碼,而且故意在程序中不釋放某些內存塊,你會在Debug窗口裏看到如下的信息:
{47} normal block at 0x00C91C90, 200 bytes long. Data: < > 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F |
內存泄漏的確檢測到了,可是和上面MFC程序的例子相比,缺乏了文件名和行號。對於一個比較大的程序,沒有這些信息,解決問題將變得十分困難。
爲了可以知道泄漏的內存塊是在哪裏分配的,你須要實現相似MFC的映射功能,把new,maolloc等函數映射到_malloc_dbg函數上。這裏我再也不贅述,你能夠參考MFC的源代碼。
因爲Debug Function實如今MS C-RuntimeLibrary中,因此它只能檢測到堆內存的泄漏,並且只限於malloc,realloc或strdup等分配的內存,而那些系統資源,好比HANDLE,GDI Object,或是不經過C-Runtime Library分配的內存,好比VARIANT,BSTR的泄漏,它是沒法檢測到的,這是這種檢測法的一個重大的侷限性。另外,爲了能記錄內存塊是在哪裏分配的,源代碼必須相應的配合,這在調試一些老的程序很是麻煩,畢竟修改源代碼不是一件省心的事,這是這種檢測法的另外一個侷限性。
對於開發一個大型的程序,MS C-Runtime Library提供的檢測功能是遠遠不夠的。接下來咱們就看看外掛式的檢測工具。我用的比較多的是BoundsChecker,一則由於它的功能比較全面,更重要的是它的穩定性。這類工具若是不穩定,反而會忙裏添亂。究竟是出自鼎鼎大名的NuMega,我用下來基本上沒有什麼大問題。
BoundsChecker採用一種被稱爲 Code Injection的技術,來截獲對分配內存和釋放內存的函數的調用。簡單地說,當你的程序開始運行時,BoundsChecker的DLL被自動載入進程的地址空間(這能夠經過system-level的Hook實現),而後它會修改進程中對內存分配和釋放的函數調用,讓這些調用首先轉入它的代碼,而後再執行原來的代碼。BoundsChecker在作這些動做的時,無須修改被調試程序的源代碼或工程配置文件,這使得使用它很是的簡便、直接。
這裏咱們以malloc函數爲例,截獲其餘的函數方法與此相似。
須要被截獲的函數可能在DLL中,也可能在程序的代碼裏。好比,若是靜態連結C-Runtime Library,那麼malloc函數的代碼會被連結到程序裏。爲了截獲住對這類函數的調用,BoundsChecker會動態修改這些函數的指令。
如下兩段彙編代碼,一段沒有BoundsChecker介入,另外一段則有BoundsChecker的介入:
126: _CRTIMP void * __cdecl malloc ( 127: size_t nSize 128: ) 129: { 00403C10 push ebp 00403C11 mov ebp,esp 130: return _nh_malloc_dbg(nSize, _newmode, _NORMAL_BLOCK, NULL, 0); 00403C13 push 0 00403C15 push 0 00403C17 push 1 00403C19 mov eax,[__newmode (0042376c)] 00403C1E push eax 00403C1F mov ecx,dword ptr [nSize] 00403C22 push ecx 00403C23 call _nh_malloc_dbg (00403c80) 00403C28 add esp,14h 131: } |
如下這一段代碼有BoundsChecker介入:
126: _CRTIMP void * __cdecl malloc ( 127: size_t nSize 128: ) 129: { 00403C10 jmp 01F41EC8 00403C15 push 0 00403C17 push 1 00403C19 mov eax,[__newmode (0042376c)] 00403C1E push eax 00403C1F mov ecx,dword ptr [nSize] 00403C22 push ecx 00403C23 call _nh_malloc_dbg (00403c80) 00403C28 add esp,14h 131: } |
當BoundsChecker介入後,函數malloc的前三條彙編指令被替換成一條jmp指令,原來的三條指令被搬到地址01F41EC8處了。當程序進入malloc後先jmp到01F41EC8,執行原來的三條指令,而後就是BoundsChecker的天下了。大體上它會先記錄函數的返回地址(函數的返回地址在stack上,因此很容易修改),而後把返回地址指向屬於BoundsChecker的代碼,接着跳到malloc函數原來的指令,也就是在00403c15的地方。當malloc函數結束的時候,因爲返回地址被修改,它會返回到BoundsChecker的代碼中,此時BoundsChecker會記錄由malloc分配的內存的指針,而後再跳轉到到原來的返回地址去。
若是內存分配/釋放函數在DLL中,BoundsChecker則採用另外一種方法來截獲對這些函數的調用。BoundsChecker經過修改程序的DLL Import Table讓table中的函數地址指向本身的地址,以達到截獲的目的。
截獲住這些分配和釋放函數,BoundsChecker就能記錄被分配的內存或資源的生命週期。接下來的問題是如何與源代碼相關,也就是說當BoundsChecker檢測到內存泄漏,它如何報告這塊內存塊是哪段代碼分配的。答案是調試信息(Debug Information)。當咱們編譯一個Debug版的程序時,編譯器會把源代碼和二進制代碼之間的對應關係記錄下來,放到一個單獨的文件裏(.pdb)或者直接連結進目標程序,經過直接讀取調試信息就能獲得分配某塊內存的源代碼在哪一個文件,哪一行上。使用Code Injection和Debug Information,使BoundsChecker不但能記錄呼叫分配函數的源代碼的位置,並且還能記錄分配時的Call Stack,以及Call Stack上的函數的源代碼位置。這在使用像MFC這樣的類庫時很是有用,如下我用一個例子來講明:
void ShowXItemMenu() { … CMenu menu; menu.CreatePopupMenu(); //add menu items. menu.TrackPropupMenu(); … } void ShowYItemMenu( ) { … CMenu menu; menu.CreatePopupMenu(); //add menu items. menu.TrackPropupMenu(); menu.Detach();//this will cause HMENU leak … } BOOL CMenu::CreatePopupMenu() { … hMenu = CreatePopupMenu(); … } |
當調用ShowYItemMenu()時,咱們故意形成HMENU的泄漏。可是,對於BoundsChecker來講被泄漏的HMENU是在class CMenu::CreatePopupMenu()中分配的。假設的你的程序有許多地方使用了CMenu的CreatePopupMenu()函數,如CMenu::CreatePopupMenu()形成的,你依然沒法確認問題的根結到底在哪裏,在ShowXItemMenu()中仍是在ShowYItemMenu()中,或者還有其它的地方也使用了CreatePopupMenu()?有了Call Stack的信息,問題就容易了。BoundsChecker會以下報告泄漏的HMENU的信息:
Function File Line CMenu::CreatePopupMenu E:"8168"vc98"mfc"mfc"include"afxwin1.inl 1009 ShowYItemMenu E:"testmemleak"mytest.cpp 100 |
這裏省略了其餘的函數調用
如此,咱們很容易找到發生問題的函數是ShowYItemMenu()。當使用MFC之類的類庫編程時,大部分的API調用都被封裝在類庫的class裏,有了Call Stack信息,咱們就能夠很是容易的追蹤到真正發生泄漏的代碼。
記錄Call Stack信息會使程序的運行變得很是慢,所以默認狀況下BoundsChecker不會記錄Call Stack信息。能夠按照如下的步驟打開記錄Call Stack信息的選項開關:
1. 打開菜單:BoundsChecker|Setting…
2. 在Error Detection頁中,在Error Detection Scheme的List中選擇Custom
3. 在Category的Combox中選擇 Pointer and leak error check
4. 鉤上Report Call Stack複選框
5. 點擊Ok
基於Code Injection,BoundsChecker還提供了API Parameter的校驗功能,memory over run等功能。這些功能對於程序的開發都很是有益。因爲這些內容不屬於本文的主題,因此不在此詳述了。
儘管BoundsChecker的功能如此強大,可是面對隱式內存泄漏仍然顯得蒼白無力。因此接下來咱們看看如何用Performance Monitor檢測內存泄漏。
NT的內核在設計過程當中已經加入了系統監視功能,好比CPU的使用率,內存的使用狀況,I/O操做的頻繁度等都做爲一個個Counter,應用程序能夠經過讀取這些Counter瞭解整個系統的或者某個進程的運行情況。Performance Monitor就是這樣一個應用程序。
爲了檢測內存泄漏,咱們通常能夠監視Process對象的Handle Count,Virutal Bytes 和Working Set三個Counter。Handle Count記錄了進程當前打開的HANDLE的個數,監視這個Counter有助於咱們發現程序是否有Handle泄漏;Virtual Bytes記錄了該進程當前在虛地址空間上使用的虛擬內存的大小,NT的內存分配採用了兩步走的方法,首先,在虛地址空間上保留一段空間,這時操做系統並無分配物理內存,只是保留了一段地址。而後,再提交這段空間,這時操做系統纔會分配物理內存。因此,Virtual Bytes通常總大於程序的Working Set。監視Virutal Bytes能夠幫助咱們發現一些系統底層的問題; Working Set記錄了操做系統爲進程已提交的內存的總量,這個值和程序申請的內存總量存在密切的關係,若是程序存在內存的泄漏這個值會持續增長,可是Virtual Bytes倒是跳躍式增長的。
監視這些Counter可讓咱們瞭解進程使用內存的狀況,若是發生了泄漏,即便是隱式內存泄漏,這些Counter的值也會持續增長。可是,咱們知道有問題殊不知道哪裏有問題,因此通常使用Performance Monitor來驗證是否有內存泄漏,而使用BoundsChecker來找到和解決。
當Performance Monitor顯示有內存泄漏,而BoundsChecker卻沒法檢測到,這時有兩種可能:第一種,發生了偶發性內存泄漏。這時你要確保使用Performance Monitor和使用BoundsChecker時,程序的運行環境和操做方法是一致的。第二種,發生了隱式的內存泄漏。這時你要從新審查程序的設計,而後仔細研究Performance Monitor記錄的Counter的值的變化圖,分析其中的變化和程序運行邏輯的關係,找到一些可能的緣由。這是一個痛苦的過程,充滿了假設、猜測、驗證、失敗,但這也是一個積累經驗的絕好機會。
若是一我的自稱爲程序高手,卻對內存一無所知,那麼我能夠告訴你,他必定在吹牛。用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對象的大體內存佈局以下:
讓咱們想一想,當咱們將一個Derived1型的對象傳給一個接受非引用Base型參數的函數時會發生切割,那麼是怎麼切割的呢?相信如今你已經知道了,那就是僅僅取出了Derived1型的對象中的subobject,而忽略了全部Derived1自定義的其它數據成員,而後將這個subobject傳遞給函數(實際上,函數中使用的是這個subobject的拷貝)。
全部繼承Base類的派生類的對象都含有一個Base型的subobject(這是能用Base型指針指向一個Derived1對象的關鍵所在,天然也是多態的關鍵了),而全部的subobject和全部Base型的對象都共用同一個s_object對象,天然,從Base類派生的整個繼承體系中的類的實例都會共用同一個s_object對象了。上面提到的example、example1、example2的對象佈局以下圖所示:
棧對象的優點是在適當的時候自動生成,又在適當的時候自動銷燬,不須要程序員操心;並且棧對象的建立速度通常較堆對象快,由於分配堆對象時,會調用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 ; //防止使用懸掛指針 |
如今感受是否是好多了,生成對象和釋放對象的操做一致了。
許多 C 或者 C++ 程序員對垃圾回收嗤之以鼻,認爲垃圾回收確定比本身來管理動態內存要低效,並且在回收的時候必定會讓程序停頓在那裏,而若是本身控制內存管理的話,分配和釋放時間都是穩定的,不會致使程序停頓。最後,不少 C/C++ 程序員堅信在C/C++ 中沒法實現垃圾回收機制。這些錯誤的觀點都是因爲不瞭解垃圾回收的算法而臆想出來的。
其實垃圾回收機制並不慢,甚至比動態內存分配更高效。由於咱們能夠只分配不釋放,那麼分配內存的時候只須要從堆上一直的得到新的內存,移動堆頂的指針就夠了;而釋放的過程被省略了,天然也加快了速度。現代的垃圾回收算法已經發展了不少,增量收集算法已經可讓垃圾回收過程分段進行,避免打斷程序的運行了。而傳統的動態內存管理的算法一樣有在適當的時間收集內存碎片的工做要作,並不比垃圾回收更有優點。
而垃圾回收的算法的基礎一般基於掃描並標記當前可能被使用的全部內存塊,從已經被分配的全部內存中把未標記的內存回收來作的。C/C++ 中沒法實現垃圾回收的觀點一般基於沒法正確掃描出全部可能還會被使用的內存塊,可是,看似不可能的事情實際上實現起來卻並不複雜。首先,經過掃描內存的數據,指向堆上動態分配出來內存的指針是很容易被識別出來的,若是有識別錯誤,也只能是把一些不是指針的數據當成指針,而不會把指針當成非指針數據。這樣,回收垃圾的過程只會漏回收掉而不會錯誤的把不該該回收的內存清理。其次,若是回溯全部內存塊被引用的根,只可能存在於全局變量和當前的棧內,而全局變量(包括函數內的靜態變量)都是集中存在於 bss段或 data段中。
垃圾回收的時候,只須要掃描 bss 段, data 段以及當前被使用着的棧空間,找到多是動態內存指針的量,把引用到的內存遞歸掃描就能夠獲得當前正在使用的全部動態內存了。
若是肯爲你的工程實現一個不錯的垃圾回收器,提升內存管理的速度,甚至減小總的內存消耗都是可能的。若是有興趣的話,能夠搜索一下網上已有的關於垃圾回收的論文和實現了的庫,開拓視野對一個程序員尤其重要。