C++內存管理詳解

內存管理是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最小值爲4Bytecommit是保留在虛擬內存的頁文件裏面,它設置的較大會使棧開闢較大的值,可能增加內存的開銷和啓動時間。

  碎片問題:對於堆來講,頻繁的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 重載全局的newdelete操作符

  可以很容易地重載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)進行

  檢查。如果是用mallocnew來申請內存,應該用if(p==NULL) if(p!=NULL)進行防錯處理。

  * 內存分配雖然成功,但是尚未初始化就引用它。

  犯這種錯誤主要有兩個起因:一是沒有初始化的觀念;二是誤以爲內存的缺省初值全爲零,導致引用初值錯誤(例如數組)。 內存的缺省初值究竟是什麼並沒有統一的標準,儘管有些時候爲零值,我們寧可信其無不可信其有。所以無論用何種方式創建數組,都別忘了賦初值,即便是賦零值也不可省略,不要嫌麻煩。

  * 內存分配成功並且已經初始化,但操作越過了內存的邊界。

  例如在使用數組時經常發生下標“多1”或者“少1”的操作。特別是在for循環語句中,循環次數很容易搞錯,導致數組操作越界。

  * 忘記了釋放內存,造成內存泄露。

  含有這種錯誤的函數每被調用一次就丟失一塊內存。剛開始時系統的內存充足,你看不到錯誤。終有一次程序突然死掉,系統出現提示:內存耗盡。

  動態內存的申請與釋放必須配對,程序中mallocfree的使用次數一定要相同,否則肯定有錯誤(new/delete同理)。

  * 釋放了內存卻繼續使用它。

 

  有三種情況:

  (1)程序中的對象調用關係過於複雜,實在難以搞清楚某個對象究竟是否已經釋放了內存,此時應該重新設計數據結構,從根本上解決對象管理的混亂局面。

  (2)函數的return語句寫錯了,注意不要返回指向“棧內存”的“指針”或者“引用”,因爲該內存在函數體結束時被自動銷燬。

  (3)使用freedelete釋放了內存後,沒有將指針設置爲NULL。導致產生“野指針”。

  【規則1】用mallocnew申請內存之後,應該立即檢查指針值是否爲NULL。防止使用指針值爲NULL的內存。

  【規則2】不要忘記爲數組和動態內存賦初值。防止將未被初始化的內存作爲右值使用。

  【規則3】避免數組或指針的下標越界,特別要當心發生“多1”或者“少1”操作。

  【規則4】動態內存的申請與釋放必須配對,防止內存泄漏。

  【規則5】用freedelete釋放了內存之後,立即將指針設置爲NULL,防止產生“野指針”。

1.1.4 指針與數組的對比

  C++/C程序中,指針和數組在不少地方可以相互替換着用,讓人產生一種錯覺,以爲兩者是等價的。

  數組要麼在靜態存儲區被創建(如全局數組),要麼在棧上被創建。數組名對應着(而不是指向)一塊內存,其地址與容量在生命期內保持不變,只有數組的內容可以改變。

  指針可以隨時指向任意類型的內存塊,它的特徵是“可變”,所以我們常用指針來操作動態內存。指針遠比數組靈活,但也更危險。

  下面以字符串爲例比較指針與數組的特性。

1.1.4.1 修改內容

下面示例中,字符數組a的容量是6個字符,其內容爲helloa的內容可以改變,如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進行復制。同理,比較ba的內容是否相同,不能用if(b==a) 來判斷,應該用標準庫函數strcmp進行比較。

語句p = a 並不能把a的內容複製指針p,而是把a的地址賦給了p。要想複製a的內容,可以先用庫函數mallocp申請一塊容量爲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)指針pfree或者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

  mallocfreeC++/C語言的標準庫函數,new/deleteC++的運算符。它們都可用於申請動態內存和釋放內存。

  對於非內部數據類型的對象而言,光用maloc/free無法滿足動態對象的要求。對象在創建的同時要自動執行構造函數,對象在消亡之前要自動執行析構函數。由於malloc/free是庫函數而不是運算符,不在編譯器控制權限之內,不能夠把執行構造函數和析構函數的任務強加於malloc/free

