[導語]
內存管理是C++最令人切齒痛恨的問題,也是C++最有爭議的問題,C++高手從中獲得了更好的性能,更大的自由,C++菜鳥的收穫則是一遍一遍的檢查代碼和對C++的痛恨,但內存管理在C++中無處不在,內存泄漏幾乎在每個C++程序中都會發生,因此要想成爲C++高手,內存管理一關是必須要過的,除非放棄C++,轉到Java或者.NET,他們的內存管理基本是自動的,當然你也放棄了自由和對內存的支配權,還放棄了C++超絕的性能。本期專題將從內存管理、內存泄漏、內存回收這三個方面來探討C++內存管理問題。
1 內存管理
偉大的Bill Gates 曾經失言:
640K ought to be enough for everybody — Bill Gates 1981
程序員們經常編寫內存管理程序,往往提心吊膽。如果不想觸雷,唯一的解決辦法就是發現所有潛伏的地雷並且排除它們,躲是躲不了的。本文的內容比一般教科書的要深入得多,讀者需細心閱讀,做到真正地通曉內存管理。
1.1 C++內存管理詳解
1.1.1 內存分配方式
1.1.1.1 分配方式簡介
在C++中,內存分成5個區,他們分別是堆、棧、自由存儲區、全局/靜態存儲區和常量存儲區。
棧,在執行函數時,函數內局部變量的存儲單元都可以在棧上創建,函數執行結束時這些存儲單元自動被釋放。棧內存分配運算內置於處理器的指令集中,效率很高,但是分配的內存容量有限。
堆,就是那些由new分配的內存塊,他們的釋放編譯器不去管,由我們的應用程序去控制,一般一個new就要對應一個delete。如果程序員沒有釋放掉,那麼在程序結束後,操作系統會自動回收。
自由存儲區,就是那些由malloc等分配的內存塊,他和堆是十分相似的,不過它是用free來結束自己的生命的。
全局/靜態存儲區,全局變量和靜態變量被分配到同一塊內存中,在以前的C語言中,全局變量又分爲初始化的和未初始化的,在C++裏面沒有這個區分了,他們共同佔用同一塊內存區。
常量存儲區,這是一塊比較特殊的存儲區,他們裏面存放的是常量,不允許修改。
1.1.1.2 明確區分堆與棧
在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.1.1.3 堆和棧究竟有什麼區別?
好了,我們回到我們的主題:堆和棧究竟有什麼區別?
主要的區別由以下幾點:
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可是相當困難的:)
1.1.2 控制C++的內存分配
在嵌入式系統中使用C++的一個常見問題是內存分配,即對new 和 delete 操作符的失控。
具有諷刺意味的是,問題的根源卻是C++對內存的管理非常的容易而且安全。具體地說,當一個對象被消除時,它的析構函數能夠安全的釋放所分配的內存。
這當然是個好事情,但是這種使用的簡單性使得程序員們過度使用new 和 delete,而不注意在嵌入式C++環境中的因果關係。並且,在嵌入式系統中,由於內存的限制,頻繁的動態分配不定大小的內存會引起很大的問題以及堆破碎的風險。
作爲忠告,保守的使用內存分配是嵌入式環境中的第一原則。
但當你必須要使用new 和delete時,你不得不控制C++中的內存分配。你需要用一個全局的new 和delete來代替系統的內存分配符,並且一個類一個類的重載new 和delete。
一個防止堆破碎的通用方法是從不同固定大小的內存持中分配不同類型的對象。對每個類重載new 和delete就提供了這樣的控制。
1.1.2.1 重載全局的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 操作符的方法,你可以自由地採用不同的分配策略,從不同的內存池中分配不同的類對象。
1.1.2.2 爲單個的類重載 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[]操作符中的個數參數是數組的大小加上額外的存儲對象數目的一些字節。在你的內存分配機制重要考慮的這一點。你應該儘量避免分配對象數組,從而使你的內存分配策略簡單。
1.1.3 常見的內存錯誤及其對策
發生內存錯誤是件非常麻煩的事情。編譯器不能自動發現這些錯誤,通常是在程序運行時才能捕捉到。而這些錯誤大多沒有明顯的症狀,時隱時現,增加了改錯的難度。有時用戶怒氣衝衝地把你找來,程序卻沒有發生任何問題,你一走,錯誤又發作了。 常見的內存錯誤及其對策如下:
* 內存分配未成功,卻使用了它。
編程新手常犯這種錯誤,因爲他們沒有意識到內存分配會不成功。常用解決辦法是,在使用內存之前檢查指針是否爲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,防止產生“野指針”。
1.1.4 指針與數組的對比
C++/C程序中,指針和數組在不少地方可以相互替換着用,讓人產生一種錯覺,以爲兩者是等價的。
數組要麼在靜態存儲區被創建(如全局數組),要麼在棧上被創建。數組名對應着(而不是指向)一塊內存,其地址與容量在生命期內保持不變,只有數組的內容可以改變。
指針可以隨時指向任意類型的內存塊,它的特徵是“可變”,所以我們常用指針來操作動態內存。指針遠比數組靈活,但也更危險。
下面以字符串爲例比較指針與數組的特性。
1.1.4.1 修改內容
下面示例中,字符數組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; |
1.1.4.2 內容複製與比較
不能對數組名進行直接複製與比較。若想把數組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) … |
1.1.4.3 計算內存容量
用運算符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字節 } |
1.1.5 指針參數是如何傳遞內存的?
如果函數的參數是一個指針,不要指望用該指針去申請動態內存。如下示例中,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,它返回的始終是同一個“只讀”的內存塊。
1.1.6 杜絕“野指針”
“野指針”不是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就成了“野指針”。但奇怪的是我運行這個程序時居然沒有出錯,這可能與編譯器有關。
1.1.7 有了malloc/free爲什麼還要new/delete?
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也一樣。
1.1.8 內存耗盡怎麼辦?
如果在申請動態內存時找不到足夠大的內存塊,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); } } |
1.1.9 malloc/free的使用要點
函數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連續操作兩次就會導致程序運行錯誤。
1.1.10 new/delete的使用要點
運算符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; // 錯誤的用法 |
後者有可能引起程序崩潰和內存泄漏。
1.2 C++中的健壯指針和資源管理
我最喜歡的對資源的定義是:"任何在你的程序中獲得並在此後釋放的東西?quot;內存是一個相當明顯的資源的例子。它需要用new來獲得,用delete來釋放。同時也有許多其它類型的資源文件句柄、重要的片斷、Windows中的GDI資源,等等。將資源的概念推廣到程序中創建、釋放的所有對象也是十分方便的,無論對象是在堆中分配的還是在棧中或者是在全局作用於內生命的。
對於給定的資源的擁有着,是負責釋放資源的一個對象或者是一段代碼。所有權分立爲兩種級別——自動的和顯式的(automatic and explicit),如果一個對象的釋放是由語言本身的機制來保證的,這個對象的就是被自動地所有。例如,一個嵌入在其他對象中的對象,他的清除需要其他對象來在清除的時候保證。外面的對象被看作嵌入類的所有者。 類似地,每個在棧上創建的對象(作爲自動變量)的釋放(破壞)是在控制流離開了對象被定義的作用域的時候保證的。這種情況下,作用於被看作是對象的所有者。注意所有的自動所有權都是和語言的其他機制相容的,包括異常。無論是如何退出作用域的——正常流程控制退出、一個break語句、一個return、一個goto、或者是一個throw——自動資源都可以被清除。
到目前爲止,一切都很好!問題是在引入指針、句柄和抽象的時候產生的。如果通過一個指針訪問一個對象的話,比如對象在堆中分配,C++不自動地關注它的釋放。程序員必須明確的用適當的程序方法來釋放這些資源。比如說,如果一個對象是通過調用new來創建的,它需要用delete來回收。一個文件是用CreateFile(Win32 API)打開的,它需要用CloseHandle來關閉。用EnterCritialSection進入的臨界區(Critical Section)需要LeaveCriticalSection退出,等等。一個"裸"指針,文件句柄,或者臨界區狀態沒有所有者來確保它們的最終釋放。基本的資源管理的前提就是確保每個資源都有他們的所有者。
1.2.1 第一條規則(RAII)
一個指針,一個句柄,一個臨界區狀態只有在我們將它們封裝入對象的時候纔會擁有所有者。這就是我們的第一規則:在構造函數中分配資源,在析構函數中釋放資源。
當你按照規則將所有資源封裝的時候,你可以保證你的程序中沒有任何的資源泄露。這點在當封裝對象(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 } |
注意無論發生什麼,臨界區都會藉助於語言的機制保證釋放。
還有一件需要記住的事情——每一種資源都需要被分別封裝。這是因爲資源分配是一個非常容易出錯的操作,是要資源是有限提供的。我們會假設一個失敗的資源分配會導致一個異常——事實上,這會經常的發生。所以如果你想試圖用一個石頭打兩隻鳥的話,或者在一個構造函數中申請兩種形式的資源,你可能就會陷入麻煩。只要想想在一種資源分配成功但另一種失敗拋出異常時會發生什麼。因爲構造函數還沒有全部完成,析構函數不可能被調用,第一種資源就會發生泄露。
這種情況可以非常簡單的避免。無論何時你有一個需要兩種以上資源的類時,寫兩個小的封裝器將它們嵌入你的類中。每一個嵌入的構造都可以保證刪除,即使包裝類沒有構造完成。
1.2.2 Smart Pointers
我們至今還沒有討論最常見類型的資源——用操作符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即可。
1.2.3 Resource Transfer
到目前爲止,我們所討論的一直是生命週期在一個單獨的作用域內的資源。現在我們要解決一個困難的問題——如何在不同的作用域間安全的傳遞資源。這一問題在當你處理容器的時候會變得十分明顯。你可以動態的創建一串對象,將它們存放至一個容器中,然後將它們取出,並且在最終安排它們。爲了能夠讓這安全的工作——沒有泄露——對象需要改變其所有者。
這個問題的一個非常顯而易見的解決方法是使用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?這聽起來並不好。
1.2.4 Strong Pointers
資源管理在內容索引(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。
我馬上意識到我已經在某些東西之上了。我開始用了新的方法重寫原來的代碼。
1.2.5 Parser
我過去有一個老的算術操作分析器,是用老的資源管理的技術寫的。分析器的作用是在分析樹中生成節點,節點是動態分配的。例如分析器的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方法並不將內部的指針清零--你的編譯器的庫很可能用的就是這種陳舊的實現),但是最後在標準被廣泛接受之前都被解決了。
1.2.6 Transfer Semantics
目前爲止,我們一直在討論在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的類來封裝。
1.2.7 Strong Vectors
標準庫只在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等等。
1.2.8 Code Inspection
如果你嚴格遵照資源管理的條款,你就不會再資源泄露或者兩次刪除的地方遇到麻煩。你也降低了訪問野指針的機率。同樣的,遵循原有的規則,用delete刪除用new申請的德指針,不要兩次刪除一個指針。你也不會遇到麻煩。但是,那個是更好的注意呢?
這兩個方法有一個很大的不同點。就是和尋找傳統方法的bug相比,找到違反資源管理的規定要容易的多。後者僅需要一個代碼檢測或者一個運行測試,而前者則在代碼中隱藏得很深,並需要很深的檢查。
設想你要做一段傳統的代碼的內存泄露檢查。第一件事,你要做的就是grep所有在代碼中出現的new,你需要找出被分配空間地指針都作了什麼。你需要確定導致刪除這個指針的所有的執行路徑。你需要檢查break語句,過程返回,異常。原有的指針可能賦給另一個指針,你對這個指針也要做相同的事。
相比之下,對於一段用資源管理技術實現的代碼。你也用grep檢查所有的new,但是這次你只需要檢查鄰近的調用:
● 這是一個直接的Strong Pointer轉換,還是我們在一個構造函數的函數體中?
● 調用的返回知是否立即保存到對象中,構造函數中是否有可以產生異常的代碼。?
● 如果這樣的話析構函數中時候有delete?
下一步,你需要用grep查找所有的release方法,並實施相同的檢查。
不同點是需要檢查、理解單個執行路徑和只需要做一些本地的檢驗。這難道不是提醒你非結構化的和結構化的程序設計的不同嗎?原理上,你可以認爲你可以應付goto,並且跟蹤所有的可能分支。另一方面,你可以將你的懷疑本地化爲一段代碼。本地化在兩種情況下都是關鍵所在。
在資源管理中的錯誤模式也比較容易調試。最常見的bug是試圖訪問一個釋放過的strong pointer。這將導致一個錯誤,並且很容易跟蹤。
1.2.9 共享的所有權
爲每一個程序中的資源都找出或者指定一個所有者是一件很容易的事情嗎?答案是出乎意料的,是!如果你發現了一些問題,這可能說明你的設計上存在問題。還有另一種情況就是共享所有權是最好的甚至是唯一的選擇。
共享的責任分配給被共享的對象和它的客戶(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)是讀者的工作。
1.2.10 所有權網絡
鏈表是資源管理分析中的一個很有意思的例子。如果你選擇表成爲鏈(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) 你會發現資源管理清除了許多錯誤和異常處理帶來的複雜性。不僅僅你的代碼會變得精力充沛,它也會變得簡單並容易維護。
2 內存泄漏
2.1 C++中動態內存分配引發問題的解決方案
假設我們要開發一個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: 第四個範例。 下面,程序結束,析構函數將被調用。 這個字符串將被刪除:第四個範例。 這個字符串將被刪除:第三個範例。 這個字符串將被刪除:第四個範例。 這個字符串將被刪除:第三個範例。 這個字符串將被刪除:第二個範例。 這個字符串將被刪除:第一個範例。 |
2.2 如何對付內存泄漏?
寫出那些不會導致任何內存泄漏的代碼。很明顯,當你的代碼中到處充滿了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)。
2.3淺談C/C++內存泄漏及其檢測工具
對於一個c/c++程序員來說,內存泄漏是一個常見的也是令人頭疼的問題。已經有許多技術被研究出來以應對這個問題,比如Smart Pointer,Garbage Collection等。Smart Pointer技術比較成熟,STL中已經包含支持Smart Pointer的class,但是它的使用似乎並不廣泛,而且它也不能解決所有的問題;Garbage Collection技術在Java中已經比較成熟,但是在c/c++領域的發展並不順暢,雖然很早就有人思考在C++中也加入GC的支持。現實世界就是這樣的,作爲一個c/c++程序員,內存泄漏是你心中永遠的痛。不過好在現在有許多工具能夠幫助我們驗證內存泄漏的存在,找出發生問題的代碼。
2.3.1 內存泄漏的定義
一般我們常說的內存泄漏是指堆內存的泄漏。堆內存是指程序從堆中分配的,大小任意的(內存塊的大小可以在程序運行期決定),使用完後必須顯示釋放的內存。應用程序一般使用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的要小很多。
2.3.2 內存泄漏的發生方式
以發生的方式來分類,內存泄漏可以分爲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的析構函數裏被刪除)。當不斷的有連接建立、斷開時隱式內存泄漏就發生了。
從用戶使用程序的角度來看,內存泄漏本身不會產生什麼危害,作爲一般的用戶,根本感覺不到內存泄漏的存在。真正有危害的是內存泄漏的堆積,這會最終消耗盡系統所有的內存。從這個角度來說,一次性內存泄漏並沒有什麼危害,因爲它不會堆積,而隱式內存泄漏危害性則非常大,因爲較之於常發性和偶發性內存泄漏它更難被檢測到。
2.3.3 檢測內存泄漏
檢測內存泄漏的關鍵是要能截獲住對分配內存和釋放內存的函數的調用。截獲住這兩個函數,我們就能跟蹤每一塊內存的生命週期,比如,每當成功的分配一塊內存後,就把它的指針加入一個全局的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雖然無法標示出發生問題的代碼,但是它能檢測出隱式的內存泄漏的存在,這是其他兩類工具無能爲力的地方。
以下我們詳細討論這三種檢測工具:
2.3.3.1 VC下內存泄漏的檢測方法
用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,我用下來基本上沒有什麼大問題。
2.3.3.2 使用BoundsChecker檢測內存泄漏
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檢測內存泄漏。
2.3.3.3 使用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的值的變化圖,分析其中的變化和程序運行邏輯的關係,找到一些可能的原因。這是一個痛苦的過程,充滿了假設、猜想、驗證、失敗,但這也是一個積累經驗的絕好機會。
3 探討C++內存回收
3.1 C++內存對象大會戰
如果一個人自稱爲程序高手,卻對內存一無所知,那麼我可以告訴你,他一定在吹牛。用C或C++寫程序,需要更多地關注內存,這不僅僅是因爲內存的分配是否合理直接影響着程序的效率和性能,更爲主要的是,當我們操作內存的時候一不小心就會出現問題,而且很多時候,這些問題都是不易發覺的,比如內存泄漏,比如懸掛指針。筆者今天在這裏並不是要討論如何避免這些問題,而是想從另外一個角度來認識C++內存對象。
我們知道,C++將內存劃分爲三個邏輯區域:堆、棧和靜態存儲區。既然如此,我稱位於它們之中的對象分別爲堆對象,棧對象以及靜態對象。那麼這些不同的內存對象有什麼區別了?堆對象和棧對象各有什麼優劣了?如何禁止創建堆對象或棧對象了?這些便是今天的主題。
3.1.1 基本概念
先來看看棧。棧,一般用於存放局部變量或對象,如我們在函數定義中用類似下面語句聲明的對象:
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的對象佈局如下圖所示:
3.1.2 三種內存對象的比較
棧對象的優勢是在適當的時候自動生成,又在適當的時候自動銷燬,不需要程序員操心;而且棧對象的創建速度一般較堆對象快,因爲分配堆對象時,會調用operator new操作,operator new會採用某種內存空間搜索算法,而該搜索過程可能是很費時間的,產生棧對象則沒有這麼麻煩,它僅僅需要移動棧頂指針就可以了。但是要注意的是,通常棧空間容量比較小,一般是1MB~2MB,所以體積比較大的對象不適合在棧中分配。特別要注意遞歸函數中最好不要使用棧對象,因爲隨着遞歸調用深度的增加,所需的棧空間也會線性增加,當所需棧空間不夠時,便會導致棧溢出,這樣就會產生運行時錯誤。
堆對象,其產生時刻和銷燬時刻都要程序員精確定義,也就是說,程序員對堆對象的生命具有完全的控制權。我們常常需要這樣的對象,比如,我們需要創建一個對象,能夠被多個函數所訪問,但是又不想使其成爲全局的,那麼這個時候創建一個堆對象無疑是良好的選擇,然後在各個函數之間傳遞這個堆對象的指針,便可以實現對該對象的共享。另外,相比於棧空間,堆的容量要大得多。實際上,當物理內存不夠時,如果這時還需要生成新的堆對象,通常不會產生運行時錯誤,而是系統會使用虛擬內存來擴展實際的物理內存。
接下來看看static對象。
首先是全局對象。全局對象爲類間通信和函數間通信提供了一種最簡單的方式,雖然這種方式並不優雅。一般而言,在完全的面嚮對象語言中,是不存在全局對象的,比如C#,因爲全局對象意味着不安全和高耦合,在程序中過多地使用全局對象將大大降低程序的健壯性、穩定性、可維護性和可複用性。C++也完全可以剔除全局對象,但是最終沒有,我想原因之一是爲了兼容C。
其次是類的靜態成員,上面已經提到,基類及其派生類的所有對象都共享這個靜態成員對象,所以當需要在這些class之間或這些class objects之間進行數據共享或通信時,這樣的靜態成員無疑是很好的選擇。
接着是靜態局部對象,主要可用於保存該對象所在函數被屢次調用期間的中間狀態,其中一個最顯著的例子就是遞歸函數,我們都知道遞歸函數是自己調用自己的函數,如果在遞歸函數中定義一個nonstatic局部對象,那麼當遞歸次數相當大時,所產生的開銷也是巨大的。這是因爲nonstatic局部對象是棧對象,每遞歸調用一次,就會產生一個這樣的對象,每返回一次,就會釋放這個對象,而且,這樣的對象只侷限於當前調用層,對於更深入的嵌套層和更淺露的外層,都是不可見的。每個層都有自己的局部對象和參數。
在遞歸函數設計中,可以使用static對象替代nonstatic局部對象(即棧對象),這不僅可以減少每次遞歸調用和返回時產生和釋放nonstatic對象的開銷,而且static對象還可以保存遞歸調用的中間狀態,並且可爲各個調用層所訪問。
3.1.3 使用棧對象的意外收穫
前面已經介紹到,棧對象是在適當的時候創建,然後在適當的時候自動釋放的,也就是棧對象有自動管理功能。那麼棧對象會在什麼會自動釋放了?第一,在其生命期結束的時候;第二,在其所在的函數發生異常的時候。你也許說,這些都很正常啊,沒什麼大不了的。是的,沒什麼大不了的。但是隻要我們再深入一點點,也許就有意外的收穫了。
棧對象,自動釋放時,會調用它自己的析構函數。如果我們在棧對象中封裝資源,而且在棧對象的析構函數中執行釋放資源的動作,那麼就會使資源泄漏的概率大大降低,因爲棧對象可以自動的釋放資源,即使在所在函數發生異常的時候。實際的過程是這樣的:函數拋出異常時,會發生所謂的stack_unwinding(堆棧回滾),即堆棧會展開,由於是棧對象,自然存在於棧中,所以在堆棧回滾的過程中,棧對象的析構函數會被執行,從而釋放其所封裝的資源。除非,除非在析構函數執行的過程中再次拋出異常――而這種可能性是很小的,所以用棧對象封裝資源是比較安全的。基於此認識,我們就可以創建一個自己的句柄或代理來封裝資源了。智能指針(auto_ptr)中就使用了這種技術。在有這種需要的時候,我們就希望我們的資源封裝類只能在棧中創建,也就是要限制在堆中創建該資源封裝類的實例。
3.1.4 禁止產生堆對象
上面已經提到,你決定禁止產生某種類型的堆對象,這時你可以自己創建一個資源封裝類,該類對象只能在棧中產生,這樣就能在異常的情況下自動釋放封裝的資源。
那麼怎樣禁止產生堆對象了?我們已經知道,產生堆對象的唯一方法是使用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 ; |
但是,並不一定所有的編譯器都是如此。
既然我們可以禁止產生某種類型的堆對象,那麼可以設計一個類,使之不能產生棧對象嗎?當然可以。
3.1.5 禁止產生棧對象
前面已經提到了,創建棧對象時會移動棧頂指針以“挪出”適當大小的空間,然後在這個空間上直接調用對應的構造函數以形成一個棧對象,而當函數返回時,會調用其析構函數釋放這個對象,然後再調整棧頂指針收回那塊棧內存。在這個過程中是不需要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 ; //防止使用懸掛指針 |
現在感覺是不是好多了,生成對象和釋放對象的操作一致了。
3.2 淺議C++ 中的垃圾回收方法
許多 C 或者 C++ 程序員對垃圾回收嗤之以鼻,認爲垃圾回收肯定比自己來管理動態內存要低效,而且在回收的時候一定會讓程序停頓在那裏,而如果自己控制內存管理的話,分配和釋放時間都是穩定的,不會導致程序停頓。最後,很多 C/C++ 程序員堅信在C/C++ 中無法實現垃圾回收機制。這些錯誤的觀點都是由於不瞭解垃圾回收的算法而臆想出來的。
其實垃圾回收機制並不慢,甚至比動態內存分配更高效。因爲我們可以只分配不釋放,那麼分配內存的時候只需要從堆上一直的獲得新的內存,移動堆頂的指針就夠了;而釋放的過程被省略了,自然也加快了速度。現代的垃圾回收算法已經發展了很多,增量收集算法已經可以讓垃圾回收過程分段進行,避免打斷程序的運行了。而傳統的動態內存管理的算法同樣有在適當的時間收集內存碎片的工作要做,並不比垃圾回收更有優勢。
而垃圾回收的算法的基礎通常基於掃描並標記當前可能被使用的所有內存塊,從已經被分配的所有內存中把未標記的內存回收來做的。C/C++ 中無法實現垃圾回收的觀點通常基於無法正確掃描出所有可能還會被使用的內存塊,但是,看似不可能的事情實際上實現起來卻並不複雜。首先,通過掃描內存的數據,指向堆上動態分配出來內存的指針是很容易被識別出來的,如果有識別錯誤,也只能是把一些不是指針的數據當成指針,而不會把指針當成非指針數據。這樣,回收垃圾的過程只會漏回收掉而不會錯誤的把不應該回收的內存清理。其次,如果回溯所有內存塊被引用的根,只可能存在於全局變量和當前的棧內,而全局變量(包括函數內的靜態變量)都是集中存在於 bss 段或 data段中。
垃圾回收的時候,只需要掃描 bss 段, data 段以及當前被使用着的棧空間,找到可能是動態內存指針的量,把引用到的內存遞歸掃描就可以得到當前正在使用的所有動態內存了。
如果肯爲你的工程實現一個不錯的垃圾回收器,提高內存管理的速度,甚至減少總的內存消耗都是可能的。如果有