內(nèi)存管理是C++最令人切齒痛恨的問題,也是C++最有爭議的問題,C++高手從中獲得了更好的性能,更大的自由,C++菜鳥的收獲則是一遍一遍的檢查代碼和對 C++的痛恨,但內(nèi)存管理在C++中無處不在,內(nèi)存泄漏幾乎在每個C++程序中都會發(fā)生,因此要想成為C++高手,內(nèi)存管理一關(guān)是必須要過的,除非放棄 C++,轉(zhuǎn)到Java或者.NET,他們的內(nèi)存管理基本是自動的,當(dāng)然你也放棄了自由和對內(nèi)存的支配權(quán),還放棄了C++超絕的性能。本期專題將從內(nèi)存管 理、內(nèi)存泄漏、內(nèi)存回收這三個方面來探討C++內(nèi)存管理問題。
1 內(nèi)存管理
偉大的Bill Gates 曾經(jīng)失言:
640K ought to be enough for everybody — Bill Gates 1981
程序員們經(jīng)常編寫內(nèi)存管理程序,往往提心吊膽。如果不想觸雷,唯一的解決辦法就是發(fā)現(xiàn)所有潛伏的地雷并且排除它們,躲是躲不了的。本文的內(nèi)容比一般教科書的要深入得多,讀者需細(xì)心閱讀,做到真正地通曉內(nèi)存管理。
1.1 C++內(nèi)存管理詳解
1.1.1 內(nèi)存分配方式
1.1.1.1 分配方式簡介
在C++中,內(nèi)存分成5個區(qū),他們分別是堆、棧、自由存儲區(qū)、全局/靜態(tài)存儲區(qū)和常量存儲區(qū)。
棧,在執(zhí)行函數(shù)時,函數(shù)內(nèi)局部變量的存儲單元都可以在棧上創(chuàng)建,函數(shù)執(zhí)行結(jié)束時這些存儲單元自動被釋放。棧內(nèi)存分配運(yùn)算內(nèi)置于處理器的指令集中,效率很高,但是分配的內(nèi)存容量有限。
堆,就是那些由new分配的內(nèi)存塊,他們的釋放編譯器不去管,由我們的應(yīng)用程序去控制,一般一個new就要對應(yīng)一個delete。如果程序員沒有釋放掉,那么在程序結(jié)束后,操作系統(tǒng)會自動回收。
自由存儲區(qū),就是那些由malloc等分配的內(nèi)存塊,他和堆是十分相似的,不過它是用free來結(jié)束自己的生命的。
全局/靜態(tài)存儲區(qū),全局變量和靜態(tài)變量被分配到同一塊內(nèi)存中,在以前的C語言中,全局變量又分為初始化的和未初始化的,在C++里面沒有這個區(qū)分了,他們共同占用同一塊內(nèi)存區(qū)。
常量存儲區(qū),這是一塊比較特殊的存儲區(qū),他們里面存放的是常量,不允許修改。
1.1.1.2 明確區(qū)分堆與棧
在bbs上,堆與棧的區(qū)分問題,似乎是一個永恒的話題,由此可見,初學(xué)者對此往往是混淆不清的,所以我決定拿他第一個開刀。
首先,我們舉一個例子:
void f() { int* p=new int[5]; } |
這條短短的一句話就包含了堆與棧,看到new,我們首先就應(yīng)該想到,我們分配了一塊堆內(nèi)存,那么指針p呢?他分配的是一塊棧內(nèi)存,所以這句話的意思就是:在棧內(nèi)存中存放了一個指向一塊堆內(nèi)存的指針p。在程序會先確定在堆中分配內(nèi)存的大小,然后調(diào)用operator new分配內(nèi)存,然后返回這塊內(nèi)存的首地址,放入棧中,他在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 |
這里,我們?yōu)榱撕唵尾]有釋放內(nèi)存,那么該怎么去釋放呢?是delete p么?澳,錯了,應(yīng)該是delete []p,這是為了告訴編譯器:我刪除的是一個數(shù)組,VC6就會根據(jù)相應(yīng)的Cookie信息去進(jìn)行釋放內(nèi)存的工作。
1.1.1.3 堆和棧究竟有什么區(qū)別?
好了,我們回到我們的主題:堆和棧究竟有什么區(qū)別?
主要的區(qū)別由以下幾點(diǎn):
1、管理方式不同;
2、空間大小不同;
3、能否產(chǎn)生碎片不同;
4、生長方向不同;
5、分配方式不同;
6、分配效率不同;
管理方式:對于棧來講,是由編譯器自動管理,無需我們手工控制;對于堆來說,釋放工作由程序員控制,容易產(chǎn)生memory leak。
空間大。阂话銇碇v在32位系統(tǒng)下,堆內(nèi)存可以達(dá)到4G的空間,從這個角度來看堆內(nèi)存幾乎是沒有什么限制的。但是對于棧來講,一般都是有一定的空間大小的,例如,在VC6下面,默認(rèn)的?臻g大小是1M(好像是,記不清楚了)。當(dāng)然,我們可以修改:
打開工程,依次操作菜單如下:Project->Setting->Link,在Category 中選中Output,然后在Reserve中設(shè)定堆棧的最大值和commit。
注意:reserve最小值為4Byte;commit是保留在虛擬內(nèi)存的頁文件里面,它設(shè)置的較大會使棧開辟較大的值,可能增加內(nèi)存的開銷和啟動時間。
碎片問題:對于堆來講,頻繁的new/delete勢 必會造成內(nèi)存空間的不連續(xù),從而造成大量的碎片,使程序效率降低。對于棧來講,則不會存在這個問題,因為棧是先進(jìn)后出的隊列,他們是如此的一一對應(yīng),以至 于永遠(yuǎn)都不可能有一個內(nèi)存塊從棧中間彈出,在他彈出之前,在他上面的后進(jìn)的棧內(nèi)容已經(jīng)被彈出,詳細(xì)的可以參考數(shù)據(jù)結(jié)構(gòu),這里我們就不再一一討論了。
生長方向:對于堆來講,生長方向是向上的,也就是向著內(nèi)存地址增加的方向;對于棧來講,它的生長方向是向下的,是向著內(nèi)存地址減小的方向增長。
分配方式:堆都是動態(tài)分配的,沒有靜態(tài)分配的堆。棧有2種分配方式:靜態(tài)分配和動態(tài)分配。靜態(tài)分配是編譯器完成的,比如局部變量的分配。動態(tài)分配由alloca函數(shù)進(jìn)行分配,但是棧的動態(tài)分配和堆是不同的,他的動態(tài)分配是由編譯器進(jìn)行釋放,無需我們手工實現(xiàn)。
分配效率:棧是機(jī)器系統(tǒng)提供的數(shù)據(jù)結(jié)構(gòu),計算機(jī)會在底層對棧提供支持:分配專門的寄存器存放棧的地址,壓棧出棧都有專門的指令執(zhí)行,這就決定了棧的效率比較高。堆則是C/C++函數(shù)庫提供的,它的機(jī)制是很復(fù)雜的,例如為了分配一塊內(nèi)存,庫函數(shù)會按照一定的算法(具體的算法可以參考數(shù)據(jù)結(jié)構(gòu)/操作系統(tǒng))在堆內(nèi)存中搜索可用的足夠大小的空間,如果沒有足夠大小的空間(可能是由于內(nèi)存碎片太多),就有可能調(diào)用系統(tǒng)功能去增加程序數(shù)據(jù)段的內(nèi)存空間,這樣就有機(jī)會分到足夠大小的內(nèi)存,然后進(jìn)行返回。顯然,堆的效率比棧要低得多。
從這里我們可以看到,堆和棧相比,由于大量new/delete的使用,容易造成大量的內(nèi)存碎片;由于沒有專門的系統(tǒng)支持,效率很低;由于可能引發(fā)用戶態(tài)和核心態(tài)的切換,內(nèi)存的申請,代價變得更加昂貴。所以棧在程序中是應(yīng)用最廣泛的,就算是函數(shù)的調(diào)用也利用棧去完成,函數(shù)調(diào)用過程中的參數(shù),返回地址,EBP和局部變量都采用棧的方式存放。所以,我們推薦大家盡量用棧,而不是用堆。
雖然棧有如此眾多的好處,但是由于和堆相比不是那么靈活,有時候分配大量的內(nèi)存空間,還是用堆好一些。
無論是堆還是棧,都要防止越界現(xiàn)象的發(fā)生(除非你是故意使其越界),因為越界的結(jié)果要么是程序崩潰,要么是摧毀程序的堆、棧結(jié)構(gòu),產(chǎn)生以想不到的結(jié)果,就算是在你的程序運(yùn)行過程中,沒有發(fā)生上面的問題,你還是要小心,說不定什么時候就崩掉,那時候debug可是相當(dāng)困難的:)
1.1.2 控制C++的內(nèi)存分配
在嵌入式系統(tǒng)中使用C++的一個常見問題是內(nèi)存分配,即對new 和 delete 操作符的失控。
具有諷刺意味的是,問題的根源卻是C++對內(nèi)存的管理非常的容易而且安全。具體地說,當(dāng)一個對象被消除時,它的析構(gòu)函數(shù)能夠安全的釋放所分配的內(nèi)存。
這當(dāng)然是個好事情,但是這種使用的簡單性使得程序員們過度使用new 和 delete,而不注意在嵌入式C++環(huán)境中的因果關(guān)系。并且,在嵌入式系統(tǒng)中,由于內(nèi)存的限制,頻繁的動態(tài)分配不定大小的內(nèi)存會引起很大的問題以及堆破碎的風(fēng)險。
作為忠告,保守的使用內(nèi)存分配是嵌入式環(huán)境中的第一原則。
但當(dāng)你必須要使用new 和delete時,你不得不控制C++中的內(nèi)存分配。你需要用一個全局的new 和delete來代替系統(tǒng)的內(nèi)存分配符,并且一個類一個類的重載new 和delete。
一個防止堆破碎的通用方法是從不同固定大小的內(nèi)存持中分配不同類型的對象。對每個類重載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); } |
這段代碼可以代替默認(rèn)的操作符來滿足內(nèi)存分配的請求。出于解釋C++的目的,我們也可以直接調(diào)用malloc() 和free()。
也可以對單個類的new 和 delete 操作符重載。這是你能靈活的控制對象的內(nèi)存分配。
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 對象的內(nèi)存分配都采用這段代碼。更進(jìn)一步,任何從TestClass 繼承的類也都采用這一方式,除非它自己也重載了new 和 delete 操作符。通過重載new 和 delete 操作符的方法,你可以自由地采用不同的分配策略,從不同的內(nèi)存池中分配不同的類對象。
1.1.2.2 為單個的類重載 new[ ]和delete[ ]
必須小心對象數(shù)組的分配。你可能希望調(diào)用到被你重載過的new 和 delete 操作符,但并不如此。內(nèi)存的請求被定向到全局的new[ ]和delete[ ] 操作符,而這些內(nèi)存來自于系統(tǒng)堆。
C++將對象數(shù)組的內(nèi)存分配作為一個單獨(dú)的操作,而不同于單個對象的內(nèi)存分配。為了改變這種方式,你同樣需要重載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; } |
但是注意:對于多數(shù)C++的實現(xiàn),new[]操作符中的個數(shù)參數(shù)是數(shù)組的大小加上額外的存儲對象數(shù)目的一些字節(jié)。在你的內(nèi)存分配機(jī)制重要考慮的這一點(diǎn)。你應(yīng)該盡量避免分配對象數(shù)組,從而使你的內(nèi)存分配策略簡單。
1.1.3 常見的內(nèi)存錯誤及其對策
發(fā)生內(nèi)存錯誤是件非常麻煩的事情。編譯器不能自動發(fā)現(xiàn)這些錯誤,通常是在程序運(yùn)行時才能捕捉到。而這些錯誤大多沒有明顯的癥狀,時隱時現(xiàn),增加了改錯的難度。有時用戶怒氣沖沖地把你找來,程序卻沒有發(fā)生任何問題,你一走,錯誤又發(fā)作了。 常見的內(nèi)存錯誤及其對策如下:
* 內(nèi)存分配未成功,卻使用了它。
編程新手常犯這種錯誤,因為他們沒有意識到內(nèi)存分配會不成功。常用解決辦法是,在使用內(nèi)存之前檢查指針是否為NULL。如果指針p是函數(shù)的參數(shù),那么在函數(shù)的入口處用assert(p!=NULL)進(jìn)行
檢查。如果是用malloc或new來申請內(nèi)存,應(yīng)該用if(p==NULL) 或if(p!=NULL)進(jìn)行防錯處理。
* 內(nèi)存分配雖然成功,但是尚未初始化就引用它。
犯這種錯誤主要有兩個起因:一是沒有初始化的觀念;二是誤以為內(nèi)存的缺省初值全為零,導(dǎo)致引用初值錯誤(例如數(shù)組)。 內(nèi)存的缺省初值究竟是什么并沒有統(tǒng)一的標(biāo)準(zhǔn),盡管有些時候為零值,我們寧可信其無不可信其有。所以無論用何種方式創(chuàng)建數(shù)組,都別忘了賦初值,即便是賦零值也不可省略,不要嫌麻煩。
* 內(nèi)存分配成功并且已經(jīng)初始化,但操作越過了內(nèi)存的邊界。
例如在使用數(shù)組時經(jīng)常發(fā)生下標(biāo)“多1”或者“少1”的操作。特別是在for循環(huán)語句中,循環(huán)次數(shù)很容易搞錯,導(dǎo)致數(shù)組操作越界。
* 忘記了釋放內(nèi)存,造成內(nèi)存泄露。
含有這種錯誤的函數(shù)每被調(diào)用一次就丟失一塊內(nèi)存。剛開始時系統(tǒng)的內(nèi)存充足,你看不到錯誤。終有一次程序突然死掉,系統(tǒng)出現(xiàn)提示:內(nèi)存耗盡。
動態(tài)內(nèi)存的申請與釋放必須配對,程序中malloc與free的使用次數(shù)一定要相同,否則肯定有錯誤(new/delete同理)。
* 釋放了內(nèi)存卻繼續(xù)使用它。
有三種情況:
。1)程序中的對象調(diào)用關(guān)系過于復(fù)雜,實在難以搞清楚某個對象究竟是否已經(jīng)釋放了內(nèi)存,此時應(yīng)該重新設(shè)計數(shù)據(jù)結(jié)構(gòu),從根本上解決對象管理的混亂局面。
。2)函數(shù)的return語句寫錯了,注意不要返回指向“棧內(nèi)存”的“指針”或者“引用”,因為該內(nèi)存在函數(shù)體結(jié)束時被自動銷毀。
。3)使用free或delete釋放了內(nèi)存后,沒有將指針設(shè)置為NULL。導(dǎo)致產(chǎn)生“野指針”。
【規(guī)則1】用malloc或new申請內(nèi)存之后,應(yīng)該立即檢查指針值是否為NULL。防止使用指針值為NULL的內(nèi)存。
【規(guī)則2】不要忘記為數(shù)組和動態(tài)內(nèi)存賦初值。防止將未被初始化的內(nèi)存作為右值使用。
【規(guī)則3】避免數(shù)組或指針的下標(biāo)越界,特別要當(dāng)心發(fā)生“多1”或者“少1”操作。
【規(guī)則4】動態(tài)內(nèi)存的申請與釋放必須配對,防止內(nèi)存泄漏。
【規(guī)則5】用free或delete釋放了內(nèi)存之后,立即將指針設(shè)置為NULL,防止產(chǎn)生“野指針”。
1.1.4 指針與數(shù)組的對比
C++/C程序中,指針和數(shù)組在不少地方可以相互替換著用,讓人產(chǎn)生一種錯覺,以為兩者是等價的。
數(shù)組要么在靜態(tài)存儲區(qū)被創(chuàng)建(如全局?jǐn)?shù)組),要么在棧上被創(chuàng)建。數(shù)組名對應(yīng)著(而不是指向)一塊內(nèi)存,其地址與容量在生命期內(nèi)保持不變,只有數(shù)組的內(nèi)容可以改變。
指針可以隨時指向任意類型的內(nèi)存塊,它的特征是“可變”,所以我們常用指針來操作動態(tài)內(nèi)存。指針遠(yuǎn)比數(shù)組靈活,但也更危險。
下面以字符串為例比較指針與數(shù)組的特性。
1.1.4.1 修改內(nèi)容
下面示例中,字符數(shù)組a的容量是6個字符,其內(nèi)容為hello。a的內(nèi)容可以改變,如a[0]= ‘X’。指針p指向常量字符串“world”(位于靜態(tài)存儲區(qū),內(nèi)容為world),常量字符串的內(nèi)容是不可以被修改的。從語法上看,編譯器并不覺得語句p[0]= ‘X’有什么不妥,但是該語句企圖修改常量字符串的內(nèi)容而導(dǎo)致運(yùn)行錯誤。
char a[] = “hello”; a[0] = ‘X’; cout << a << endl; char *p = “world”; // 注意p指向常量字符串 p[0] = ‘X’; // 編譯器不能發(fā)現(xiàn)該錯誤 cout << p << endl; |
1.1.4.2 內(nèi)容復(fù)制與比較
不能對數(shù)組名進(jìn)行直接復(fù)制與比較。若想把數(shù)組a的內(nèi)容復(fù)制給數(shù)組b,不能用語句 b = a ,否則將產(chǎn)生編譯錯誤。應(yīng)該用標(biāo)準(zhǔn)庫函數(shù)strcpy進(jìn)行復(fù)制。同理,比較b和a的內(nèi)容是否相同,不能用if(b==a) 來判斷,應(yīng)該用標(biāo)準(zhǔn)庫函數(shù)strcmp進(jìn)行比較。
語句p = a 并不能把a(bǔ)的內(nèi)容復(fù)制指針p,而是把a(bǔ)的地址賦給了p。要想復(fù)制a的內(nèi)容,可以先用庫函數(shù)malloc為p申請一塊容量為strlen(a)+1個字符的內(nèi)存,再用strcpy進(jìn)行字符串復(fù)制。同理,語句if(p==a) 比較的不是內(nèi)容而是地址,應(yīng)該用庫函數(shù)strcmp來比較。
// 數(shù)組… 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 計算內(nèi)存容量
用運(yùn)算符sizeof可以計算出數(shù)組的容量(字節(jié)數(shù))。如下示例中,sizeof(a)的值是12(注意別忘了’’)。指針p指向a,但是sizeof(p)的值卻是4。這是因為sizeof(p)得到的是一個指針變量的字節(jié)數(shù),相當(dāng)于sizeof(char*),而不是p所指的內(nèi)存容量。C++/C語言沒有辦法知道指針?biāo)傅膬?nèi)存容量,除非在申請內(nèi)存時記住它。
char a[] = "hello world"; char *p = a; cout<< sizeof(a) << endl; // 12字節(jié) cout<< sizeof(p) << endl; // 4字節(jié) |
注意當(dāng)數(shù)組作為函數(shù)的參數(shù)進(jìn)行傳遞時,該數(shù)組自動退化為同類型的指針。如下示例中,不論數(shù)組a的容量是多少,sizeof(a)始終等于sizeof(char *)。
void Func(char a[100]) { cout<< sizeof(a) << endl; // 4字節(jié)而不是100字節(jié) } |
1.1.5 指針參數(shù)是如何傳遞內(nèi)存的?
如果函數(shù)的參數(shù)是一個指針,不要指望用該指針去申請動態(tài)內(nèi)存。如下示例中,Test函數(shù)的語句GetMemory(str, 200)并沒有使str獲得期望的內(nèi)存,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"); // 運(yùn)行錯誤 } |
毛病出在函數(shù)GetMemory中。編譯器總是要為函數(shù)的每個參數(shù)制作臨時副本,指針參數(shù)p的副本是 _p,編譯器使 _p = p。如果函數(shù)體內(nèi)的程序修改了_p的內(nèi)容,就導(dǎo)致參數(shù)p的內(nèi)容作相應(yīng)的修改。這就是指針可以用作輸出參數(shù)的原因。在本例中,_p申請了新的內(nèi)存,只是把_p所指的內(nèi)存地址改變了,但是p絲毫未變。所以函數(shù)GetMemory并不能輸出任何東西。事實上,每執(zhí)行一次GetMemory就會泄露一塊內(nèi)存,因為沒有用free釋放內(nèi)存。
如果非得要用指針參數(shù)去申請內(nèi)存,那么應(yīng)該改用“指向指針的指針”,見示例:
void GetMemory2(char **p, int num) { *p = (char *)malloc(sizeof(char) * num); } void Test2(void) { char *str = NULL; GetMemory2(&str, 100); // 注意參數(shù)是 &str,而不是str strcpy(str, "hello"); cout<< str << endl; free(str); } |
由于“指向指針的指針”這個概念不容易理解,我們可以用函數(shù)返回值來傳遞動態(tài)內(nèi)存。這種方法更加簡單,見示例:
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); } |
用函數(shù)返回值來傳遞動態(tài)內(nèi)存這種方法雖然好用,但是常常有人把return語句用錯了。這里強(qiáng)調(diào)不要用return語句返回指向“棧內(nèi)存”的指針,因為該內(nèi)存在函數(shù)結(jié)束時自動消亡,見示例:
char *GetString(void) { char p[] = "hello world"; return p; // 編譯器將提出警告 } void Test4(void) { char *str = NULL; str = GetString(); // str 的內(nèi)容是垃圾 cout<< str << endl; } |
用調(diào)試器逐步跟蹤Test4,發(fā)現(xiàn)執(zhí)行str = GetString語句后str不再是NULL指針,但是str的內(nèi)容不是“hello world”而是垃圾。
如果把上述示例改寫成如下示例,會怎么樣?
char *GetString2(void) { char *p = "hello world"; return p; } void Test5(void) { char *str = NULL; str = GetString2(); cout<< str << endl; } |
函數(shù)Test5運(yùn)行雖然不會出錯,但是函數(shù)GetString2的設(shè)計概念卻是錯誤的。因為GetString2內(nèi)的“hello world”是常量字符串,位于靜態(tài)存儲區(qū),它在程序生命期內(nèi)恒定不變。無論什么時候調(diào)用GetString2,它返回的始終是同一個“只讀”的內(nèi)存塊。
1.1.6 杜絕“野指針”
“野指針”不是NULL指針,是指向“垃圾”內(nèi)存的指針。人們一般不會錯用NULL指針,因為用if語句很容易判斷。但是“野指針”是很危險的,if語句對它不起作用。 “野指針”的成因主要有兩種:
(1)指針變量沒有被初始化。任何指針變量剛被創(chuàng)建時不會自動成為NULL指針,它的缺省值是隨機(jī)的,它會亂指一氣。所以,指針變量在創(chuàng)建的同時應(yīng)當(dāng)被初始化,要么將指針設(shè)置為NULL,要么讓它指向合法的內(nèi)存。例如
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是“野指針” } |
函數(shù)Test在執(zhí)行語句p->Func()時,對象a已經(jīng)消失,而p是指向a的,所以p就成了“野指針”。但奇怪的是我運(yùn)行這個程序時居然沒有出錯,這可能與編譯器有關(guān)。
1.1.7 有了malloc/free為什么還要new/delete?
malloc與free是C++/C語言的標(biāo)準(zhǔn)庫函數(shù),new/delete是C++的運(yùn)算符。它們都可用于申請動態(tài)內(nèi)存和釋放內(nèi)存。
對于非內(nèi)部數(shù)據(jù)類型的對象而言,光用maloc/free無法滿足動態(tài)對象的要求。對象在創(chuàng)建的同時要自動執(zhí)行構(gòu)造函數(shù),對象在消亡之前要自動執(zhí)行析構(gòu)函數(shù)。由于malloc/free是庫函數(shù)而不是運(yùn)算符,不在編譯器控制權(quán)限之內(nèi),不能夠把執(zhí)行構(gòu)造函數(shù)和析構(gòu)函數(shù)的任務(wù)強(qiáng)加于malloc/free。
因此C++語言需要一個能完成動態(tài)內(nèi)存分配和初始化工作的運(yùn)算符new,以及一個能完成清理與釋放內(nèi)存工作的運(yùn)算符delete。注意new/delete不是庫函數(shù)。我們先看一看malloc/free和new/delete如何實現(xiàn)對象的動態(tài)內(nèi)存管理,見示例:
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)); // 申請動態(tài)內(nèi)存 a->Initialize(); // 初始化 //… a->Destroy(); // 清除工作 free(a); // 釋放內(nèi)存 } void UseNewDelete(void) { Obj *a = new Obj; // 申請動態(tài)內(nèi)存并且初始化 //… delete a; // 清除并且釋放內(nèi)存 } |
類Obj的函數(shù)Initialize模擬了構(gòu)造函數(shù)的功能,函數(shù)Destroy模擬了析構(gòu)函數(shù)的功能。函數(shù)UseMallocFree中,由于malloc/free不能執(zhí)行構(gòu)造函數(shù)與析構(gòu)函數(shù),必須調(diào)用成員函數(shù)Initialize和Destroy來完成初始化與清除工作。函數(shù)UseNewDelete則簡單得多。
所以我們不要企圖用malloc/free來完成動態(tài)對象的內(nèi)存管理,應(yīng)該用new/delete。由于內(nèi)部數(shù)據(jù)類型的“對象”沒有構(gòu)造與析構(gòu)的過程,對它們而言malloc/free和new/delete是等價的。
既然new/delete的功能完全覆蓋了malloc/free,為什么C++不把malloc/free淘汰出局呢?這是因為C++程序經(jīng)常要調(diào)用C函數(shù),而C程序只能用malloc/free管理動態(tài)內(nèi)存。
如果用free釋放“new創(chuàng)建的動態(tài)對象”,那么該對象因無法執(zhí)行析構(gòu)函數(shù)而可能導(dǎo)致程序出錯。如果用delete釋放“malloc申請的動態(tài)內(nèi)存”,結(jié)果也會導(dǎo)致程序出錯,但是該程序的可讀性很差。所以new/delete必須配對使用,malloc/free也一樣。
1.1.8 內(nèi)存耗盡怎么辦?
如果在申請動態(tài)內(nèi)存時找不到足夠大的內(nèi)存塊,malloc和new將返回NULL指針,宣告內(nèi)存申請失敗。通常有三種方式處理“內(nèi)存耗盡”問題。
。1)判斷指針是否為NULL,如果是則馬上用return語句終止本函數(shù)。例如:
void Func(void) { A *a = new A; if(a == NULL) { return; } … } |
。2)判斷指針是否為NULL,如果是則馬上用exit(1)終止整個程序的運(yùn)行。例如:
void Func(void) { A *a = new A; if(a == NULL) { cout << “Memory Exhausted” << endl; exit(1); } … } |
。3)為new和malloc設(shè)置異常處理函數(shù)。例如Visual C++可以用_set_new_hander函數(shù)為new設(shè)置用戶自己定義的異常處理函數(shù),也可以讓malloc享用與new相同的異常處理函數(shù)。詳細(xì)內(nèi)容請參考C++使用手冊。
上述(1)(2)方式使用最普遍。如果一個函數(shù)內(nèi)有多處需要申請動態(tài)內(nèi)存,那么方式(1)就顯得力不從心(釋放內(nèi)存很麻煩),應(yīng)該用方式(2)來處理。
很多人不忍心用exit(1),問:“不編寫出錯處理程序,讓操作系統(tǒng)自己解決行不行?”
不行。如果發(fā)生“內(nèi)存耗盡”這樣的事情,一般說來應(yīng)用程序已經(jīng)無藥可救。如果不用exit(1) 把壞程序殺死,它可能會害死操作系統(tǒng)。道理如同:如果不把歹徒擊斃,歹徒在老死之前會犯下更多的罪。
有一個很重要的現(xiàn)象要告訴大家。對于32位以上的應(yīng)用程序而言,無論怎樣使用malloc與new,幾乎不可能導(dǎo)致“內(nèi)存耗盡”。我在Windows 98下用Visual C++編寫了測試程序,見示例7。這個程序會無休止地運(yùn)行下去,根本不會終止。因為32位操作系統(tǒng)支持“虛存”,內(nèi)存用完了,自動用硬盤空間頂替。我只聽到硬盤嘎吱嘎吱地響,Window 98已經(jīng)累得對鍵盤、鼠標(biāo)毫無反應(yīng)。
我可以得出這么一個結(jié)論:對于32位以上的應(yīng)用程序,“內(nèi)存耗盡”錯誤處理程序毫無用處。這下可把Unix和Windows程序員們樂壞了:反正錯誤處理程序不起作用,我就不寫了,省了很多麻煩。
我不想誤導(dǎo)讀者,必須強(qiáng)調(diào):不加錯誤處理將導(dǎo)致程序的質(zhì)量很差,千萬不可因小失大。
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的使用要點(diǎn)
函數(shù)malloc的原型如下:
void * malloc(size_t size); |
用malloc申請一塊長度為length的整數(shù)類型的內(nèi)存,程序如下:
int *p = (int *) malloc(sizeof(int) * length); |
我們應(yīng)當(dāng)把注意力集中在兩個要素上:“類型轉(zhuǎn)換”和“sizeof”。
* malloc返回值的類型是void *,所以在調(diào)用malloc時要顯式地進(jìn)行類型轉(zhuǎn)換,將void * 轉(zhuǎn)換成所需要的指針類型。
* malloc函數(shù)本身并不識別要申請的內(nèi)存是什么類型,它只關(guān)心內(nèi)存的總字節(jié)數(shù)。我們通常記不住int, float等數(shù)據(jù)類型的變量的確切字節(jié)數(shù)。例如int變量在16位系統(tǒng)下是2個字節(jié),在32位下是4個字節(jié);而float變量在16位系統(tǒng)下是4個字節(jié),在32位下也是4個字節(jié)。最好用以下程序作一次測試:
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運(yùn)算符是良好的風(fēng)格,但要當(dāng)心有時我們會昏了頭,寫出 p = malloc(sizeof(p))這樣的程序來。
函數(shù)free的原型如下:
void free( void * memblock ); |
為什么free函數(shù)不象malloc函數(shù)那樣復(fù)雜呢?這是因為指針p的類型以及它所指的內(nèi)存的容量事先都是知道的,語句free(p)能正確地釋放內(nèi)存。如果p是NULL指針,那么free對p無論操作多少次都不會出問題。如果p不是NULL指針,那么free對p連續(xù)操作兩次就會導(dǎo)致程序運(yùn)行錯誤。
1.1.10 new/delete的使用要點(diǎn)
運(yùn)算符new使用起來要比函數(shù)malloc簡單得多,例如:
int *p1 = (int *)malloc(sizeof(int) * length); int *p2 = new int[length]; |
這是因為new內(nèi)置了sizeof、類型轉(zhuǎn)換和類型安全檢查功能。對于非內(nèi)部數(shù)據(jù)類型的對象而言,new在創(chuàng)建動態(tài)對象的同時完成了初始化工作。如果對象有多個構(gòu)造函數(shù),那么new的語句也可以有多種形式。例如
class Obj { public : Obj(void); // 無參數(shù)的構(gòu)造函數(shù) Obj(int x); // 帶一個參數(shù)的構(gòu)造函數(shù) … } void Test(void) { Obj *a = new Obj; Obj *b = new Obj(1); // 初值為1 … delete a; delete b; } |
如果用new創(chuàng)建對象數(shù)組,那么只能使用對象的無參數(shù)構(gòu)造函數(shù)。例如:
Obj *objects = new Obj[100]; // 創(chuàng)建100個動態(tài)對象 |
不能寫成:
Obj *objects = new Obj[100](1);// 創(chuàng)建100個動態(tài)對象的同時賦初值1 |
在用delete釋放對象數(shù)組時,留意不要丟了符號‘[]’。例如:
delete []objects; // 正確的用法 delete objects; // 錯誤的用法 |
后者有可能引起程序崩潰和內(nèi)存泄漏。
1.2 C++中的健壯指針和資源管理
我最喜歡的對資源的定義是:"任何在你的程序中獲得并在此后釋放的東西?quot;內(nèi)存是一個相當(dāng)明顯的資源的例子。它需要用new來獲得,用delete來釋放。同時也有許多其它類型的資源文件句柄、重要的片斷、Windows中的GDI資源,等等。將資源的概念推廣到程序中創(chuàng)建、釋放的所有對象也是十分方便的,無論對象是在堆中分配的還是在棧中或者是在全局作用于內(nèi)生命的。
對于給定的資源的擁有著,是負(fù)責(zé)釋放資源的一個對象或者是一段代碼。所有權(quán)分立為兩種級別——自動的和顯式的(automatic and explicit),如果一個對象的釋放是由語言本身的機(jī)制來保證的,這個對象的就是被自動地所有。例如,一個嵌入在其他對象中的對象,他的清除需要其他對象來在清除的時候保證。外面的對象被看作嵌入類的所有者。 類似地,每個在棧上創(chuàng)建的對象(作為自動變量)的釋放(破壞)是在控制流離開了對象被定義的作用域的時候保證的。這種情況下,作用于被看作是對象的所有者。注意所有的自動所有權(quán)都是和語言的其他機(jī)制相容的,包括異常。無論是如何退出作用域的——正常流程控制退出、一個break語句、一個return、一個goto、或者是一個throw——自動資源都可以被清除。
到目前為止,一切都很好!問題是在引入指針、句柄和抽象的時候產(chǎn)生的。如果通過一個指針訪問一個對象的話,比如對象在堆中分配,C++不自動地關(guān)注它的釋放。程序員必須明確的用適當(dāng)?shù)某绦蚍椒▉磲尫胚@些資源。比如說,如果一個對象是通過調(diào)用new來創(chuàng)建的,它需要用delete來回收。一個文件是用CreateFile(Win32 API)打開的,它需要用CloseHandle來關(guān)閉。用EnterCritialSection進(jìn)入的臨界區(qū)(Critical Section)需要LeaveCriticalSection退出,等等。一個"裸"指針,文件句柄,或者臨界區(qū)狀態(tài)沒有所有者來確保它們的最終釋放;镜馁Y源管理的前提就是確保每個資源都有他們的所有者。
1.2.1 第一條規(guī)則(RAII)
一個指針,一個句柄,一個臨界區(qū)狀態(tài)只有在我們將它們封裝入對象的時候才會擁有所有者。這就是我們的第一規(guī)則:在構(gòu)造函數(shù)中分配資源,在析構(gòu)函數(shù)中釋放資源。
當(dāng)你按照規(guī)則將所有資源封裝的時候,你可以保證你的程序中沒有任何的資源泄露。這點(diǎn)在當(dāng)封裝對象(Encapsulating Object) 在棧中建立或者嵌入在其他的對象中的時候非常明顯。但是對那些動態(tài)申請的對象呢?不要急!任何動態(tài)申請的東西都被看作一種資源,并且要按照上面提到的方法 進(jìn)行封裝。這一對象封裝對象的鏈不得不在某個地方終止。它最終終止在最高級的所有者,自動的或者是靜態(tài)的。這些分別是對離開作用域或者程序時釋放資源的保 證。
下面是資源封裝的一個經(jīng)典例子。在一個多線程的應(yīng)用程序中,線程之間共享對象的問題是通過用這樣一個對象聯(lián)系臨界區(qū)來解決的。每一個需要訪問共享資源的客戶需要獲得臨界區(qū)。例如,這可能是Win32下臨界區(qū)的實現(xiàn)方法。
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; }; |
這里聰明的部分是我們確保每一個進(jìn)入臨界區(qū)的客戶最后都可以離開。"進(jìn)入"臨界區(qū)的狀態(tài)是一種資源,并應(yīng)當(dāng)被封裝。封裝器通常被稱作一個鎖(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 } |
注意無論發(fā)生什么,臨界區(qū)都會借助于語言的機(jī)制保證釋放。
還有一件需要記住的事情——每一種資源都需要被分別封裝。這是因為資源分配是一 個非常容易出錯的操作,是要資源是有限提供的。我們會假設(shè)一個失敗的資源分配會導(dǎo)致一個異!聦嵣希@會經(jīng)常的發(fā)生。所以如果你想試圖用一個石頭打兩 只鳥的話,或者在一個構(gòu)造函數(shù)中申請兩種形式的資源,你可能就會陷入麻煩。只要想想在一種資源分配成功但另一種失敗拋出異常時會發(fā)生什么。因為構(gòu)造函數(shù)還 沒有全部完成,析構(gòu)函數(shù)不可能被調(diào)用,第一種資源就會發(fā)生泄露。
這種情況可以非常簡單的避免。無論何時你有一個需要兩種以上資源的類時,寫兩個小的封裝器將它們嵌入你的類中。每一個嵌入的構(gòu)造都可以保證刪除,即使包裝類沒有構(gòu)造完成。
1.2.2 Smart Pointers
我們至今還沒有討論最常見類型的資源——用操作符new分配,此后用指針訪問的一個對象。我們需要為每個對象分別定義一個封裝類嗎?(事實上,C++標(biāo)準(zhǔn)模板庫已經(jīng)有了一個模板類,叫做auto_ptr,其作用就是提供這種封裝。我們一會兒在回到auto_ptr。)讓我們從一個極其簡單、呆板但安全的東西開始?聪旅娴腟mart Pointer模板類,它十分堅固,甚至無法實現(xiàn)。
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的構(gòu)造函數(shù)設(shè)計為protected呢?如果我需要遵守第一條規(guī)則,那么我就必須這樣做。資源——在這里是class T的一個對象——必須在封裝器的構(gòu)造函數(shù)中分配。但是我不能只簡單的調(diào)用new T,因為我不知道T的構(gòu)造函數(shù)的參數(shù)。因為,在原則上,每一個T都有一個不同的構(gòu)造函數(shù);我需要為他定義個另外一個封裝器。模板的用處會很大,為每一個新的類,我可以通過繼承SmartPointer定義一個新的封裝器,并且提供一個特定的構(gòu)造函數(shù)。
class SmartItem: public SmartPointer<Item> { public: explicit SmartItem (int i) : SmartPointer<Item> (new Item (i)) {} }; |
為每一個類提供一個Smart Pointer真的值得嗎?說實話——不!他很有教學(xué)的價值,但是一旦你學(xué)會如何遵循第一規(guī)則的話,你就可以放松規(guī)則并使用一些高級的技術(shù)。這一技術(shù)是讓SmartPointer的構(gòu)造函數(shù)成為public,但是只是是用它來做資源轉(zhuǎn)換(Resource Transfer)我的意思是用new操作符的結(jié)果直接作為SmartPointer的構(gòu)造函數(shù)的參數(shù),像這樣:
SmartPointer<Item> item (new Item (i)); |
這個方法明顯更需要自控性,不只是你,而且包括你的程序小組的每個成員。他們都必須發(fā)誓出了作資源轉(zhuǎn)換外不把構(gòu)造函數(shù)用在人以其他用途。幸運(yùn)的是,這條規(guī)矩很容易得以加強(qiáng)。只需要在源文件中查找所有的new即可。
1.2.3 Resource Transfer
到目前為止,我們所討論的一直是生命周期在一個單獨(dú)的作用域內(nèi)的資源,F(xiàn)在我們 要解決一個困難的問題——如何在不同的作用域間安全的傳遞資源。這一問題在當(dāng)你處理容器的時候會變得十分明顯。你可以動態(tài)的創(chuàng)建一串對象,將它們存放至一 個容器中,然后將它們?nèi)〕,并且在最終安排它們。為了能夠讓這安全的工作——沒有泄露——對象需要改變其所有者。
這個問題的一個非常顯而易見的解決方法是使用Smart Pointer,無論是在加入容器前還是還找到它們以后。這是他如何運(yùn)作的,你加入Release方法到Smart Pointer中:
template <class T> T * SmartPointer<T>::Release () { T * pTmp = _p; _p = 0; return pTmp; } |
注意在Release調(diào)用以后,Smart Pointer就不再是對象的所有者了——它內(nèi)部的指針指向空,F(xiàn)在,調(diào)用了Release都必須是一個負(fù)責(zé)的人并且迅速隱藏返回的指針到新的所有者對象中。在我們的例子中,容器調(diào)用了Release,比如這個Stack的例子:
void Stack::Push (SmartPointer <Item> & item) throw (char *) { if (_top == maxStack) throw "Stack overflow"; _arr [_top++] = item.Release (); }; |
同樣的,你也可以再你的代碼中用加強(qiáng)Release的可靠性。
相應(yīng)的Pop方法要做些什么呢?他應(yīng)該釋放了資源并祈禱調(diào)用它的是一個負(fù)責(zé)的人而且立即作一個資源傳遞它到一個Smart Pointer?這聽起來并不好。
1.2.4 Strong Pointers
資源管理在內(nèi)容索引(Windows NT Server上的一部分,現(xiàn)在是Windows 2000)上工作,并且,我對這十分滿意。然后我開始想……這一方法是在這樣一個完整的系統(tǒng)中形成的,如果可以把它內(nèi)建入語言的本身豈不是一件非常好?我提出了強(qiáng)指針(Strong Pointer)和弱指針(Weak Pointer)。一個Strong Pointer會在許多地方和我們這個SmartPointer相似--它在超出它的作用域后會清除他所指向的對象。資源傳遞會以強(qiáng)指針賦值的形式進(jìn)行。也可以有Weak Pointer存在,它們用來訪問對象而不需要所有對象--比如可賦值的引用。
任何指針都必須聲明為Strong或者Weak,并且語言應(yīng)該來關(guān)注類型轉(zhuǎn)換的規(guī)定。例如,你不可以將Weak Pointer傳遞到一個需要Strong Pointer的地方,但是相反卻可以。Push方法可以接受一個Strong Pointer并且將它轉(zhuǎn)移到Stack中的Strong Pointer的序列中。Pop方法將會返回一個Strong Pointer。把Strong Pointer的引入語言將會使垃圾回收成為歷史。
這里還有一個小問題--修改C++標(biāo)準(zhǔn)幾乎和競選美國總統(tǒng)一樣容易。當(dāng)我將我的注意告訴給Bjarne Stroutrup的時候,他看我的眼神好像是我剛剛要向他借一千美元一樣。
然后我突然想到一個念頭。我可以自己實現(xiàn)Strong Pointers。畢竟,它們都很想Smart Pointers。給它們一個拷貝構(gòu)造函數(shù)并重載賦值操作符并不是一個大問題。事實上,這正是標(biāo)準(zhǔn)庫中的auto_ptr有的。重要的是對這些操作給出一個資源轉(zhuǎn)移的語法,但是這也不是很難。
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的新的實現(xiàn):
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方法強(qiáng)制客戶將其返回值賦給一個Strong Pointer,SmartPointer<Item>。任何試圖將他對一個普通指針的賦值都會產(chǎn)生一個編譯期錯誤,因為類型不匹配。此外,因為Pop以值方式返回一個Strong Pointer(在Pop的聲明時SmartPointer<Item>后面沒有&符號),編譯器在return時自動進(jìn)行了一個資源轉(zhuǎn)換。他調(diào)用了operator =來從數(shù)組中提取一個Item,拷貝構(gòu)造函數(shù)將他傳遞給調(diào)用者。調(diào)用者最后擁有了指向Pop賦值的Strong Pointer指向的一個Item。
我馬上意識到我已經(jīng)在某些東西之上了。我開始用了新的方法重寫原來的代碼。
1.2.5 Parser
我過去有一個老的算術(shù)操作分析器,是用老的資源管理的技術(shù)寫的。分析器的作用是在分析樹中生成節(jié)點(diǎn),節(jié)點(diǎn)是動態(tài)分配的。例如分析器的Expression方法生成一個表達(dá)式節(jié)點(diǎn)。我沒有時間用Strong Pointer去重寫這個分析器。我令Expression、Term和Factor方法以傳值的方式將Strong Pointer返回到Node中?聪旅娴腅xpression方法的實現(xiàn):
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方法被調(diào)用。他傳值返回一個指向Node的Strong Pointer并且立刻把它保存到我們自己的Strong Pointer,pNode中。如果下一個符號不是加號或者減號,我們就簡單的把這個SmartPointer以值返回,這樣就釋放了Node的所有權(quán)。另外一方面,如果下一個符號是加號或者減號,我們創(chuàng)建一個新的SumMode并且立刻(直接傳遞)將它儲存到MultiNode的一個Strong Pointer中。這里,SumNode是從MultiMode中繼承而來的,而MulitNode是從Node繼承而來的。原來的Node的所有權(quán)轉(zhuǎn)給了SumNode。
只要是他們在被加號和減號分開的時候,我們就不斷的創(chuàng)建terms,我們將這些term轉(zhuǎn)移到我們的MultiNode中,同時MultiNode得到了所有權(quán)。最后,我們將指向MultiNode的Strong Pointer向上映射為指向Mode的Strong Pointer,并且將他返回調(diào)用著。
我們需要對Strong Pointers進(jìn)行顯式的向上映射,即使指針是被隱式的封裝。例如,一個MultiNode是一個Node,但是相同的is-a關(guān)系在SmartPointer<MultiNode>和SmartPointer<Node>之間并不存在,因為它們是分離的類(模板實例)并不存在繼承關(guān)系。up-cast模板是像下面這樣定義的:
template<class To, class From> inline SmartPointer<To> up_cast (SmartPointer<From> & from) { return SmartPointer<To> (from.Release ()); } |
如果你的編譯器支持新加入標(biāo)準(zhǔn)的成員模板(member template)的話,你可以為SmartPointer<T>定義一個新的構(gòu)造函數(shù)用來從接受一個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的話,賦值會導(dǎo)致一個編譯時刻錯誤。
std::auto_ptr |
后來我意識到在STL中的auto_ptr模板,就是我的Strong Pointer。在那時候還有許多的實現(xiàn)差異(auto_ptr的Release方法并不將內(nèi)部的指針清零--你的編譯器的庫很可能用的就是這種陳舊的實現(xiàn)),但是最后在標(biāo)準(zhǔn)被廣泛接受之前都被解決了。
1.2.6 Transfer Semantics
目前為止,我們一直在討論在C++程序中資源管理的方法。宗旨是將資源封裝到一些輕量級的類中,并由類負(fù)責(zé)它們的釋放。特別的是,所有用new操作符分配的資源都會被儲存并傳遞進(jìn)Strong Pointer(標(biāo)準(zhǔn)庫中的auto_ptr)的內(nèi)部。
這里的關(guān)鍵詞是傳遞(passing)。一個容器可以通過傳值返回一個Strong Pointer來安全的釋放資源。容器的客戶只能夠通過提供一個相應(yīng)的Strong Pointer來保存這個資源。任何一個將結(jié)果賦給一個"裸"指針的做法都立即會被編譯器發(fā)現(xiàn)。
auto_ptr<Item> item = stack.Pop (); // ok Item * p = stack.Pop (); // Error! Type mismatch. |
以傳值方式被傳遞的對象有value semantics 或者稱為 copy semantics。Strong Pointers是以值方式傳遞的--但是我們能說它們有copy semantics嗎?不是這樣的!它們所指向的對象肯定沒有被拷貝過。事實上,傳遞過后,源auto_ptr不在訪問原有的對象,并且目標(biāo)auto_ptr成為了對象的唯一擁有者(但是往往auto_ptr的舊的實現(xiàn)即使在釋放后仍然保持著對對象的所有權(quán))。自然而然的我們可以將這種新的行為稱作Transfer Semantics。
拷貝構(gòu)造函數(shù)(copy construcor)和賦值操作符定義了auto_ptr的Transfer Semantics,它們用了非const的auto_ptr引用作為它們的參數(shù)。
auto_ptr (auto_ptr<T> & ptr); auto_ptr & operator = (auto_ptr<T> & ptr); |
這是因為它們確實改變了他們的源--剝奪了對資源的所有權(quán)。
通過定義相應(yīng)的拷貝構(gòu)造函數(shù)和重載賦值操作符,你可以將Transfer Semantics加入到許多對象中。例如,許多Windows中的資源,比如動態(tài)建立的菜單或者位圖,可以用有Transfer Semantics的類來封裝。
1.2.7 Strong Vectors
標(biāo)準(zhǔn)庫只在auto_ptr中支持資源管理。甚至連最簡單的容器也不支持ownership semantics。你可能想將auto_ptr和標(biāo)準(zhǔn)容器組合到一起可能會管用,但是并不是這樣的。例如,你可能會這樣做,但是會發(fā)現(xiàn)你不能夠用標(biāo)準(zhǔn)的方法來進(jìn)行索引。
vector< auto_ptr<Item> > autoVector; |
這種建造不會編譯成功;
Item * item = autoVector [0]; |
另一方面,這會導(dǎo)致一個從autoVect到auto_ptr的所有權(quán)轉(zhuǎn)換:
auto_ptr<Item> item = autoVector [0]; |
我們沒有選擇,只能夠構(gòu)造我們自己的Strong Vector。最小的接口應(yīng)該如下:
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 (); }; |
你也許會發(fā)現(xiàn)一個非常防御性的設(shè)計態(tài)度。我決定不提供一個對vector的左值索引的訪問,取而代之,如果你想設(shè)定(set)一個值的話,你必須用assign或者assign_direct方法。我的觀點(diǎn)是,資源管理不應(yīng)該被忽視,同時,也不應(yīng)該在所有的地方濫用。在我的經(jīng)驗里,一個strong vector經(jīng)常被許多push_back方法充斥著。
Strong vector最好用一個動態(tài)的Strong Pointers的數(shù)組來實現(xiàn):
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>的數(shù)組,將所有的東西從老的書組類轉(zhuǎn)移出來,在其中交換,并且刪除原來的數(shù)組。
auto_vector的其他實現(xiàn)都是十分直接的,因為所有資源管理的復(fù)雜度都在auto_ptr中。例如,assign方法簡單的利用了重載的賦值操作符來刪除原有的對象并轉(zhuǎn)移資源到新的對象:
void assign (size_t i, auto_ptr<T> & p) { _arr [i] = p; } |
我已經(jīng)討論了push_back和pop_back方法。push_back方法傳值返回一個auto_ptr,因為它將所有權(quán)從auto_vector轉(zhuǎn)換到auto_ptr中。
對auto_vector的索引訪問是借助auto_ptr的get方法來實現(xiàn)的,get簡單的返回一個內(nèi)部指針。
T * operator [] (size_t i) { return _arr [i].get (); } |
沒有容器可以沒有iterator。我們需要一個iterator讓auto_vector看起來更像一個普通的指針向量。特別是,當(dāng)我們廢棄iterator的時候,我們需要的是一個指針而不是auto_ptr。我們不希望一個auto_vector的iterator在無意中進(jìn)行資源轉(zhuǎn)換。
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提供了標(biāo)準(zhǔn)的begin和end方法來找回iterator:
class auto_vector { public: typedef auto_iterator<T> iterator; iterator begin () { return _arr; } iterator end () { return _arr + _end; } }; |
你也許會問我們是否要利用資源管理重新實現(xiàn)每一個標(biāo)準(zhǔn)的容器?幸運(yùn)的是,不;事實是strong vector解決了大部分所有權(quán)的需求。當(dāng)你把你的對象都安全的放置到一個strong vector中,你可以用所有其它的容器來重新安排(weak)pointer。
設(shè)想,例如,你需要對一些動態(tài)分配的對象排序的時候。你將它們的指針保存到一個strong vector中。然后你用一個標(biāo)準(zhǔn)的vector來保存從strong vector中獲得的weak指針。你可以用標(biāo)準(zhǔn)的算法對這個vector進(jìn)行排序。這種中介vector叫做permutation vector。相似的,你也可以用標(biāo)準(zhǔn)的maps, priority queues, heaps, hash tables等等。
1.2.8 Code Inspection
如果你嚴(yán)格遵照資源管理的條款,你就不會再資源泄露或者兩次刪除的地方遇到麻煩。你也降低了訪問野指針的幾率。同樣的,遵循原有的規(guī)則,用delete刪除用new申請的德指針,不要兩次刪除一個指針。你也不會遇到麻煩。但是,那個是更好的注意呢?
這兩個方法有一個很大的不同點(diǎn)。就是和尋找傳統(tǒng)方法的bug相比,找到違反資源管理的規(guī)定要容易的多。后者僅需要一個代碼檢測或者一個運(yùn)行測試,而前者則在代碼中隱藏得很深,并需要很深的檢查。
設(shè)想你要做一段傳統(tǒng)的代碼的內(nèi)存泄露檢查。第一件事,你要做的就是grep所有在代碼中出現(xiàn)的new,你需要找出被分配空間地指針都作了什么。你需要確定導(dǎo)致刪除這個指針的所有的執(zhí)行路徑。你需要檢查break語句,過程返回,異常。原有的指針可能賦給另一個指針,你對這個指針也要做相同的事。
相比之下,對于一段用資源管理技術(shù)實現(xiàn)的代碼。你也用grep檢查所有的new,但是這次你只需要檢查鄰近的調(diào)用:
● 這是一個直接的Strong Pointer轉(zhuǎn)換,還是我們在一個構(gòu)造函數(shù)的函數(shù)體中?
● 調(diào)用的返回知是否立即保存到對象中,構(gòu)造函數(shù)中是否有可以產(chǎn)生異常的代碼。?
● 如果這樣的話析構(gòu)函數(shù)中時候有delete?
下一步,你需要用grep查找所有的release方法,并實施相同的檢查。
不同點(diǎn)是需要檢查、理解單個執(zhí)行路徑和只需要做一些本地的檢驗。這難道不是提醒你非結(jié)構(gòu)化的和結(jié)構(gòu)化的程序設(shè)計的不同嗎?原理上,你可以認(rèn)為你可以應(yīng)付goto,并且跟蹤所有的可能分支。另一方面,你可以將你的懷疑本地化為一段代碼。本地化在兩種情況下都是關(guān)鍵所在。
在資源管理中的錯誤模式也比較容易調(diào)試。最常見的bug是試圖訪問一個釋放過的strong pointer。這將導(dǎo)致一個錯誤,并且很容易跟蹤。
1.2.9 共享的所有權(quán)
為每一個程序中的資源都找出或者指定一個所有者是一件很容易的事情嗎?答案是出乎意料的,是!如果你發(fā)現(xiàn)了一些問題,這可能說明你的設(shè)計上存在問題。還有另一種情況就是共享所有權(quán)是最好的甚至是唯一的選擇。
共享的責(zé)任分配給被共享的對象和它的客戶(client)。一個共享資源必須為它的所有者保持一個引用計數(shù)。另一方面,所有者再釋放資源的時候必須通報共享對象。最后一個釋放資源的需要在最后負(fù)責(zé)free的工作。
最簡單的共享的實現(xiàn)是共享對象繼承引用計數(shù)的類RefCounted:
class RefCounted { public: RefCounted () : _count (1) {} int GetRefCount () const { return _count; } void IncRefCount () { _count++; } int DecRefCount () { return --_count; } private int _count; }; |
按照資源管理,一個引用計數(shù)是一種資源。如果你遵守它,你需要釋放它。當(dāng)你意識到這一事實的時候,剩下的就變得簡單了。簡單的遵循規(guī)則--再構(gòu)造函數(shù)中獲得引用計數(shù),在析構(gòu)函數(shù)中釋放。甚至有一個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的方法。當(dāng)然,一個便于使用的RefPtr需要有一個重載的指針訪問操作符。在RefPtr中加入轉(zhuǎn)換語義學(xué)(transfer semantics)是讀者的工作。
1.2.10 所有權(quán)網(wǎng)絡(luò)
鏈表是資源管理分析中的一個很有意思的例子。如果你選擇表成為鏈(link)的所有者的話,你會陷入實現(xiàn)遞歸的所有權(quán)。每一個link都是它的繼承者的所有者,并且,相應(yīng)的,余下的鏈表的所有者。下面是用smart pointer實現(xiàn)的一個表單元:
class Link { // ... private auto_ptr<Link> _next; }; 最好的方法是,將連接控制封裝到一個弄構(gòu)進(jìn)行資源轉(zhuǎn)換的類中。 對于雙鏈表呢?安全的做法是指明一個方向,如forward: class DoubleLink { // ... private DoubleLink *_prev; auto_ptr<DoubleLink> _next; }; |
注意不要創(chuàng)建環(huán)形鏈表。
這給我們帶來了另外一個有趣的問題--資源管理可以處理環(huán)形的所有權(quán)嗎?它可以,用一個mark-and-sweep的算法。這里是實現(xiàn)這種方法的一個例子:
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來實現(xiàn)方法IsBeingDeleted,就像從CyclPtr繼承。對特殊的所有權(quán)網(wǎng)絡(luò)普通化是十分直接的。
將原有代碼轉(zhuǎn)換為資源管理代碼
如果你是一個經(jīng)驗豐富的程序員,你一定會知道找資源的bug是一件浪費(fèi)時間的痛苦的經(jīng)歷。我不必說服你和你的團(tuán)隊花費(fèi)一點(diǎn)時間來熟悉資源管理是十分值得的。你可以立即開始用這個方法,無論你是在開始一個新項目或者是在一個項目的中期。轉(zhuǎn)換不必立即全部完成。下面是步驟。
(1) 首先,在你的工程中建立基本的Strong Pointer。然后通過查找代碼中的new來開始封裝裸指針。
(2) 最先封裝的是在過程中定義的臨時指針。簡單的將它們替換為auto_ptr并且刪除相應(yīng)的delete。如果一個指針在過程中沒有被刪除而是被返回,用auto_ptr替換并在返回前調(diào)用release方法。在你做第二次傳遞的時候,你需要處理對release的調(diào)用。注意,即使是在這點(diǎn),你的代碼也可能更加"精力充沛"--你會移出代碼中潛在的資源泄漏問題。
(3) 下面是指向資源的裸指針。確保它們被獨(dú)立的封裝到auto_ptr中,或者在構(gòu)造函數(shù)中分配在析構(gòu)函數(shù)中釋放。如果你有傳遞所有權(quán)的行為的話,需要調(diào)用release方法。如果你有容器所有對象,用Strong Pointers重新實現(xiàn)它們。
(4) 接下來,找到所有對release的方法調(diào)用并且盡力清除所有,如果一個release調(diào)用返回一個指針,將它修改傳值返回一個auto_ptr。
(5) 重復(fù)著一過程,直到最后所有new和release的調(diào)用都在構(gòu)造函數(shù)或者資源轉(zhuǎn)換的時候發(fā)生。這樣,你在你的代碼中處理了資源泄漏的問題。對其他資源進(jìn)行相似的操作。
(6) 你會發(fā)現(xiàn)資源管理清除了許多錯誤和異常處理帶來的復(fù)雜性。不僅僅你的代碼會變得精力充沛,它也會變得簡單并容易維護(hù)。
2 內(nèi)存泄漏
2.1 C++中動態(tài)內(nèi)存分配引發(fā)問題的解決方案
假設(shè)我們要開發(fā)一個String類,它可以方便地處理字符串?dāng)?shù)據(jù)。我們可以在類中聲明一個數(shù)組,考慮到有時候字符串極長,我們可以把數(shù)組大小設(shè)為200,但一般的情況下又不需要這么多的空間,這樣是浪費(fèi)了內(nèi)存。對了,我們可以使用new操作符,這樣是十分靈活的,但在類中就會出現(xiàn)許多意想不到的問題,本文就是針對這一現(xiàn)象而寫的,F(xiàn)在,我們先來開發(fā)一個String類,但它是一個不完善的類。的確,我們要刻意地使它出現(xiàn)各種各樣的問題,這樣才好對癥下藥。好了,我們開始吧!
/* String.h */ #ifndef STRING_H_ #define STRING_H_ class String { private: char * str; //存儲數(shù)據(jù) int len; //字符串長度 public: String(const char * s); //構(gòu)造函數(shù) String(); // 默認(rèn)構(gòu)造函數(shù) ~String(); // 析構(gòu)函數(shù) 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); }//拷貝數(shù)據(jù) String::String() { len =0; str = new char[len+1]; str[0]='"0'; } String::~String() { cout<<"這個字符串將被刪除:"<<str<<'"n';//為了方便觀察結(jié)果,特留此行代碼。 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("天極網(wǎng)"); cout<<temp<<'"n'; system("PAUSE"); return 0; } |
運(yùn)行結(jié)果:
天極網(wǎng) 請按任意鍵繼續(xù). . . |
大家可以看到,以上程序十分正確,而且也是十分有用的?墒,我們不能被表面現(xiàn)象所迷惑!下面,請大家用test_String.cpp文件替換test_right.cpp文件進(jìn)行編譯,看看結(jié)果。有的編譯器可能就是根本不能進(jìn)行編譯!
test_String.cpp:
#include <iostream> #include <stdlib.h> #include "String.h" using namespace std; void show_right(const String&); void show_String(const String);//注意,參數(shù)非引用,而是按值傳遞。 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上沒有任何反應(yīng)。 cout<<"使用正確的函數(shù):"<<endl; show_right(test2); cout<<test2<<endl; cout<<"使用錯誤的函數(shù):"<<endl; show_String(test2); cout<<test2<<endl; //這一段代碼出現(xiàn)嚴(yán)重的錯誤! String String2(test3); cout<<"String2: "<<String2<<endl; String String3; String3=test4; cout<<"String3: "<<String3<<endl; cout<<"下面,程序結(jié)束,析構(gòu)函數(shù)將被調(diào)用。"<<endl; return 0; } void show_right(const String& a) { cout<<a<<endl; } void show_String(const String a) { cout<<a<<endl; } |
運(yùn)行結(jié)果:
下面分別輸入三個范例: 第一個范例。 第二個范例。 第三個范例。 第一個范例。 這個字符串將被刪除:第一個范例。 使用正確的函數(shù): 第二個范例。 第二個范例。 使用錯誤的函數(shù): 第二個范例。 這個字符串將被刪除:第二個范例。 這個字符串將被刪除:?= ?= String2: 第三個范例。 String3: 第四個范例。 下面,程序結(jié)束,析構(gòu)函數(shù)將被調(diào)用。 這個字符串將被刪除:第四個范例。 這個字符串將被刪除:第三個范例。 這個字符串將被刪除:?= 這個字符串將被刪除:x = 這個字符串將被刪除:?= 這個字符串將被刪除: |
現(xiàn)在,請大家自己試試運(yùn)行結(jié)果,或許會更加慘不忍睹呢!下面,我為大家一一分析原因。
首先,大家要知道,C++類有以下這些極為重要的函數(shù):
一:復(fù)制構(gòu)造函數(shù)。
二:賦值函數(shù)。
我們先來講復(fù)制構(gòu)造函數(shù)。什么是復(fù)制構(gòu)造函數(shù)呢?比如,我們可以寫下這樣的代碼:String test1(test2);這是進(jìn)行初始化。我們知道,初始化對象要用構(gòu)造函數(shù)?蛇@兒呢?按理說,應(yīng)該有聲明為這樣的構(gòu)造函數(shù):String(const String &);可是,我們并沒有定義這個構(gòu)造函數(shù)呀?答案是,C++提供了默認(rèn)的復(fù)制構(gòu)造函數(shù),問題也就出在這兒。
(1):什么時候會調(diào)用復(fù)制構(gòu)造函數(shù)呢?(以String類為例。)
在我們提供這樣的代碼:String test1(test2)時,它會被調(diào)用;當(dāng)函數(shù)的參數(shù)列表為按值傳遞,也就是沒有用引用和指針作為類型時,如:void show_String(const String),它會被調(diào)用。其實,還有一些情況,但在這兒就不列舉了。
(2):它是什么樣的函數(shù)。
它的作用就是把兩個類進(jìn)行復(fù)制。拿String類為例,C++提供的默認(rèn)復(fù)制構(gòu)造函數(shù)是這樣的:
String(const String& a) { str=a.str; len=a.len; } |
在平時,這樣并不會有任何的問題出現(xiàn),但我們用了new操 作符,涉及到了動態(tài)內(nèi)存分配,我們就不得不談?wù)劀\復(fù)制和深復(fù)制了。以上的函數(shù)就是實行的淺復(fù)制,它只是復(fù)制了指針,而并沒有復(fù)制指針指向的數(shù)據(jù),可謂一點(diǎn) 兒用也沒有。打個比方吧!就像一個朋友讓你把一個程序通過網(wǎng)絡(luò)發(fā)給他,而你大大咧咧地把快捷方式發(fā)給了他,有什么用處呢?我們來具體談?wù)劊?/p>
假如,A對象中存儲了這樣的字符串:“C++”。它的地址為2000,F(xiàn)在,我們把A對象賦給B對象:String B=A,F(xiàn)在,A和B對象的str指針均指向2000地址?此瓶梢允褂茫绻鸅對象的析構(gòu)函數(shù)被調(diào)用時,則地址2000處的字符串“C++”已經(jīng)被從內(nèi)存中抹去,而A對象仍然指向地址2000。這時,如果我們寫下這樣的代碼:cout<<A<<endl;或是等待程序結(jié)束,A對象的析構(gòu)函數(shù)被調(diào)用時,A對象的數(shù)據(jù)能否顯示出來呢?只會是亂碼。而且,程序還會這樣做:連續(xù)對地址2000處使用兩次delete操作符,這樣的后果是十分嚴(yán)重的!
本例中,有這樣的代碼:
String* String1=new String(test1); cout<<*String1<<endl; delete String1; |
假設(shè)test1中str指向的地址為2000,而String中str指針同樣指向地址2000,我們刪除了2000處的數(shù)據(jù),而test1對象呢?已經(jīng)被破壞了。大家從運(yùn)行結(jié)果上可以看到,我們使用cout<<test1時,一點(diǎn)反應(yīng)也沒有。而在test1的析構(gòu)函數(shù)被調(diào)用時,顯示是這樣:“這個字符串將被刪除:”。
再看看這段代碼:
cout<<"使用錯誤的函數(shù):"<<endl; show_String(test2); cout<<test2<<endl;//這一段代碼出現(xiàn)嚴(yán)重的錯誤! |
show_String函數(shù)的參數(shù)列表void show_String(const String a)是按值傳遞的,所以,我們相當(dāng)于執(zhí)行了這樣的代碼:String a=test2;函數(shù)執(zhí)行完畢,由于生存周期的緣故,對象a被析構(gòu)函數(shù)刪除,我們馬上就可以看到錯誤的顯示結(jié)果了:這個字符串將被刪除:?=。當(dāng)然,test2也被破壞了。解決的辦法很簡單,當(dāng)然是手工定義一個復(fù)制構(gòu)造函數(shù)嘍!人力可以勝天!
String::String(const String& a) { len=a.len; str=new char(len+1); strcpy(str,a.str); } |
我們執(zhí)行的是深復(fù)制。這個函數(shù)的功能是這樣的:假設(shè)對象A中的str指針指向地址2000,內(nèi)容為“I am a C++ Boy!”。我們執(zhí)行代碼String B=A時,我們先開辟出一塊內(nèi)存,假設(shè)為3000。我們用strcpy函數(shù)將地址2000的內(nèi)容拷貝到地址3000中,再將對象B的str指針指向地址3000。這樣,就互不干擾了。
大家把這個函數(shù)加入程序中,問題就解決了大半,但還沒有完全解決,問題在賦值函數(shù)上。我們的程序中有這樣的段代碼:
String String3; String3=test4; |
經(jīng)過我前面的講解,大家應(yīng)該也會對這段代碼進(jìn)行尋根摸底:憑什么可以這樣做:String3=test4???原因是,C++為了用戶的方便,提供的這樣的一個操作符重載函數(shù):operator=。所以,我們可以這樣做。大家應(yīng)該猜得到,它同樣是執(zhí)行了淺復(fù)制,出了同樣的毛病。比如,執(zhí)行了這段代碼后,析構(gòu)函數(shù)開始大展神威^_^。由于這些變量是后進(jìn)先出的,所以最后的String3變量先被刪除:這個字符串將被刪除:第四個范例。很正常。最后,刪除到test4的時候,問題來了:這個字符串將被刪除:?=。原因我不用贅述了,只是這個賦值函數(shù)怎么寫,還有一點(diǎn)兒學(xué)問呢!大家請看:
平時,我們可以寫這樣的代碼:x=y=z。(均為整型變量。)而在類對象中,我們同樣要這樣,因為這很方便。而對象A=B=C就是A.operator=(B.operator=(c))。而這個operator=函數(shù)的參數(shù)列表應(yīng)該是:const String& a,所以,大家不難推出,要實現(xiàn)這樣的功能,返回值也要是String&,這樣才能實現(xiàn)A=B=C。我們先來寫寫看:
String& String::operator=(const String& a) { delete [] str;//先刪除自身的數(shù)據(jù) len=a.len; str=new char[len+1]; strcpy(str,a.str);//此三行為進(jìn)行拷貝 return *this;//返回自身的引用 } |
是不是這樣就行了呢?我們假如寫出了這種代碼:A=A,那么大家看看,豈不是把A對象的數(shù)據(jù)給刪除了嗎?這樣可謂引發(fā)一系列的錯誤。所以,我們還要檢查是否為自身賦值。只比較兩對象的數(shù)據(jù)是不行了,因為兩個對象的數(shù)據(jù)很有可能相同。我們應(yīng)該比較地址。以下是完好的賦值函數(shù):
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; } |
把這些代碼加入程序,問題就完全解決,下面是運(yùn)行結(jié)果:
下面分別輸入三個范例: 第一個范例 第二個范例 第三個范例 第一個范例 這個字符串將被刪除:第一個范例。 第一個范例 使用正確的函數(shù): 第二個范例。 第二個范例。 使用錯誤的函數(shù): 第二個范例。 這個字符串將被刪除:第二個范例。 第二個范例。 String2: 第三個范例。 String3: 第四個范例。 下面,程序結(jié)束,析構(gòu)函數(shù)將被調(diào)用。 這個字符串將被刪除:第四個范例。 這個字符串將被刪除:第三個范例。 這個字符串將被刪除:第四個范例。 這個字符串將被刪除:第三個范例。 這個字符串將被刪除:第二個范例。 這個字符串將被刪除:第一個范例。 |
2.2 如何對付內(nèi)存泄漏?
寫出那些不會導(dǎo)致任何內(nèi)存泄漏的代碼。很明顯,當(dāng)你的代碼中到處充滿了new 操作、delete操作和指針運(yùn)算的話,你將會在某個地方搞暈了頭,導(dǎo)致內(nèi)存泄漏,指針引用錯誤,以及諸如此類的問題。這和你如何小心地對待內(nèi)存分配工作其實完全沒有關(guān)系:代碼的復(fù)雜性最終總是會超過你能夠付出的時間和努力。于是隨后產(chǎn)生了一些成功的技巧,它們依賴于將內(nèi)存分配(allocations)與重新分配(deallocation)工作隱藏在易于管理的類型之后。標(biāo)準(zhǔn)容器(standard containers)是一個優(yōu)秀的例子。它們不是通過你而是自己為元素管理內(nèi)存,從而避免了產(chǎn)生糟糕的結(jié)果。想象一下,沒有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’; } |
你有多少機(jī)會在第一次就得到正確的結(jié)果?你又怎么知道你沒有導(dǎo)致內(nèi)存泄漏呢?
注意,沒有出現(xiàn)顯式的內(nèi)存管理,宏,造型,溢出檢查,顯式的長度限制,以及指針。通過使用函數(shù)對象和標(biāo)準(zhǔn)算法(standard algorithm),我可以避免使用指針——例如使用迭代子(iterator),不過對于一個這么小的程序來說有點(diǎn)小題大作了。
這些技巧并不完美,要系統(tǒng)化地使用它們也并不總是那么容易。但是,應(yīng)用它們產(chǎn)生了驚人的差異,而且通過減少顯式的內(nèi)存分配與重新分配的次數(shù),你甚至可以使余下的例子更加容易被跟蹤。早在1981年,我就指出,通過將我必須顯式地跟蹤的對象的數(shù)量從幾萬個減少到幾打,為了使程序正確運(yùn)行而付出的努力從可怕的苦工,變成了應(yīng)付一些可管理的對象,甚至更加簡單了。
如果你的程序還沒有包含將顯式內(nèi)存管理減少到最小限度的庫,那么要讓你程序完成和正確運(yùn)行的話,最快的途徑也許就是先建立一個這樣的庫。
模板和標(biāo)準(zhǔn)庫實現(xiàn)了容器、資源句柄以及諸如此類的東西,更早的使用甚至在多年以前。異常的使用使之更加完善。
如果你實在不能將內(nèi)存分配/重新分配的操作隱藏到你需要的對象中時,你可以使用資源句柄(resource handle),以將內(nèi)存泄漏的可能性降至最低。這里有個例子:我需要通過一個函數(shù),在空閑內(nèi)存中建立一個對象并返回它。這時候可能忘記釋放這個對象。畢竟,我們不能說,僅僅關(guān)注當(dāng)這個指針要被釋放的時候,誰將負(fù)責(zé)去做。使用資源句柄,這里用了標(biāo)準(zhǔn)庫中的auto_ptr,使需要為之負(fù)責(zé)的地方變得明確了。
#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; // 誰該負(fù)責(zé)釋放這個S? }; auto_ptr<S> g() { return auto_ptr<S>(new S); // 顯式傳遞負(fù)責(zé)釋放這個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產(chǎn)生了內(nèi)存泄漏 // *q被自動釋放 } |
在更一般的意義上考慮資源,而不僅僅是內(nèi)存。
如果在你的環(huán)境中不能系統(tǒng)地應(yīng)用這些技巧(例如,你必須使用別的地方的代碼,或者你的程序的另一部分簡直是原始人類(譯注:原文是Neanderthals,尼安德特人,舊石器時代廣泛分布在歐洲的猿人)寫的,如此等等),那么注意使用一個內(nèi)存泄漏檢測器作為開發(fā)過程的一部分,或者插入一個垃圾收集器(garbage collector)。
2.3淺談C/C++內(nèi)存泄漏及其檢測工具
對于一個c/c++程序員來說,內(nèi)存泄漏是一個常見的也是令人頭疼的問題。已經(jīng)有許多技術(shù)被研究出來以應(yīng)對這個問題,比如Smart Pointer,Garbage Collection等。Smart Pointer技術(shù)比較成熟,STL中已經(jīng)包含支持Smart Pointer的class,但是它的使用似乎并不廣泛,而且它也不能解決所有的問題;Garbage Collection技術(shù)在Java中已經(jīng)比較成熟,但是在c/c++領(lǐng)域的發(fā)展并不順暢,雖然很早就有人思考在C++中也加入GC的支持。現(xiàn)實世界就是這樣的,作為一個c/c++程序員,內(nèi)存泄漏是你心中永遠(yuǎn)的痛。不過好在現(xiàn)在有許多工具能夠幫助我們驗證內(nèi)存泄漏的存在,找出發(fā)生問題的代碼。
2.3.1 內(nèi)存泄漏的定義
一般我們常說的內(nèi)存泄漏是指堆內(nèi)存的泄漏。堆內(nèi)存是指程序從堆中分配的,大小任意的(內(nèi)存塊的大小可以在程序運(yùn)行期決定),使用完后必須顯示釋放的內(nèi)存。應(yīng)用程序一般使用malloc,realloc,new等函數(shù)從堆中分配到一塊內(nèi)存,使用完后,程序必須負(fù)責(zé)相應(yīng)的調(diào)用free或delete釋放該內(nèi)存塊,否則,這塊內(nèi)存就不能被再次使用,我們就說這塊內(nèi)存泄漏了。以下這段小程序演示了堆內(nèi)存發(fā)生泄漏的情形:
void MyFunction(int nSize) { char* p= new char[nSize]; if( !GetStringFrom( p, nSize ) ){ MessageBox(“Error”); return; } …//using the string pointed by p; delete p; } |
當(dāng)函數(shù)GetStringFrom()返回零的時候,指針p指向的內(nèi)存就不會被釋放。這是一種常見的發(fā)生內(nèi)存泄漏的情形。程序在入口處分配內(nèi)存,在出口處釋放內(nèi)存,但是c函數(shù)可以在任何地方退出,所以一旦有某個出口處沒有釋放應(yīng)該釋放的內(nèi)存,就會發(fā)生內(nèi)存泄漏。
廣義的說,內(nèi)存泄漏不僅僅包含堆內(nèi)存的泄漏,還包含系統(tǒng)資源的泄漏(resource leak),比如核心態(tài)HANDLE,GDI Object,SOCKET, Interface等,從根本上說這些由操作系統(tǒng)分配的對象也消耗內(nèi)存,如果這些對象發(fā)生泄漏最終也會導(dǎo)致內(nèi)存的泄漏。而且,某些對象消耗的是核心態(tài)內(nèi)存,這些對象嚴(yán)重泄漏時會導(dǎo)致整個操作系統(tǒng)不穩(wěn)定。所以相比之下,系統(tǒng)資源的泄漏比堆內(nèi)存的泄漏更為嚴(yán)重。
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; } |
當(dāng)函數(shù)Something()返回非零的時候,程序在退出前沒有把pOldBmp選回pDC中,這會導(dǎo)致pOldBmp指向的HBITMAP對象發(fā)生泄漏。這個程序如果長時間的運(yùn)行,可能會導(dǎo)致整個系統(tǒng)花屏。這種問題在Win9x下比較容易暴露出來,因為Win9x的GDI堆比Win2k或NT的要小很多。
2.3.2 內(nèi)存泄漏的發(fā)生方式
以發(fā)生的方式來分類,內(nèi)存泄漏可以分為4類:
1. 常發(fā)性內(nèi)存泄漏。發(fā)生內(nèi)存泄漏的代碼會被多次執(zhí)行到,每次被執(zhí)行的時候都會導(dǎo)致一塊內(nèi)存泄漏。比如例二,如果Something()函數(shù)一直返回True,那么pOldBmp指向的HBITMAP對象總是發(fā)生泄漏。
2. 偶發(fā)性內(nèi)存泄漏。發(fā)生內(nèi)存泄漏的代碼只有在某些特定環(huán)境或操作過程下才會發(fā)生。比如例二,如果Something()函數(shù)只有在特定環(huán)境下才返回True,那么pOldBmp指向的HBITMAP對象并不總是發(fā)生泄漏。常發(fā)性和偶發(fā)性是相對的。對于特定的環(huán)境,偶發(fā)性的也許就變成了常發(fā)性的。所以測試環(huán)境和測試方法對檢測內(nèi)存泄漏至關(guān)重要。
3. 一次性內(nèi)存泄漏。發(fā)生內(nèi)存泄漏的代碼只會被執(zhí)行一次,或者由于算法上的缺陷,導(dǎo)致總會有一塊僅且一塊內(nèi)存發(fā)生泄漏。比如,在類的構(gòu)造函數(shù)中分配內(nèi)存,在析構(gòu)函數(shù)中卻沒有釋放該內(nèi)存,但是因為這個類是一個Singleton,所以內(nèi)存泄漏只會發(fā)生一次。另一個例子:
char* g_lpszFileName = NULL; void SetFileName( const char* lpcszFileName ) { if( g_lpszFileName ){ free( g_lpszFileName ); } g_lpszFileName = strdup( lpcszFileName ); } |
如果程序在結(jié)束的時候沒有釋放g_lpszFileName指向的字符串,那么,即使多次調(diào)用SetFileName(),總會有一塊內(nèi)存,而且僅有一塊內(nèi)存發(fā)生泄漏。
4. 隱式內(nèi)存泄漏。程序在運(yùn)行過程中不停的分配內(nèi)存,但是直到結(jié)束的時候才釋放內(nèi)存。嚴(yán)格的說這里并沒有發(fā)生內(nèi)存泄漏,因為最終程序釋放了所有申請的內(nèi)存。但是對于一個服務(wù)器程序,需要運(yùn)行幾天,幾周甚至幾個月,不及時釋放內(nèi)存也可能導(dǎo)致最終耗盡系統(tǒng)的所有內(nèi)存。所以,我們稱這類內(nèi)存泄漏為隱式內(nèi)存泄漏。舉一個例子:
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; }; |
假設(shè)在Client從Server端斷開后,Server并沒有呼叫OnClientDisconnected()函數(shù),那么代表那次連接的Connection對象就不會被及時的刪除(在Server程序退出的時候,所有Connection對象會在ConnectionManager的析構(gòu)函數(shù)里被刪除)。當(dāng)不斷的有連接建立、斷開時隱式內(nèi)存泄漏就發(fā)生了。
從用戶使用程序的角度來看,內(nèi)存泄漏本身不會產(chǎn)生什么危害,作為一般的用戶,根本感覺不到內(nèi)存泄漏的存在。真正有危害的是內(nèi)存泄漏的堆積,這會最終消耗盡系統(tǒng)所有的內(nèi)存。從這個角度來說,一次性內(nèi)存泄漏并沒有什么危害,因為它不會堆積,而隱式內(nèi)存泄漏危害性則非常大,因為較之于常發(fā)性和偶發(fā)性內(nèi)存泄漏它更難被檢測到。
2.3.3 檢測內(nèi)存泄漏
檢測內(nèi)存泄漏的關(guān)鍵是要能截獲住對分配內(nèi)存和釋放內(nèi)存的函數(shù)的調(diào)用。截獲住這兩個函數(shù),我們就能跟蹤每一塊內(nèi)存的生命周期,比如,每當(dāng)成功的分配一塊內(nèi)存后,就把它的指針加入一個全局的list中;每當(dāng)釋放一塊內(nèi)存,再把它的指針從list中刪除。這樣,當(dāng)程序結(jié)束的時候,list中剩余的指針就是指向那些沒有被釋放的內(nèi)存。這里只是簡單的描述了檢測內(nèi)存泄漏的基本原理,詳細(xì)的算法可以參見Steve Maguire的<<Writing Solid Code>>。
如果要檢測堆內(nèi)存的泄漏,那么需要截獲住malloc/realloc/free和new/delete就可以了(其實new/delete最終也是用malloc/free的,所以只要截獲前面一組即可)。對于其他的泄漏,可以采用類似的方法,截獲住相應(yīng)的分配和釋放函數(shù)。比如,要檢測BSTR的泄漏,就需要截獲SysAllocString/SysFreeString;要檢測HMENU的泄漏,就需要截獲CreateMenu/ DestroyMenu。(有的資源的分配函數(shù)有多個,釋放函數(shù)只有一個,比如,SysAllocStringLen也可以用來分配BSTR,這時就需要截獲多個分配函數(shù))
在Windows平臺下,檢測內(nèi)存泄漏的工具常用的一般有三種,MS C-Runtime Library內(nèi)建的檢測功能;外掛式的檢測工具,諸如,Purify,BoundsChecker等;利用Windows NT自帶的Performance Monitor。這三種工具各有優(yōu)缺點(diǎn),MS C-Runtime Library雖然功能上較之外掛式的工具要弱,但是它是免費(fèi)的;Performance Monitor雖然無法標(biāo)示出發(fā)生問題的代碼,但是它能檢測出隱式的內(nèi)存泄漏的存在,這是其他兩類工具無能為力的地方。
以下我們詳細(xì)討論這三種檢測工具:
2.3.3.1 VC下內(nèi)存泄漏的檢測方法
用MFC開發(fā)的應(yīng)用程序,在DEBUG版模式下編譯后,都會自動加入內(nèi)存泄漏的檢測代碼。在程序結(jié)束后,如果發(fā)生了內(nèi)存泄漏,在Debug窗口中會顯示出所有發(fā)生泄漏的內(nèi)存塊的信息,以下兩行顯示了一塊被泄漏的內(nèi)存塊的信息:
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
第一行顯示該內(nèi)存塊由TestDlg.cpp文件,第70行代碼分配,地址在0x00881710,大小為200字節(jié),{59}是指調(diào)用內(nèi)存分配函數(shù)的Request Order,關(guān)于它的詳細(xì)信息可以參見MSDN中_CrtSetBreakAlloc()的幫助。第二行顯示該內(nèi)存塊前16個字節(jié)的內(nèi)容,尖括號內(nèi)是以ASCII方式顯示,接著的是以16進(jìn)制方式顯示。
一般大家都誤以為這些內(nèi)存泄漏的檢測功能是由MFC提供的,其實不然。MFC只是封裝和利用了MS C-Runtime Library的Debug Function。非MFC程序也可以利用MS C-Runtime Library的Debug Function加入內(nèi)存泄漏的檢測功能。MS C-Runtime Library在實現(xiàn)malloc/free,strdup等函數(shù)時已經(jīng)內(nèi)建了內(nèi)存泄漏的檢測功能。
注意觀察一下由MFC Application Wizard生成的項目,在每一個cpp文件的頭部都有這樣一段宏定義:
#ifdef _DEBUG #define new DEBUG_NEW #undef THIS_FILE static char THIS_FILE[] = __FILE__; #endif |
有了這樣的定義,在編譯DEBUG版時,出現(xiàn)在這個cpp文件中的所有new都被替換成DEBUG_NEW了。那么DEBUG_NEW是什么呢?DEBUG_NEW也是一個宏,以下摘自afx.h,1632行
#define DEBUG_NEW new(THIS_FILE, __LINE__) |
所以如果有這樣一行代碼:
char* p = new char[200]; |
經(jīng)過宏替換就變成了:
char* p = new( THIS_FILE, __LINE__)char[200]; |
根據(jù)C++的標(biāo)準(zhǔn),對于以上的new的使用方法,編譯器會去找這樣定義的operator new:
void* operator new(size_t, LPCSTR, int) |
我們在afxmem.cpp 63行找到了一個這樣的operator new 的實現(xiàn)
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函數(shù)比較長,為了簡單期間,我只摘錄了部分。很顯然最后的內(nèi)存分配還是通過_malloc_dbg函數(shù)實現(xiàn)的,這個函數(shù)屬于MS C-Runtime Library 的Debug Function。這個函數(shù)不但要求傳入內(nèi)存的大小,另外還有文件名和行號兩個參數(shù)。文件名和行號就是用來記錄此次分配是由哪一段代碼造成的。如果這塊內(nèi)存在程序結(jié)束之前沒有被釋放,那么這些信息就會輸出到Debug窗口里。
這里順便提一下THIS_FILE,__FILE和__LINE__。__FILE__和__LINE__都是編譯器定義的宏。當(dāng)碰到__FILE__時,編譯器會把__FILE__替換成一個字符串,這個字符串就是當(dāng)前在編譯的文件的路徑名。當(dāng)碰到__LINE__時,編譯器會把__LINE__替換成一個數(shù)字,這個數(shù)字就是當(dāng)前這行代碼的行號。在DEBUG_NEW的定義中沒有直接使用__FILE__,而是用了THIS_FILE,其目的是為了減小目標(biāo)文件的大小。假設(shè)在某個cpp文件中有100處使用了new,如果直接使用__FILE__,那編譯器會產(chǎn)生100個常量字符串,這100個字符串都是飧?/SPAN>cpp文件的路徑名,顯然十分冗余。如果使用THIS_FILE,編譯器只會產(chǎn)生一個常量字符串,那100處new的調(diào)用使用的都是指向常量字符串的指針。
再次觀察一下由MFC Application Wizard生成的項目,我們會發(fā)現(xiàn)在cpp文件中只對new做了映射,如果你在程序中直接使用malloc函數(shù)分配內(nèi)存,調(diào)用malloc的文件名和行號是不會被記錄下來的。如果這塊內(nèi)存發(fā)生了泄漏,MS C-Runtime Library仍然能檢測到,但是當(dāng)輸出這塊內(nèi)存塊的信息,不會包含分配它的的文件名和行號。
要在非MFC程序中打開內(nèi)存泄漏的檢測功能非常容易,你只要在程序的入口處加入以下幾行代碼:
int tmpFlag = _CrtSetDbgFlag( _CRTDBG_REPORT_FLAG ); tmpFlag |= _CRTDBG_LEAK_CHECK_DF; _CrtSetDbgFlag( tmpFlag ); |
這樣,在程序結(jié)束的時候,也就是winmain,main或dllmain函數(shù)返回之后,如果還有內(nèi)存塊沒有釋放,它們的信息會被打印到Debug窗口里。
如果你試著創(chuàng)建了一個非MFC應(yīng)用程序,而且在程序的入口處加入了以上代碼,并且故意在程序中不釋放某些內(nèi)存塊,你會在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 |
內(nèi)存泄漏的確檢測到了,但是和上面MFC程序的例子相比,缺少了文件名和行號。對于一個比較大的程序,沒有這些信息,解決問題將變得十分困難。
為了能夠知道泄漏的內(nèi)存塊是在哪里分配的,你需要實現(xiàn)類似MFC的映射功能,把new,maolloc等函數(shù)映射到_malloc_dbg函數(shù)上。這里我不再贅述,你可以參考MFC的源代碼。
由于Debug Function實現(xiàn)在MS C-RuntimeLibrary中,所以它只能檢測到堆內(nèi)存的泄漏,而且只限于malloc,realloc或strdup等分配的內(nèi)存,而那些系統(tǒng)資源,比如HANDLE,GDI Object,或是不通過C-Runtime Library分配的內(nèi)存,比如VARIANT,BSTR的泄漏,它是無法檢測到的,這是這種檢測法的一個重大的局限性。另外,為了能記錄內(nèi)存塊是在哪里分配的,源代碼必須相應(yīng)的配合,這在調(diào)試一些老的程序非常麻煩,畢竟修改源代碼不是一件省心的事,這是這種檢測法的另一個局限性。
對于開發(fā)一個大型的程序,MS C-Runtime Library提供的檢測功能是遠(yuǎn)遠(yuǎn)不夠的。接下來我們就看看外掛式的檢測工具。我用的比較多的是BoundsChecker,一則因為它的功能比較全面,更重要的是它的穩(wěn)定性。這類工具如果不穩(wěn)定,反而會忙里添亂。到底是出自鼎鼎大名的NuMega,我用下來基本上沒有什么大問題。
2.3.3.2 使用BoundsChecker檢測內(nèi)存泄漏
BoundsChecker采用一種被稱為 Code Injection的技術(shù),來截獲對分配內(nèi)存和釋放內(nèi)存的函數(shù)的調(diào)用。簡單地說,當(dāng)你的程序開始運(yùn)行時,BoundsChecker的DLL被自動載入進(jìn)程的地址空間(這可以通過system-level的Hook實現(xiàn)),然后它會修改進(jìn)程中對內(nèi)存分配和釋放的函數(shù)調(diào)用,讓這些調(diào)用首先轉(zhuǎn)入它的代碼,然后再執(zhí)行原來的代碼。BoundsChecker在做這些動作的時,無須修改被調(diào)試程序的源代碼或工程配置文件,這使得使用它非常的簡便、直接。
這里我們以malloc函數(shù)為例,截獲其他的函數(shù)方法與此類似。
需要被截獲的函數(shù)可能在DLL中,也可能在程序的代碼里。比如,如果靜態(tài)連結(jié)C-Runtime Library,那么malloc函數(shù)的代碼會被連結(jié)到程序里。為了截獲住對這類函數(shù)的調(diào)用,BoundsChecker會動態(tài)修改這些函數(shù)的指令。
以下兩段匯編代碼,一段沒有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: } |
當(dāng)BoundsChecker介入后,函數(shù)malloc的前三條匯編指令被替換成一條jmp指令,原來的三條指令被搬到地址01F41EC8處了。當(dāng)程序進(jìn)入malloc后先jmp到01F41EC8,執(zhí)行原來的三條指令,然后就是BoundsChecker的天下了。大致上它會先記錄函數(shù)的返回地址(函數(shù)的返回地址在stack上,所以很容易修改),然后把返回地址指向?qū)儆贐oundsChecker的代碼,接著跳到malloc函數(shù)原來的指令,也就是在00403c15的地方。當(dāng)malloc函數(shù)結(jié)束的時候,由于返回地址被修改,它會返回到BoundsChecker的代碼中,此時BoundsChecker會記錄由malloc分配的內(nèi)存的指針,然后再跳轉(zhuǎn)到到原來的返回地址去。
如果內(nèi)存分配/釋放函數(shù)在DLL中,BoundsChecker則采用另一種方法來截獲對這些函數(shù)的調(diào)用。BoundsChecker通過修改程序的DLL Import Table讓table中的函數(shù)地址指向自己的地址,以達(dá)到截獲的目的。
截獲住這些分配和釋放函數(shù),BoundsChecker就能記錄被分配的內(nèi)存或資源的生命周期。接下來的問題是如何與源代碼相關(guān),也就是說當(dāng)BoundsChecker檢測到內(nèi)存泄漏,它如何報告這塊內(nèi)存塊是哪段代碼分配的。答案是調(diào)試信息(Debug Information)。當(dāng)我們編譯一個Debug版的程序時,編譯器會把源代碼和二進(jìn)制代碼之間的對應(yīng)關(guān)系記錄下來,放到一個單獨(dú)的文件里(.pdb)或者直接連結(jié)進(jìn)目標(biāo)程序,通過直接讀取調(diào)試信息就能得到分配某塊內(nèi)存的源代碼在哪個文件,哪一行上。使用Code Injection和Debug Information,使BoundsChecker不但能記錄呼叫分配函數(shù)的源代碼的位置,而且還能記錄分配時的Call Stack,以及Call Stack上的函數(shù)的源代碼位置。這在使用像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(); … } |
當(dāng)調(diào)用ShowYItemMenu()時,我們故意造成HMENU的泄漏。但是,對于BoundsChecker來說被泄漏的HMENU是在class CMenu::CreatePopupMenu()中分配的。假設(shè)的你的程序有許多地方使用了CMenu的CreatePopupMenu()函數(shù),如CMenu::CreatePopupMenu()造成的,你依然無法確認(rèn)問題的根結(jié)到底在哪里,在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 |
這里省略了其他的函數(shù)調(diào)用
如此,我們很容易找到發(fā)生問題的函數(shù)是ShowYItemMenu()。當(dāng)使用MFC之類的類庫編程時,大部分的API調(diào)用都被封裝在類庫的class里,有了Call Stack信息,我們就可以非常容易的追蹤到真正發(fā)生泄漏的代碼。
記錄Call Stack信息會使程序的運(yùn)行變得非常慢,因此默認(rèn)情況下BoundsChecker不會記錄Call Stack信息?梢园凑找韵碌牟襟E打開記錄Call Stack信息的選項開關(guān):
1. 打開菜單:BoundsChecker|Setting…
2. 在Error Detection頁中,在Error Detection Scheme的List中選擇Custom
3. 在Category的Combox中選擇 Pointer and leak error check
4. 鉤上Report Call Stack復(fù)選框
5. 點(diǎn)擊Ok
基于Code Injection,BoundsChecker還提供了API Parameter的校驗功能,memory over run等功能。這些功能對于程序的開發(fā)都非常有益。由于這些內(nèi)容不屬于本文的主題,所以不在此詳述了。
盡管BoundsChecker的功能如此強(qiáng)大,但是面對隱式內(nèi)存泄漏仍然顯得蒼白無力。所以接下來我們看看如何用Performance Monitor檢測內(nèi)存泄漏。
2.3.3.3 使用Performance Monitor檢測內(nèi)存泄漏
NT的內(nèi)核在設(shè)計過程中已經(jīng)加入了系統(tǒng)監(jiān)視功能,比如CPU的使用率,內(nèi)存的使用情況,I/O操作的頻繁度等都作為一個個Counter,應(yīng)用程序可以通過讀取這些Counter了解整個系統(tǒng)的或者某個進(jìn)程的運(yùn)行狀況。Performance Monitor就是這樣一個應(yīng)用程序。
為了檢測內(nèi)存泄漏,我們一般可以監(jiān)視Process對象的Handle Count,Virutal Bytes 和Working Set三個Counter。Handle Count記錄了進(jìn)程當(dāng)前打開的HANDLE的個數(shù),監(jiān)視這個Counter有助于我們發(fā)現(xiàn)程序是否有Handle泄漏;Virtual Bytes記錄了該進(jìn)程當(dāng)前在虛地址空間上使用的虛擬內(nèi)存的大小,NT的內(nèi)存分配采用了兩步走的方法,首先,在虛地址空間上保留一段空間,這時操作系統(tǒng)并沒有分配物理內(nèi)存,只是保留了一段地址。然后,再提交這段空間,這時操作系統(tǒng)才會分配物理內(nèi)存。所以,Virtual Bytes一般總大于程序的Working Set。監(jiān)視Virutal Bytes可以幫助我們發(fā)現(xiàn)一些系統(tǒng)底層的問題; Working Set記錄了操作系統(tǒng)為進(jìn)程已提交的內(nèi)存的總量,這個值和程序申請的內(nèi)存總量存在密切的關(guān)系,如果程序存在內(nèi)存的泄漏這個值會持續(xù)增加,但是Virtual Bytes卻是跳躍式增加的。
監(jiān)視這些Counter可以讓我們了解進(jìn)程使用內(nèi)存的情況,如果發(fā)生了泄漏,即使是隱式內(nèi)存泄漏,這些Counter的值也會持續(xù)增加。但是,我們知道有問題卻不知道哪里有問題,所以一般使用Performance Monitor來驗證是否有內(nèi)存泄漏,而使用BoundsChecker來找到和解決。
當(dāng)Performance Monitor顯示有內(nèi)存泄漏,而BoundsChecker卻無法檢測到,這時有兩種可能:第一種,發(fā)生了偶發(fā)性內(nèi)存泄漏。這時你要確保使用Performance Monitor和使用BoundsChecker時,程序的運(yùn)行環(huán)境和操作方法是一致的。第二種,發(fā)生了隱式的內(nèi)存泄漏。這時你要重新審查程序的設(shè)計,然后仔細(xì)研究Performance Monitor記錄的Counter的值的變化圖,分析其中的變化和程序運(yùn)行邏輯的關(guān)系,找到一些可能的原因。這是一個痛苦的過程,充滿了假設(shè)、猜想、驗證、失敗,但這也是一個積累經(jīng)驗的絕好機(jī)會。
3 探討C++內(nèi)存回收
3.1 C++內(nèi)存對象大會戰(zhàn)
如果一個人自稱為程序高手,卻對內(nèi)存一無所知,那么我可以告訴你,他一定在吹牛。用C或C++寫 程序,需要更多地關(guān)注內(nèi)存,這不僅僅是因為內(nèi)存的分配是否合理直接影響著程序的效率和性能,更為主要的是,當(dāng)我們操作內(nèi)存的時候一不小心就會出現(xiàn)問題,而 且很多時候,這些問題都是不易發(fā)覺的,比如內(nèi)存泄漏,比如懸掛指針。筆者今天在這里并不是要討論如何避免這些問題,而是想從另外一個角度來認(rèn)識C++內(nèi)存對象。
我們知道,C++將內(nèi)存劃分為三個邏輯區(qū)域:堆、棧和靜態(tài)存儲區(qū)。既然如此,我稱位于它們之中的對象分別為堆對象,棧對象以及靜態(tài)對象。那么這些不同的內(nèi)存對象有什么區(qū)別了?堆對象和棧對象各有什么優(yōu)劣了?如何禁止創(chuàng)建堆對象或棧對象了?這些便是今天的主題。
3.1.1 基本概念
先來看看棧。棧,一般用于存放局部變量或?qū)ο,如我們在函?shù)定義中用類似下面語句聲明的對象:
Type stack_object ; |
stack_object便是一個棧對象,它的生命期是從定義點(diǎn)開始,當(dāng)所在函數(shù)返回時,生命結(jié)束。
另外,幾乎所有的臨時對象都是棧對象。比如,下面的函數(shù)定義:
Type fun(Type object); |
這個函數(shù)至少產(chǎn)生兩個臨時對象,首先,參數(shù)是按值傳遞的,所以會調(diào)用拷貝構(gòu)造函數(shù)生成一個臨時對象object_copy1 ,在函數(shù)內(nèi)部使用的不是使用的不是object,而是object_copy1,自然,object_copy1是一個棧對象,它在函數(shù)返回時被釋放;還有這個函數(shù)是值返回的,在函數(shù)返回時,如果我們不考慮返回值優(yōu)化(NRV),那么也會產(chǎn)生一個臨時對象object_copy2,這個臨時對象會在函數(shù)返回后一段時間內(nèi)被釋放。比如某個函數(shù)中有如下代碼:
Type tt ,result ; //生成兩個棧對象 tt = fun(tt); //函數(shù)返回時,生成的是一個臨時對象object_copy2 |
上面的第二個語句的執(zhí)行情況是這樣的,首先函數(shù)fun返回時生成一個臨時對象object_copy2 ,然后再調(diào)用賦值運(yùn)算符執(zhí)行
tt = object_copy2 ; //調(diào)用賦值運(yùn)算符 |
看到了嗎?編譯器在我們毫無知覺的情況下,為我們生成了這么多臨時對象,而生成這些臨時對象的時間和空間的開銷可能是很大的,所以,你也許明白了,為什么對于“大”對象最好用const引用傳遞代替按值進(jìn)行函數(shù)參數(shù)傳遞了。
接下來,看看堆。堆,又叫自由存儲區(qū),它是在程序執(zhí)行的過程中動態(tài)分配的,所以它最大的特性就是動態(tài)性。在C++中,所有堆對象的創(chuàng)建和銷毀都要由程序員負(fù)責(zé),所以,如果處理不好,就會發(fā)生內(nèi)存問題。如果分配了堆對象,卻忘記了釋放,就會產(chǎn)生內(nèi)存泄漏;而如果已釋放了對象,卻沒有將相應(yīng)的指針置為NULL,該指針就是所謂的“懸掛指針”,再度使用此指針時,就會出現(xiàn)非法訪問,嚴(yán)重時就導(dǎo)致程序崩潰。
那么,C++中是怎樣分配堆對象的?唯一的方法就是用new(當(dāng)然,用類malloc指令也可獲得C式堆內(nèi)存),只要使用new,就會在堆中分配一塊內(nèi)存,并且返回指向該堆對象的指針。
再來看看靜態(tài)存儲區(qū)。所有的靜態(tài)對象、全局對象都于靜態(tài)存儲區(qū)分配。關(guān)于全局對象,是在main()函數(shù)執(zhí)行前就分配好了的。其實,在main()函數(shù)中的顯示代碼執(zhí)行之前,會調(diào)用一個由編譯器生成的_main()函數(shù),而_main()函數(shù)會進(jìn)行所有全局對象的的構(gòu)造及初始化工作。而在main()函數(shù)結(jié)束之前,會調(diào)用由編譯器生成的exit函數(shù),來釋放所有的全局對象。比如下面的代碼:
void main(void) { … …// 顯式代碼 } |
實際上,被轉(zhuǎn)化成這樣:
void main(void) { _main(); //隱式代碼,由編譯器產(chǎn)生,用以構(gòu)造所有全局對象 … … // 顯式代碼 … … exit() ; // 隱式代碼,由編譯器產(chǎn)生,用以釋放所有全局對象 } |
所以,知道了這個之后,便可以由此引出一些技巧,如,假設(shè)我們要在main()函數(shù)執(zhí)行之前做某些準(zhǔn)備工作,那么我們可以將這些準(zhǔn)備工作寫到一個自定義的全局對象的構(gòu)造函數(shù)中,這樣,在main()函數(shù)的顯式代碼執(zhí)行之前,這個全局對象的構(gòu)造函數(shù)會被調(diào)用,執(zhí)行預(yù)期的動作,這樣就達(dá)到了我們的目的。 剛才講的是靜態(tài)存儲區(qū)中的全局對象,那么,局部靜態(tài)對象了?局部靜態(tài)對象通常也是在函數(shù)中定義的,就像棧對象一樣,只不過,其前面多了個static關(guān)鍵字。局部靜態(tài)對象的生命期是從其所在函數(shù)第一次被調(diào)用,更確切地說,是當(dāng)?shù)谝淮螆?zhí)行到該靜態(tài)對象的聲明代碼時,產(chǎn)生該靜態(tài)局部對象,直到整個程序結(jié)束時,才銷毀該對象。
還有一種靜態(tài)對象,那就是它作為class的靜態(tài)成員?紤]這種情況時,就牽涉了一些較復(fù)雜的問題。
第一個問題是class的靜態(tài)成員對象的生命期,class的靜態(tài)成員對象隨著第一個class object的產(chǎn)生而產(chǎn)生,在整個程序結(jié)束時消亡。也就是有這樣的情況存在,在程序中我們定義了一個class,該類中有一個靜態(tài)對象作為成員,但是在程序執(zhí)行過程中,如果我們沒有創(chuàng)建任何一個該class object,那么也就不會產(chǎn)生該class所包含的那個靜態(tài)對象。還有,如果創(chuàng)建了多個class object,那么所有這些object都共享那個靜態(tài)對象成員。
第二個問題是,當(dāng)出現(xiàn)下列情況時:
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 = …… ; |
請注意上面標(biāo)為黑體的三條語句,它們所訪問的s_object是同一個對象嗎?答案是肯定的,它們的確是指向同一個對象,這聽起來不像是真的,是嗎?但這是事實,你可以自己寫段簡單的代碼驗證一下。我要做的是來解釋為什么會這樣? 我們知道,當(dāng)一個類比如Derived1,從另一個類比如Base繼承時,那么,可以看作一個Derived1對象中含有一個Base型的對象,這就是一個subobject。一個Derived1對象的大致內(nèi)存布局如下:
讓我們想想,當(dāng)我們將一個Derived1型的對象傳給一個接受非引用Base型參數(shù)的函數(shù)時會發(fā)生切割,那么是怎么切割的呢?相信現(xiàn)在你已經(jīng)知道了,那就是僅僅取出了Derived1型的對象中的subobject,而忽略了所有Derived1自定義的其它數(shù)據(jù)成員,然后將這個subobject傳遞給函數(shù)(實際上,函數(shù)中使用的是這個subobject的拷貝)。
所有繼承Base類的派生類的對象都含有一個Base型的subobject(這是能用Base型指針指向一個Derived1對象的關(guān)鍵所在,自然也是多態(tài)的關(guān)鍵了),而所有的subobject和所有Base型的對象都共用同一個s_object對象,自然,從Base類派生的整個繼承體系中的類的實例都會共用同一個s_object對象了。上面提到的example、example1、example2的對象布局如下圖所示:
3.1.2 三種內(nèi)存對象的比較
棧對象的優(yōu)勢是在適當(dāng)?shù)臅r候自動生成,又在適當(dāng)?shù)臅r候自動銷毀,不需要程序員操心;而且棧對象的創(chuàng)建速度一般較堆對象快,因為分配堆對象時,會調(diào)用operator new操作,operator new會采用某種內(nèi)存空間搜索算法,而該搜索過程可能是很費(fèi)時間的,產(chǎn)生棧對象則沒有這么麻煩,它僅僅需要移動棧頂指針就可以了。但是要注意的是,通常?臻g容量比較小,一般是1MB~2MB,所以體積比較大的對象不適合在棧中分配。特別要注意遞歸函數(shù)中最好不要使用棧對象,因為隨著遞歸調(diào)用深度的增加,所需的?臻g也會線性增加,當(dāng)所需棧空間不夠時,便會導(dǎo)致棧溢出,這樣就會產(chǎn)生運(yùn)行時錯誤。
堆對象,其產(chǎn)生時刻和銷毀時刻都要程序員精確定義,也就是說,程序員對堆對象的 生命具有完全的控制權(quán)。我們常常需要這樣的對象,比如,我們需要創(chuàng)建一個對象,能夠被多個函數(shù)所訪問,但是又不想使其成為全局的,那么這個時候創(chuàng)建一個堆 對象無疑是良好的選擇,然后在各個函數(shù)之間傳遞這個堆對象的指針,便可以實現(xiàn)對該對象的共享。另外,相比于棧空間,堆的容量要大得多。實際上,當(dāng)物理內(nèi)存 不夠時,如果這時還需要生成新的堆對象,通常不會產(chǎn)生運(yùn)行時錯誤,而是系統(tǒng)會使用虛擬內(nèi)存來擴(kuò)展實際的物理內(nèi)存。
接下來看看static對象。
首先是全局對象。全局對象為類間通信和函數(shù)間通信提供了一種最簡單的方式,雖然這種方式并不優(yōu)雅。一般而言,在完全的面向?qū)ο笳Z言中,是不存在全局對象的,比如C#,因為全局對象意味著不安全和高耦合,在程序中過多地使用全局對象將大大降低程序的健壯性、穩(wěn)定性、可維護(hù)性和可復(fù)用性。C++也完全可以剔除全局對象,但是最終沒有,我想原因之一是為了兼容C。
其次是類的靜態(tài)成員,上面已經(jīng)提到,基類及其派生類的所有對象都共享這個靜態(tài)成員對象,所以當(dāng)需要在這些class之間或這些class objects之間進(jìn)行數(shù)據(jù)共享或通信時,這樣的靜態(tài)成員無疑是很好的選擇。
接著是靜態(tài)局部對象,主要可用于保存該對象所在函數(shù)被屢次調(diào)用期間的中間狀態(tài),其中一個最顯著的例子就是遞歸函數(shù),我們都知道遞歸函數(shù)是自己調(diào)用自己的函數(shù),如果在遞歸函數(shù)中定義一個nonstatic局部對象,那么當(dāng)遞歸次數(shù)相當(dāng)大時,所產(chǎn)生的開銷也是巨大的。這是因為nonstatic局部對象是棧對象,每遞歸調(diào)用一次,就會產(chǎn)生一個這樣的對象,每返回一次,就會釋放這個對象,而且,這樣的對象只局限于當(dāng)前調(diào)用層,對于更深入的嵌套層和更淺露的外層,都是不可見的。每個層都有自己的局部對象和參數(shù)。
在遞歸函數(shù)設(shè)計中,可以使用static對象替代nonstatic局部對象(即棧對象),這不僅可以減少每次遞歸調(diào)用和返回時產(chǎn)生和釋放nonstatic對象的開銷,而且static對象還可以保存遞歸調(diào)用的中間狀態(tài),并且可為各個調(diào)用層所訪問。
3.1.3 使用棧對象的意外收獲
前面已經(jīng)介紹到,棧對象是在適當(dāng)?shù)臅r候創(chuàng)建,然后在適當(dāng)?shù)臅r候自動釋放的,也就 是棧對象有自動管理功能。那么棧對象會在什么會自動釋放了?第一,在其生命期結(jié)束的時候;第二,在其所在的函數(shù)發(fā)生異常的時候。你也許說,這些都很正常 啊,沒什么大不了的。是的,沒什么大不了的。但是只要我們再深入一點(diǎn)點(diǎn),也許就有意外的收獲了。
棧對象,自動釋放時,會調(diào)用它自己的析構(gòu)函數(shù)。如果我們在棧對象中封裝資源,而 且在棧對象的析構(gòu)函數(shù)中執(zhí)行釋放資源的動作,那么就會使資源泄漏的概率大大降低,因為棧對象可以自動的釋放資源,即使在所在函數(shù)發(fā)生異常的時候。實際的過 程是這樣的:函數(shù)拋出異常時,會發(fā)生所謂的stack_unwinding(堆 ;貪L),即堆棧會展開,由于是棧對象,自然存在于棧中,所以在堆棧回滾的過程中,棧對象的析構(gòu)函數(shù)會被執(zhí)行,從而釋放其所封裝的資源。除非,除非在析構(gòu) 函數(shù)執(zhí)行的過程中再次拋出異常――而這種可能性是很小的,所以用棧對象封裝資源是比較安全的;诖苏J(rèn)識,我們就可以創(chuàng)建一個自己的句柄或代理來封裝資源 了。智能指針(auto_ptr)中就使用了這種技術(shù)。在有這種需要的時候,我們就希望我們的資源封裝類只能在棧中創(chuàng)建,也就是要限制在堆中創(chuàng)建該資源封裝類的實例。
3.1.4 禁止產(chǎn)生堆對象
上面已經(jīng)提到,你決定禁止產(chǎn)生某種類型的堆對象,這時你可以自己創(chuàng)建一個資源封裝類,該類對象只能在棧中產(chǎn)生,這樣就能在異常的情況下自動釋放封裝的資源。
那么怎樣禁止產(chǎn)生堆對象了?我們已經(jīng)知道,產(chǎn)生堆對象的唯一方法是使用new操作,如果我們禁止使用new不就行了么。再進(jìn)一步,new操作執(zhí)行時會調(diào)用operator new,而operator new是可以重載的。方法有了,就是使new operator 為private,為了對稱,最好將operator delete也重載為private。現(xiàn)在,你也許又有疑問了,難道創(chuàng)建棧對象不需要調(diào)用new嗎?是的,不需要,因為創(chuàng)建棧對象不需要搜索內(nèi)存,而是直接調(diào)整堆棧指針,將對象壓棧,而operator new的主要任務(wù)是搜索合適的堆內(nèi)存,為堆對象分配空間,這在上面已經(jīng)提到過了。好,讓我們看看下面的示例代碼:
#include <stdlib.h> //需要用到C式內(nèi)存分配函數(shù) class Resource ; //代表需要被封裝的資源類 class NoHashObject { private: Resource* ptr ;//指向被封裝的資源 ... ... //其它數(shù)據(jù)成員 void* operator new(size_t size) //非嚴(yán)格實現(xiàn),僅作示意之用 { return malloc(size) ; } void operator delete(void* pp) //非嚴(yán)格實現(xiàn),僅作示意之用 { free(pp) ; } public: NoHashObject() { //此處可以獲得需要封裝的資源,并讓ptr指針指向該資源 ptr = new Resource() ; } ~NoHashObject() { delete ptr ; //釋放封裝的資源 } }; NoHashObject現(xiàn)在就是一個禁止堆對象的類了,如果你寫下如下代碼: NoHashObject* fp = new NoHashObject() ; //編譯期錯誤! delete fp ; |
上面代碼會產(chǎn)生編譯期錯誤。好了,現(xiàn)在你已經(jīng)知道了如何設(shè)計一個禁止堆對象的類了,你也許和我一樣有這樣的疑問,難道在類NoHashObject的定義不能改變的情況下,就一定不能產(chǎn)生該類型的堆對象了嗎?不,還是有辦法的,我稱之為“暴力破解法”。C++是如此地強(qiáng)大,強(qiáng)大到你可以用它做你想做的任何事情。這里主要用到的是技巧是指針類型的強(qiáng)制轉(zhuǎn)換。
void main(void) { char* temp = new char[sizeof(NoHashObject)] ; //強(qiáng)制類型轉(zhuǎn)換,現(xiàn)在ptr是一個指向NoHashObject對象的指針 NoHashObject* obj_ptr = (NoHashObject*)temp ; temp = NULL ; //防止通過temp指針修改NoHashObject對象 //再一次強(qiáng)制類型轉(zhuǎn)換,讓rp指針指向堆中NoHashObject對象的ptr成員 Resource* rp = (Resource*)obj_ptr ; //初始化obj_ptr指向的NoHashObject對象的ptr成員 rp = new Resource() ; //現(xiàn)在可以通過使用obj_ptr指針使用堆中的NoHashObject對象成員了 ... ... delete rp ;//釋放資源 temp = (char*)obj_ptr ; obj_ptr = NULL ;//防止懸掛指針產(chǎn)生 delete [] temp ;//釋放NoHashObject對象所占的堆空間。 } |
上面的實現(xiàn)是麻煩的,而且這種實現(xiàn)方式幾乎不會在實踐中使用,但是我還是寫出來路,因為理解它,對于我們理解C++內(nèi)存對象是有好處的。對于上面的這么多強(qiáng)制類型轉(zhuǎn)換,其最根本的是什么了?我們可以這樣理解:
某塊內(nèi)存中的數(shù)據(jù)是不變的,而類型就是我們戴上的眼鏡,當(dāng)我們戴上一種眼鏡后,我們就會用對應(yīng)的類型來解釋內(nèi)存中的數(shù)據(jù),這樣不同的解釋就得到了不同的信息。
所謂強(qiáng)制類型轉(zhuǎn)換實際上就是換上另一副眼鏡后再來看同樣的那塊內(nèi)存數(shù)據(jù)。
另外要提醒的是,不同的編譯器對對象的成員數(shù)據(jù)的布局安排可能是不一樣的,比如,大多數(shù)編譯器將NoHashObject的ptr指針成員安排在對象空間的頭4個字節(jié),這樣才會保證下面這條語句的轉(zhuǎn)換動作像我們預(yù)期的那樣執(zhí)行:
Resource* rp = (Resource*)obj_ptr ; |
但是,并不一定所有的編譯器都是如此。
既然我們可以禁止產(chǎn)生某種類型的堆對象,那么可以設(shè)計一個類,使之不能產(chǎn)生棧對象嗎?當(dāng)然可以。
3.1.5 禁止產(chǎn)生棧對象
前面已經(jīng)提到了,創(chuàng)建棧對象時會移動棧頂指針以“挪出”適當(dāng)大小的空間,然后在這個空間上直接調(diào)用對應(yīng)的構(gòu)造函數(shù)以形成一個棧對象,而當(dāng)函數(shù)返回時,會調(diào)用其析構(gòu)函數(shù)釋放這個對象,然后再調(diào)整棧頂指針收回那塊棧內(nèi)存。在這個過程中是不需要operator new/delete操作的,所以將operator new/delete設(shè)置為private不能達(dá)到目的。當(dāng)然從上面的敘述中,你也許已經(jīng)想到了:將構(gòu)造函數(shù)或析構(gòu)函數(shù)設(shè)為私有的,這樣系統(tǒng)就不能調(diào)用構(gòu)造/析構(gòu)函數(shù)了,當(dāng)然就不能在棧中生成對象了。
這樣的確可以,而且我也打算采用這種方案。但是在此之前,有一點(diǎn)需要考慮清楚,那就是,如果我們將構(gòu)造函數(shù)設(shè)置為私有,那么我們也就不能用new來直接產(chǎn)生堆對象了,因為new在為對象分配空間后也會調(diào)用它的構(gòu)造函數(shù)啊。所以,我打算只將析構(gòu)函數(shù)設(shè)置為private。再進(jìn)一步,將析構(gòu)函數(shù)設(shè)為private除了會限制棧對象生成外,還有其它影響嗎?是的,這還會限制繼承。
如果一個類不打算作為基類,通常采用的方案就是將其析構(gòu)函數(shù)聲明為private。
為了限制棧對象,卻不限制繼承,我們可以將析構(gòu)函數(shù)聲明為protected,這樣就兩全其美了。如下代碼所示:
class NoStackObject { protected: ~NoStackObject() { } public: void destroy() { delete this ;//調(diào)用保護(hù)析構(gòu)函數(shù) } }; |
接著,可以像這樣使用NoStackObject類:
NoStackObject* hash_ptr = new NoStackObject() ; ... ... //對hash_ptr指向的對象進(jìn)行操作 hash_ptr->destroy() ; |
呵呵,是不是覺得有點(diǎn)怪怪的,我們用new創(chuàng)建一個對象,卻不是用delete去刪除它,而是要用destroy方法。很顯然,用戶是不習(xí)慣這種怪異的使用方式的。所以,我決定將構(gòu)造函數(shù)也設(shè)為private或protected。這又回到了上面曾試圖避免的問題,即不用new,那么該用什么方式來生成一個對象了?我們可以用間接的辦法完成,即讓這個類提供一個static成員函數(shù)專門用于產(chǎn)生該類型的堆對象。(設(shè)計模式中的singleton模式就可以用這種方式實現(xiàn)。)讓我們來看看:
class NoStackObject { protected: NoStackObject() { } ~NoStackObject() { } public: static NoStackObject* creatInstance() { return new NoStackObject() ;//調(diào)用保護(hù)的構(gòu)造函數(shù) } void destroy() { delete this ;//調(diào)用保護(hù)的析構(gòu)函數(shù) } }; |
現(xiàn)在可以這樣使用NoStackObject類了:
NoStackObject* hash_ptr = NoStackObject::creatInstance() ; ... ... //對hash_ptr指向的對象進(jìn)行操作 hash_ptr->destroy() ; hash_ptr = NULL ; //防止使用懸掛指針 |
現(xiàn)在感覺是不是好多了,生成對象和釋放對象的操作一致了。
3.2 淺議C++ 中的垃圾回收方法
許多 C 或者 C++ 程序員對垃圾回收嗤之以鼻,認(rèn)為垃圾回收肯定比自己來管理動態(tài)內(nèi)存要低效,而且在回收的時候一定會讓程序停頓在那里,而如果自己控制內(nèi)存管理的話,分配和釋放時間都是穩(wěn)定的,不會導(dǎo)致程序停頓。最后,很多 C/C++ 程序員堅信在C/C++ 中無法實現(xiàn)垃圾回收機(jī)制。這些錯誤的觀點(diǎn)都是由于不了解垃圾回收的算法而臆想出來的。
其實垃圾回收機(jī)制并不慢,甚至比動態(tài)內(nèi)存分配更高效。因為我們可以只分配不釋 放,那么分配內(nèi)存的時候只需要從堆上一直的獲得新的內(nèi)存,移動堆頂?shù)闹羔樉蛪蛄;而釋放的過程被省略了,自然也加快了速度,F(xiàn)代的垃圾回收算法已經(jīng)發(fā)展了 很多,增量收集算法已經(jīng)可以讓垃圾回收過程分段進(jìn)行,避免打斷程序的運(yùn)行了。而傳統(tǒng)的動態(tài)內(nèi)存管理的算法同樣有在適當(dāng)?shù)臅r間收集內(nèi)存碎片的工作要做,并不 比垃圾回收更有優(yōu)勢。
而垃圾回收的算法的基礎(chǔ)通常基于掃描并標(biāo)記當(dāng)前可能被使用的所有內(nèi)存塊,從已經(jīng)被分配的所有內(nèi)存中把未標(biāo)記的內(nèi)存回收來做的。C/C++ 中 無法實現(xiàn)垃圾回收的觀點(diǎn)通常基于無法正確掃描出所有可能還會被使用的內(nèi)存塊,但是,看似不可能的事情實際上實現(xiàn)起來卻并不復(fù)雜。首先,通過掃描內(nèi)存的數(shù) 據(jù),指向堆上動態(tài)分配出來內(nèi)存的指針是很容易被識別出來的,如果有識別錯誤,也只能是把一些不是指針的數(shù)據(jù)當(dāng)成指針,而不會把指針當(dāng)成非指針數(shù)據(jù)。這樣, 回收垃圾的過程只會漏回收掉而不會錯誤的把不應(yīng)該回收的內(nèi)存清理。其次,如果回溯所有內(nèi)存塊被引用的根,只可能存在于全局變量和當(dāng)前的棧內(nèi),而全局變量(包括函數(shù)內(nèi)的靜態(tài)變量)都是集中存在于 bss 段或 data段中。
垃圾回收的時候,只需要掃描 bss 段, data 段以及當(dāng)前被使用著的?臻g,找到可能是動態(tài)內(nèi)存指針的量,把引用到的內(nèi)存遞歸掃描就可以得到當(dāng)前正在使用的所有動態(tài)內(nèi)存了。
如果肯為你的工程實現(xiàn)一個不錯的垃圾回收器,提高內(nèi)存管理的速度,甚至減少總的內(nèi)存消耗都是可能的。