因此C++語言需要一個能完成動態內存分配和初始化工作的運算符new,以及一個能完成清理與釋放內存工作的運算符delete。注意new/delete不是庫函數。我們先看一看malloc/freenew/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不能執行構造函數與析構函數,必須調用成員函數InitializeDestroy來完成初始化與清除工作。函數UseNewDelete則簡單得多。

  所以我們不要企圖用malloc/free來完成動態對象的內存管理,應該用new/delete。由於內部數據類型的“對象”沒有構造與析構的過程,對它們而言malloc/freenew/delete是等價的。

  既然new/delete的功能完全覆蓋了malloc/free,爲什麼C++不把malloc/free淘汰出局呢?這是因爲C++程序經常要調用C函數,而C程序只能用malloc/free管理動態內存。

如果用free釋放“new創建的動態對象”,那麼該對象因無法執行析構函數而可能導致程序出錯。如果用delete釋放“malloc申請的動態內存”,結果也會導致程序出錯,但是該程序的可讀性很差。所以new/delete必須配對使用,malloc/free也一樣。

1.1.8 內存耗盡怎麼辦?

  如果在申請動態內存時找不到足夠大的內存塊,mallocnew將返回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)爲newmalloc設置異常處理函數。例如Visual C++可以用_set_new_hander函數爲new設置用戶自己定義的異常處理函數,也可以讓malloc享用與new相同的異常處理函數。詳細內容請參考C++使用手冊。

  上述(1)(2)方式使用最普遍。如果一個函數內有多處需要申請動態內存,那麼方式(1)就顯得力不從心(釋放內存很麻煩),應該用方式(2)來處理。

  很多人不忍心用exit(1),問:“不編寫出錯處理程序,讓操作系統自己解決行不行?”

  不行。如果發生“內存耗盡”這樣的事情,一般說來應用程序已經無藥可救。如果不用exit(1) 把壞程序殺死,它可能會害死操作系統。道理如同:如果不把歹徒擊斃,歹徒在老死之前會犯下更多的罪。

  有一個很重要的現象要告訴大家。對於32位以上的應用程序而言,無論怎樣使用mallocnew,幾乎不可能導致“內存耗盡”。我在Windows 98下用Visual C++編寫了測試程序,見示例7。這個程序會無休止地運行下去,根本不會終止。因爲32位操作系統支持“虛存”,內存用完了,自動用硬盤空間頂替。我只聽到硬盤嘎吱嘎吱地響,Window 98已經累得對鍵盤、鼠標毫無反應。

  我可以得出這麼一個結論:對於32位以上的應用程序,“內存耗盡”錯誤處理程序毫無用處。這下可把UnixWindows程序員們樂壞了:反正錯誤處理程序不起作用,我就不寫了,省了很多麻煩。

我不想誤導讀者,必須強調:不加錯誤處理將導致程序的質量很差,千萬不可因小失大。

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)能正確地釋放內存。如果pNULL指針,那麼freep無論操作多少次都不會出問題。如果p不是NULL指針,那麼freep連續操作兩次就會導致程序運行錯誤。

1.1.10 new/delete的使用要點

運算符new使用起來要比函數malloc簡單得多,例如:

int *p1 = (int *)malloc(sizeof(int) * length);

int *p2 = new int[length];

這是因爲new內置了sizeof、類型轉換和類型安全檢查功能。對於非內部數據類型的對象而言,new在創建動態對象的同時完成了初始化工作。如果對象有多個構造函數,那麼new的語句也可以有多種形式。例如

class Obj

{

 public :

  Obj(void); // 無參數的構造函數

  Obj(int x); // 帶一個參數的構造函數

  …

}

void Test(void)

{

 Obj *a = new Obj;

 Obj *b = new Obj(1); // 初值爲1

 …

 delete a;

 delete b;

}

如果用new創建對象數組,那麼只能使用對象的無參數構造函數。例如:

Obj *objects = new Obj[100]; // 創建100個動態對象

不能寫成:

Obj *objects = new Obj[100](1);// 創建100個動態對象的同時賦初值1

在用delete釋放對象數組時,留意不要丟了符號‘[]’。例如:

delete []objects; // 正確的用法

delete objects; // 錯誤的用法

後者有可能引起程序崩潰和內存泄漏。

