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