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