1.2 C++中的健壯指針和資源管理

  我最喜歡的對資源的定義是:"任何在你的程序中獲得並在此後釋放的東西?quot;內存是一個相當明顯的資源的例子。它需要用new來獲得,用delete來釋放。同時也有許多其它類型的資源文件句柄、重要的片斷、Windows中的GDI資源,等等。將資源的概念推廣到程序中創建、釋放的所有對象也是十分方便的,無論對象是在堆中分配的還是在棧中或者是在全局作用於內生命的。

  對於給定的資源的擁有着,是負責釋放資源的一個對象或者是一段代碼。所有權分立爲兩種級別——自動的和顯式的(automatic and explicit),如果一個對象的釋放是由語言本身的機制來保證的,這個對象的就是被自動地所有。例如,一個嵌入在其他對象中的對象,他的清除需要其他對象來在清除的時候保證。外面的對象被看作嵌入類的所有者。   類似地,每個在棧上創建的對象(作爲自動變量)的釋放(破壞)是在控制流離開了對象被定義的作用域的時候保證的。這種情況下,作用於被看作是對象的所有者。注意所有的自動所有權都是和語言的其他機制相容的,包括異常。無論是如何退出作用域的——正常流程控制退出、一個break語句、一個return、一個goto、或者是一個throw——自動資源都可以被清除。

  到目前爲止,一切都很好!問題是在引入指針、句柄和抽象的時候產生的。如果通過一個指針訪問一個對象的話,比如對象在堆中分配,C++不自動地關注它的釋放。程序員必須明確的用適當的程序方法來釋放這些資源。比如說,如果一個對象是通過調用new來創建的,它需要用delete來回收。一個文件是用CreateFile(Win32 API)打開的,它需要用CloseHandle來關閉。用EnterCritialSection進入的臨界區(Critical Section)需要LeaveCriticalSection退出,等等。一個""指針,文件句柄,或者臨界區狀態沒有所有者來確保它們的最終釋放。基本的資源管理的前提就是確保每個資源都有他們的所有者。

1.2.1 第一條規則(RAII

  一個指針,一個句柄,一個臨界區狀態只有在我們將它們封裝入對象的時候纔會擁有所有者。這就是我們的第一規則:在構造函數中分配資源,在析構函數中釋放資源。

  當你按照規則將所有資源封裝的時候,你可以保證你的程序中沒有任何的資源泄露。這點在當封裝對象(Encapsulating Object)在棧中建立或者嵌入在其他的對象中的時候非常明顯。但是對那些動態申請的對象呢?不要急!任何動態申請的東西都被看作一種資源,並且要按照上面提到的方法進行封裝。這一對象封裝對象的鏈不得不在某個地方終止。它最終終止在最高級的所有者,自動的或者是靜態的。這些分別是對離開作用域或者程序時釋放資源的保證。

  下面是資源封裝的一個經典例子。在一個多線程的應用程序中,線程之間共享對象的問題是通過用這樣一個對象聯繫臨界區來解決的。每一個需要訪問共享資源的客戶需要獲得臨界區。例如,這可能是Win32下臨界區的實現方法。

class CritSect

{

 friend class Lock;

 public:

  CritSect () { InitializeCriticalSection (&_critSection); }

  ~CritSect () { DeleteCriticalSection (&_critSection); }

 private:

  void Acquire ()

  {

   EnterCriticalSection (&_critSection);

  }

  void Release ()

  {

   LeaveCriticalSection (&_critSection);

  }

 private:

  CRITICAL_SECTION _critSection;

};

  這裏聰明的部分是我們確保每一個進入臨界區的客戶最後都可以離開。"進入"臨界區的狀態是一種資源,並應當被封裝。封裝器通常被稱作一個鎖(lock)。

class Lock

{

 public:

  Lock (CritSect& critSect) : _critSect (critSect)

  {

   _critSect.Acquire ();

  }

  ~Lock ()

  {

   _critSect.Release ();

  }

 private

  CritSect & _critSect;

};

  鎖一般的用法如下:

void Shared::Act () throw (char *)

{

 Lock lock (_critSect);

 // perform action —— may throw

 // automatic destructor of lock

}

  注意無論發生什麼,臨界區都會藉助於語言的機制保證釋放。

  還有一件需要記住的事情——每一種資源都需要被分別封裝。這是因爲資源分配是一個非常容易出錯的操作,是要資源是有限提供的。我們會假設一個失敗的資源分配會導致一個異常——事實上,這會經常的發生。所以如果你想試圖用一個石頭打兩隻鳥的話,或者在一個構造函數中申請兩種形式的資源,你可能就會陷入麻煩。只要想想在一種資源分配成功但另一種失敗拋出異常時會發生什麼。因爲構造函數還沒有全部完成,析構函數不可能被調用,第一種資源就會發生泄露。

這種情況可以非常簡單的避免。無論何時你有一個需要兩種以上資源的類時,寫兩個小的封裝器將它們嵌入你的類中。每一個嵌入的構造都可以保證刪除,即使包裝類沒有構造完成。

1.2.2 Smart Pointers

  我們至今還沒有討論最常見類型的資源——用操作符new分配,此後用指針訪問的一個對象。我們需要爲每個對象分別定義一個封裝類嗎?(事實上,C++標準模板庫已經有了一個模板類,叫做auto_ptr,其作用就是提供這種封裝。我們一會兒在回到auto_ptr。)讓我們從一個極其簡單、呆板但安全的東西開始。看下面的Smart Pointer模板類,它十分堅固,甚至無法實現。

template <class T>

class SmartPointer

{

 public:

  ~SmartPointer () { delete _p; }

  T * operator->() { return _p; }

  T const * operator->() const { return _p; }

 protected:

  SmartPointer (): _p (0) {}

  explicit SmartPointer (T* p): _p (p) {}

  T * _p;

};

  爲什麼要把SmartPointer的構造函數設計爲protected呢?如果我需要遵守第一條規則,那麼我就必須這樣做。資源——在這裏是class T的一個對象——必須在封裝器的構造函數中分配。但是我不能只簡單的調用new T,因爲我不知道T的構造函數的參數。因爲,在原則上,每一個T都有一個不同的構造函數;我需要爲他定義個另外一個封裝器。模板的用處會很大,爲每一個新的類,我可以通過繼承SmartPointer定義一個新的封裝器,並且提供一個特定的構造函數。

class SmartItem: public SmartPointer<Item>

{

 public:

  explicit SmartItem (int i)

  : SmartPointer<Item> (new Item (i)) {}

};

  爲每一個類提供一個Smart Pointer真的值得嗎?說實話——不!他很有教學的價值,但是一旦你學會如何遵循第一規則的話,你就可以放鬆規則並使用一些高級的技術。這一技術是讓SmartPointer的構造函數成爲public,但是隻是是用它來做資源轉換(Resource Transfer)我的意思是用new操作符的結果直接作爲SmartPointer的構造函數的參數,像這樣:

SmartPointer<Item> item (new Item (i));

  這個方法明顯更需要自控性,不只是你,而且包括你的程序小組的每個成員。他們都必須發誓出了作資源轉換外不把構造函數用在人以其他用途。幸運的是,這條規矩很容易得以加強。只需要在源文件中查找所有的new即可。

1.2.3 Resource Transfer

  到目前爲止,我們所討論的一直是生命週期在一個單獨的作用域內的資源。現在我們要解決一個困難的問題——如何在不同的作用域間安全的傳遞資源。這一問題在當你處理容器的時候會變得十分明顯。你可以動態的創建一串對象,將它們存放至一個容器中,然後將它們取出,並且在最終安排它們。爲了能夠讓這安全的工作——沒有泄露——對象需要改變其所有者。

  這個問題的一個非常顯而易見的解決方法是使用Smart Pointer,無論是在加入容器前還是還找到它們以後。這是他如何運作的,你加入Release方法到Smart Pointer中:

template <class T>

T * SmartPointer<T>::Release ()

{

T * pTmp = _p;

_p = 0;

return pTmp;

}

  注意在Release調用以後,Smart Pointer就不再是對象的所有者了——它內部的指針指向空。現在,調用了Release都必須是一個負責的人並且迅速隱藏返回的指針到新的所有者對象中。在我們的例子中,容器調用了Release,比如這個Stack的例子:

void Stack::Push (SmartPointer <Item> & item) throw (char *)

{

if (_top == maxStack)

throw "Stack overflow";

_arr [_top++] = item.Release ();

};

  同樣的,你也可以再你的代碼中用加強Release的可靠性。

相應的Pop方法要做些什麼呢?他應該釋放了資源並祈禱調用它的是一個負責的人而且立即作一個資源傳遞它到一個Smart Pointer?這聽起來並不好。

1.2.4 Strong Pointers

  資源管理在內容索引(Windows NT Server上的一部分,現在是Windows 2000)上工作,並且,我對這十分滿意。然後我開始想……這一方法是在這樣一個完整的系統中形成的,如果可以把它內建入語言的本身豈不是一件非常好?我提出了強指針(Strong Pointer)和弱指針(Weak Pointer)。一個Strong Pointer會在許多地方和我們這個SmartPointer相似--它在超出它的作用域後會清除他所指向的對象。資源傳遞會以強指針賦值的形式進行。也可以有Weak Pointer存在,它們用來訪問對象而不需要所有對象--比如可賦值的引用。

  任何指針都必須聲明爲Strong或者Weak,並且語言應該來關注類型轉換的規定。例如,你不可以將Weak Pointer傳遞到一個需要Strong Pointer的地方,但是相反卻可以。Push方法可以接受一個Strong Pointer並且將它轉移到Stack中的Strong Pointer的序列中。Pop方法將會返回一個Strong Pointer。把Strong Pointer的引入語言將會使垃圾回收成爲歷史。

  這裏還有一個小問題--修改C++標準幾乎和競選美國總統一樣容易。當我將我的注意告訴給Bjarne Stroutrup的時候,他看我的眼神好像是我剛剛要向他借一千美元一樣。

然後我突然想到一個念頭。我可以自己實現Strong Pointers。畢竟,它們都很想Smart Pointers。給它們一個拷貝構造函數並重載賦值操作符並不是一個大問題。事實上,這正是標準庫中的auto_ptr有的。重要的是對這些操作給出一個資源轉移的語法,但是這也不是很難。

template <class T>

SmartPointer<T>::SmartPointer (SmartPointer<T> & ptr)

{

_p = ptr.Release ();

}

template <class T>

void SmartPointer<T>::operator = (SmartPointer<T> & ptr)

{

if (_p != ptr._p)

{

delete _p;

_p = ptr.Release ();

}

}

  使這整個想法迅速成功的原因之一是我可以以值方式傳遞這種封裝指針!我有了我的蛋糕,並且也可以吃了。看這個Stack的新的實現:

class Stack

{

enum { maxStack = 3 };

public:

Stack ()

: _top (0)

{}

void Push (SmartPointer<Item> & item) throw (char *)

{

if (_top >= maxStack)

throw "Stack overflow";

_arr [_top++] = item;

}

SmartPointer<Item> Pop ()

{

if (_top == 0)

return SmartPointer<Item> ();

return _arr [--_top];

}

private

int _top;

SmartPointer<Item> _arr [maxStack];

};

  Pop方法強制客戶將其返回值賦給一個Strong Pointer,SmartPointer<Item>。任何試圖將他對一個普通指針的賦值都會產生一個編譯期錯誤,因爲類型不匹配。此外,因爲Pop以值方式返回一個Strong Pointer(Pop的聲明時SmartPointer<Item>後面沒有&符號),編譯器在return時自動進行了一個資源轉換。他調用了operator =來從數組中提取一個Item,拷貝構造函數將他傳遞給調用者。調用者最後擁有了指向Pop賦值的Strong Pointer指向的一個Item

我馬上意識到我已經在某些東西之上了。我開始用了新的方法重寫原來的代碼。

1.2.5 Parser

我過去有一個老的算術操作分析器,是用老的資源管理的技術寫的。分析器的作用是在分析樹中生成節點,節點是動態分配的。例如分析器的Expression方法生成一個表達式節點。我沒有時間用Strong Pointer去重寫這個分析器。我令ExpressionTermFactor方法以傳值的方式將Strong Pointer返回到Node中。看下面的Expression方法的實現:

SmartPointer<Node> Parser::Expression()

{

// Parse a term

SmartPointer<Node> pNode = Term ();

EToken token = _scanner.Token();

if ( token == tPlus || token == tMinus )

{

// Expr := Term { ('+' | '-') Term }

SmartPointer<MultiNode> pMultiNode = new SumNode (pNode);

do

{

_scanner.Accept();

SmartPointer<Node> pRight = Term ();

pMultiNode->AddChild (pRight, (token == tPlus));

token = _scanner.Token();

} while (token == tPlus || token == tMinus);

pNode = up_cast<Node, MultiNode> (pMultiNode);

}

// otherwise Expr := Term

return pNode; // by value!

}

  最開始,Term方法被調用。他傳值返回一個指向NodeStrong Pointer並且立刻把它保存到我們自己的Strong Pointer,pNode中。如果下一個符號不是加號或者減號,我們就簡單的把這個SmartPointer以值返回,這樣就釋放了Node的所有權。另外一方面,如果下一個符號是加號或者減號,我們創建一個新的SumMode並且立刻(直接傳遞)將它儲存到MultiNode的一個Strong Pointer中。這裏,SumNode是從MultiMode中繼承而來的,而MulitNode是從Node繼承而來的。原來的Node的所有權轉給了SumNode

  只要是他們在被加號和減號分開的時候,我們就不斷的創建terms,我們將這些term轉移到我們的MultiNode中,同時MultiNode得到了所有權。最後,我們將指向MultiNodeStrong Pointer向上映射爲指向ModeStrong Pointer,並且將他返回調用着。

  我們需要對Strong Pointers進行顯式的向上映射,即使指針是被隱式的封裝。例如,一個MultiNode是一個Node,但是相同的is-a關係在SmartPointer<MultiNode>SmartPointer<Node>之間並不存在,因爲它們是分離的類(模板實例)並不存在繼承關係。up-cast模板是像下面這樣定義的:

template<class To, class From>

inline SmartPointer<To> up_cast (SmartPointer<From> & from)

{

return SmartPointer<To> (from.Release ());

}

  如果你的編譯器支持新加入標準的成員模板(member template)的話,你可以爲SmartPointer<T>定義一個新的構造函數用來從接受一個class U

template <class T>

template <class U> SmartPointer<T>::SmartPointer (SPrt<U> & uptr)

: _p (uptr.Release ())

{}

  這裏的這個花招是模板在U不是T的子類的時候就不會編譯成功(換句話說,只在U is-a T的時候纔會編譯)。這是因爲uptr的緣故。Release()方法返回一個指向U的指針,並被賦值爲_p,一個指向T的指針。所以如果U不是一個T的話,賦值會導致一個編譯時刻錯誤。

std::auto_ptr

後來我意識到在STL中的auto_ptr模板,就是我的Strong Pointer。在那時候還有許多的實現差異(auto_ptrRelease方法並不將內部的指針清零--你的編譯器的庫很可能用的就是這種陳舊的實現),但是最後在標準被廣泛接受之前都被解決了。

1.2.6 Transfer Semantics

  目前爲止,我們一直在討論在C++程序中資源管理的方法。宗旨是將資源封裝到一些輕量級的類中,並由類負責它們的釋放。特別的是,所有用new操作符分配的資源都會被儲存並傳遞進Strong Pointer(標準庫中的auto_ptr)的內部。

  這裏的關鍵詞是傳遞(passing)。一個容器可以通過傳值返回一個Strong Pointer來安全的釋放資源。容器的客戶只能夠通過提供一個相應的Strong Pointer來保存這個資源。任何一個將結果賦給一個""指針的做法都立即會被編譯器發現。

auto_ptr<Item> item = stack.Pop (); // ok

Item * p = stack.Pop (); // Error! Type mismatch.

  以傳值方式被傳遞的對象有value semantics 或者稱爲 copy semanticsStrong Pointers是以值方式傳遞的--但是我們能說它們有copy semantics嗎?不是這樣的!它們所指向的對象肯定沒有被拷貝過。事實上,傳遞過後,源auto_ptr不在訪問原有的對象,並且目標auto_ptr成爲了對象的唯一擁有者(但是往往auto_ptr的舊的實現即使在釋放後仍然保持着對對象的所有權)。自然而然的我們可以將這種新的行爲稱作Transfer Semantics

  拷貝構造函數(copy construcor)和賦值操作符定義了auto_ptrTransfer Semantics,它們用了非constauto_ptr引用作爲它們的參數。

auto_ptr (auto_ptr<T> & ptr);

auto_ptr & operator = (auto_ptr<T> & ptr);

  這是因爲它們確實改變了他們的源--剝奪了對資源的所有權。

通過定義相應的拷貝構造函數和重載賦值操作符,你可以將Transfer Semantics加入到許多對象中。例如,許多Windows中的資源,比如動態建立的菜單或者位圖,可以用有Transfer Semantics的類來封裝。

1.2.7 Strong Vectors

  標準庫只在auto_ptr中支持資源管理。甚至連最簡單的容器也不支持ownership semantics。你可能想將auto_ptr和標準容器組合到一起可能會管用,但是並不是這樣的。例如,你可能會這樣做,但是會發現你不能夠用標準的方法來進行索引。

vector< auto_ptr<Item> > autoVector;

  這種建造不會編譯成功;

Item * item = autoVector [0];

  另一方面,這會導致一個從autoVectauto_ptr的所有權轉換:

auto_ptr<Item> item = autoVector [0];

  我們沒有選擇,只能夠構造我們自己的Strong Vector。最小的接口應該如下:

template <class T>

class auto_vector

{

public:

explicit auto_vector (size_t capacity = 0);

T const * operator [] (size_t i) const;

T * operator [] (size_t i);

void assign (size_t i, auto_ptr<T> & p);

void assign_direct (size_t i, T * p);

void push_back (auto_ptr<T> & p);

auto_ptr<T> pop_back ();

};

  你也許會發現一個非常防禦性的設計態度。我決定不提供一個對vector的左值索引的訪問,取而代之,如果你想設定(set)一個值的話,你必須用assign或者assign_direct方法。我的觀點是,資源管理不應該被忽視,同時,也不應該在所有的地方濫用。在我的經驗裏,一個strong vector經常被許多push_back方法充斥着。

  Strong vector最好用一個動態的Strong Pointers的數組來實現:

template <class T>

class auto_vector

{

private

void grow (size_t reqCapacity);

auto_ptr<T> *_arr;

size_t _capacity;

size_t _end;

};

  grow方法申請了一個很大的auto_ptr<T>的數組,將所有的東西從老的書組類轉移出來,在其中交換,並且刪除原來的數組。

  auto_vector的其他實現都是十分直接的,因爲所有資源管理的複雜度都在auto_ptr中。例如,assign方法簡單的利用了重載的賦值操作符來刪除原有的對象並轉移資源到新的對象:

void assign (size_t i, auto_ptr<T> & p)

{

_arr [i] = p;

}

  我已經討論了push_backpop_back方法。push_back方法傳值返回一個auto_ptr,因爲它將所有權從auto_vector轉換到auto_ptr中。

  對auto_vector的索引訪問是藉助auto_ptrget方法來實現的,get簡單的返回一個內部指針。

T * operator [] (size_t i)

{

return _arr [i].get ();

}

  沒有容器可以沒有iterator。我們需要一個iteratorauto_vector看起來更像一個普通的指針向量。特別是,當我們廢棄iterator的時候,我們需要的是一個指針而不是auto_ptr。我們不希望一個auto_vectoriterator在無意中進行資源轉換。

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提供了標準的beginend方法來找回iterator

class auto_vector

{

public:

typedef auto_iterator<T> iterator;

iterator begin () { return _arr; }

iterator end () { return _arr + _end; }

}; 

  你也許會問我們是否要利用資源管理重新實現每一個標準的容器?幸運的是,不;事實是strong vector解決了大部分所有權的需求。當你把你的對象都安全的放置到一個strong vector中,你可以用所有其它的容器來重新安排(weakpointer

設想,例如,你需要對一些動態分配的對象排序的時候。你將它們的指針保存到一個strong vector中。然後你用一個標準的vector來保存從strong vector中獲得的weak指針。你可以用標準的算法對這個vector進行排序。這種中介vector叫做permutation vector。相似的,你也可以用標準的maps, priority queues, heaps, hash tables等等。

1.2.8 Code Inspection

  如果你嚴格遵照資源管理的條款,你就不會再資源泄露或者兩次刪除的地方遇到麻煩。你也降低了訪問野指針的機率。同樣的,遵循原有的規則,用delete刪除用new申請的德指針,不要兩次刪除一個指針。你也不會遇到麻煩。但是,那個是更好的注意呢?

  這兩個方法有一個很大的不同點。就是和尋找傳統方法的bug相比,找到違反資源管理的規定要容易的多。後者僅需要一個代碼檢測或者一個運行測試,而前者則在代碼中隱藏得很深,並需要很深的檢查。

  設想你要做一段傳統的代碼的內存泄露檢查。第一件事,你要做的就是grep所有在代碼中出現的new,你需要找出被分配空間地指針都作了什麼。你需要確定導致刪除這個指針的所有的執行路徑。你需要檢查break語句,過程返回,異常。原有的指針可能賦給另一個指針,你對這個指針也要做相同的事。

  相比之下,對於一段用資源管理技術實現的代碼。你也用grep檢查所有的new,但是這次你只需要檢查鄰近的調用:

  ● 這是一個直接的Strong Pointer轉換,還是我們在一個構造函數的函數體中?

  ● 調用的返回知是否立即保存到對象中,構造函數中是否有可以產生異常的代碼。?

  ● 如果這樣的話析構函數中時候有delete?

  下一步,你需要用grep查找所有的release方法,並實施相同的檢查。

  不同點是需要檢查、理解單個執行路徑和只需要做一些本地的檢驗。這難道不是提醒你非結構化的和結構化的程序設計的不同嗎?原理上,你可以認爲你可以應付goto,並且跟蹤所有的可能分支。另一方面,你可以將你的懷疑本地化爲一段代碼。本地化在兩種情況下都是關鍵所在。

  在資源管理中的錯誤模式也比較容易調試。最常見的bug是試圖訪問一個釋放過的strong pointer。這將導致一個錯誤,並且很容易跟蹤。

1.2.9 共享的所有權

  爲每一個程序中的資源都找出或者指定一個所有者是一件很容易的事情嗎?答案是出乎意料的,是!如果你發現了一些問題,這可能說明你的設計上存在問題。還有另一種情況就是共享所有權是最好的甚至是唯一的選擇。

  共享的責任分配給被共享的對象和它的客戶(client)。一個共享資源必須爲它的所有者保持一個引用計數。另一方面,所有者再釋放資源的時候必須通報共享對象。最後一個釋放資源的需要在最後負責free的工作。

  最簡單的共享的實現是共享對象繼承引用計數的類RefCounted

class RefCounted

{

public:

RefCounted () : _count (1) {}

int GetRefCount () const { return _count; }

void IncRefCount () { _count++; }

int DecRefCount () { return --_count; }

private

int _count;

};

  按照資源管理,一個引用計數是一種資源。如果你遵守它,你需要釋放它。當你意識到這一事實的時候,剩下的就變得簡單了。簡單的遵循規則--再構造函數中獲得引用計數,在析構函數中釋放。甚至有一個RefCountedsmart pointer等價物:

template <class T>

class RefPtr

{

public:

RefPtr (T * p) : _p (p) {}

RefPtr (RefPtr<T> & p)

{

_p = p._p;

_p->IncRefCount ();

}

~RefPtr ()

{

if (_p->DecRefCount () == 0)

delete _p;

}

private

T * _p;

};

  注意模板中的T不比成爲RefCounted的後代,但是它必須有IncRefCountDecRefCount的方法。當然,一個便於使用的RefPtr需要有一個重載的指針訪問操作符。在RefPtr中加入轉換語義學(transfer semantics)是讀者的工作。

1.2.10 所有權網絡

  鏈表是資源管理分析中的一個很有意思的例子。如果你選擇表成爲鏈(link)的所有者的話,你會陷入實現遞歸的所有權。每一個link都是它的繼承者的所有者,並且,相應的,餘下的鏈表的所有者。下面是用smart pointer實現的一個表單元:

class Link

{

// ...

private

auto_ptr<Link> _next;

};

  最好的方法是,將連接控制封裝到一個弄構進行資源轉換的類中。

  對於雙鏈表呢?安全的做法是指明一個方向,如forward:

class DoubleLink

{

// ...

private

DoubleLink *_prev;

auto_ptr<DoubleLink> _next;

};

  注意不要創建環形鏈表。

  這給我們帶來了另外一個有趣的問題--資源管理可以處理環形的所有權嗎?它可以,用一個mark-and-sweep的算法。這裏是實現這種方法的一個例子:

template<class T>

class CyclPtr

{

public:

CyclPtr (T * p)

:_p (p), _isBeingDeleted (false)

{}

~CyclPtr ()

{

_isBeingDeleted = true;

if (!_p->IsBeingDeleted ())

delete _p;

}

void Set (T * p)

{

_p = p;

}

bool IsBeingDeleted () const { return _isBeingDeleted; }

private

T * _p;

bool _isBeingDeleted;

};

  注意我們需要用class T來實現方法IsBeingDeleted,就像從CyclPtr繼承。對特殊的所有權網絡普通化是十分直接的。

  將原有代碼轉換爲資源管理代碼

如果你是一個經驗豐富的程序員,你一定會知道找資源的bug是一件浪費時間的痛苦的經歷。我不必說服你和你的團隊花費一點時間來熟悉資源管理是十分值得的。你可以立即開始用這個方法,無論你是在開始一個新項目或者是在一個項目的中期。轉換不必立即全部完成。下面是步驟。

(1)       首先,在你的工程中建立基本的Strong Pointer。然後通過查找代碼中的new來開始封裝裸指針。

(2)       </span

發佈了32 篇原創文章 · 獲贊 7 · 訪問量 11萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章