1.對應的new和delete要采用相同的形式 下面的語句有什麼錯?
string *stringarray = new string[100];
...
delete stringarray;
一切好象都井然有序——一個new對應著一個delete——然而卻隱藏著很大的錯誤:程序的運行情況將是不可猜測的。至少,stringarray指向的100個string對象中的99個不會被正確地摧毀,因為他們的析構函數永遠不會被調用。
用new的時候會發生兩件事。首先,內存被分配(通過operator new 函數,詳見條款7-10和條款m8),然後,為被分配的內存調用一個或多個構造函數。用delete的時候,也有兩件事發生:首先,為將被釋放的內存調用一個或多個析構函數,然後,釋放內存(通過operator delete 函數,詳見條款8和m8)。對於 delete來說會有這樣一個重要的問題:內存中有多少個對象要被刪除?答案決定了將有多少個析構函數會被調用。
這個問題簡單來說就是:要被刪除的指針指向的是單個對象呢,還是對象數組?這只有你來告訴delete。假如你在用delete時沒用括號,delete就會認為指向的是單個對象,否則,它就會認為指向的是一個數組:
string *stringptr1 = new string;
string *stringptr2 = new string[100];
...
delete stringptr1;// 刪除一個對象
delete [] stringptr2;// 刪除對象數組
假如你在stringptr1前加了"[]"會怎樣呢?答案是:那將是不可猜測的;假如你沒在stringptr2前沒加上"[]"又會怎樣呢?答案也是:不可猜測。而且對於象int這樣的固定類型來說,結果也是不可猜測的,即使這樣的類型沒有析構函數。所以,解決這類問題的規則很簡單:假如你調用 new時用了[],調用delete時也要用[]。假如調用new時沒有用[],那調用delete時也不要用[]。
在寫一個包含指針數據成員,並且提供多個構造函數的類時,牢記這一規則尤其重要。因為這樣的話,你就必須在所有初始化指針成員的構造函數裡采用相同的new的形式。否則,析構函數裡將采用什麼形式的delete呢?關於這一話題的進一步闡述,參見條款11。
這個規則對喜歡用typedef的人來說也很重要,因為寫typedef的程序員必須告訴別人,用new創建了一個typedef定義的類型的對象後,該用什麼形式的delete來刪除。舉例如下:
typedef string addresslines[4]; //一個人的地址,共4行,每行一個string
//因為addresslines是個數組,使用new:
string *pal = new addresslines; // 注重"new addresslines"返回string*, 和
// "new string[4]"返回的一樣
delete時必須以數組形式與之對應:
delete pal;// 錯誤!
delete [] pal;// 正確
為了避免混亂,最好杜絕對數組類型用typedefs。這其實很輕易,因為標准c++庫(見條款49)包含有stirng和vector模板,使用他們將會使對數組的需求減少到幾乎零。舉例來說,addresslines可以定義為一個字符串(string)的向量(vector),即 addresslines可定義為vector類型。
2.析構函數裡對指針成員調用delete
大多數情況下,執行動態內存分配的的類都在構造函數裡用new分配內存,然後在析構函數裡用delete釋放內存。最初寫這個類的時候當然不難做,你會記得最後對在所有構造函數裡分配了內存的所有成員使用delete。
然而,這個類經過維護、升級後,情況就會變得困難了,因為對類的代碼進行修改的程序員不一定就是最早寫這個類的人。而增加一個指針成員意味著幾乎都要進行下面的工作:
·在每個構造函數裡對指針進行初始化。對於一些構造函數,假如沒有內存要分配給指針的話,指針要被初始化為0(即空指針)。
·刪除現有的內存,通過賦值操作符分配給指針新的內存。
·在析構函數裡刪除指針。
假如在構造函數裡忘了初始化某個指針,或者在賦值操作的過程中忘了處理它,問題會出現得很快,很明顯,所以在實踐中這兩個問題不會那麼折磨你。但是,假如在析構函數裡沒有刪除指針,它不會表現出很明顯的外部症狀。相反,它可能只是表現為一點微小的內存洩露,並且不斷增長,最後吞噬了你的地址空間,導致程序夭折。因為這種情況經常不那麼引人注重,所以每增加一個指針成員到類裡時一定要記清楚。
另外,刪除空指針是安全的(因為它什麼也沒做)。所以,在寫構造函數,賦值操作符,或其他成員函數時,類的每個指針成員要麼指向有效的內存,要麼就指向空,那在你的析構函數裡你就可以只用簡單地delete掉他們,而不用擔心他們是不是被new過。
當然對本條款的使用也不要絕對。例如,你當然不會用delete去刪除一個沒有用new來初始化的指針,而且,就象用智能指針對象時不用勞你去刪除一樣,你也永遠不會去刪除一個傳遞給你的指針。換句話說,除非類成員最初用了new,否則是不用在析構函數裡用delete的。
說到智能指針,這裡介紹一種避免必須刪除指針成員的方法,即把這些成員用智能指針對象來代替,比如c++標准庫裡的auto_ptr。想知道它是如何工作的,看看條款m9和m10。
3.預先預備好內存不夠的情況
operator new在無法完成內存分配請求時會拋出異常(以前的做法一般是返回0,一些舊一點的編譯器還這麼做。你願意的話也可以把你的編譯器設置成這樣。關於這個話題我將推遲到本條款的結尾處討論)。大家都知道,處理內存不夠所產生的異常真可以算得上是個道德上的行為,但實際做起來又會象刀架在脖子上那樣痛苦。所以,你有時會不去管它,也許一直沒去管它。但你心裡一定還是深深地隱藏著一種罪惡感:萬一new真的產生了異常怎麼辦?
你會很自然地想到處理這種情況的一種方法,即回到以前的老路上去,使用預處理。例如,c的一種常用的做法是,定義一個類型無關的宏來分配內存並檢查分配是否成功。對於c++來說,這個宏看起來可能象這樣:
#define new(ptr, type)
try { (ptr) = new type; }
catch (std::bad_alloc&) { assert(0); }
(“慢!std::bad_alloc是做什麼的?”你會問。bad_alloc是operator new不能滿足內存分配請求時拋出的異常類型,std是bad_alloc所在的名字空間(見條款28)的名稱。“好!”你會繼續問,“assert又有什麼用?”假如你看看標准c頭文件(或與它相等價的用到了名字空間的版本,見條款49),就會發現assert是個宏。這個宏檢查傳給它的表達式是否非零,假如不是非零值,就會發出一條出錯信息並調用abort。assert只是在沒定義標准宏ndebug的時候,即在調試狀態下才這麼做。在產品發布狀態下,即定義了ndebug的時候,assert什麼也不做,相當於一條空語句。所以你只能在調試時才能檢查斷言(assertion))。
new宏不但有著上面所說的通病,即用assert去檢查可能發生在已發布程序裡的狀態(然而任何時候都可能發生內存不夠的情況),同時,它還在c ++裡有另外一個缺陷:它沒有考慮到new有各種各樣的使用方式。例如,想創建類型t對象,一般有三種常見的語法形式,你必須對每種形式可能產生的異常都要進行處理:
new t;
new t(constrUCtor arguments);
new t[size];
這裡對問題大大進行了簡化,因為有人還會自定義(重載)operator new,所以程序裡會包含任意個使用new的語法形式。
那麼,怎麼辦?假如想用一個很簡單的出錯處理方法,可以這麼做:當內存分配請求不能滿足時,調用你預先指定的一個出錯處理函數。這個方法基於一個常規,即當operator new不能滿足請求時,會在拋出異常之前調用客戶指定的一個出錯處理函數——一般稱為new-handler函數。(operator new實際工作起來要復雜一些,詳見條款8)
指定出錯處理函數時要用到set_new_handler函數,它在頭文件裡大致是象下面這樣定義的:
typedef void (*new_handler)();
new_handler set_new_handler(new_handler p) throw();
可以看到,new_handler是一個自定義的函數指針類型,它指向一個沒有輸入參數也沒有返回值的函數。set_new_handler則是一個輸入並返回new_handler類型的函數。
set_new_handler的輸入參數是operator new分配內存失敗時要調用的出錯處理函數的指針,返回值是set_new_handler沒調用之前就已經在起作用的舊的出錯處理函數的指針。
可以象下面這樣使用set_new_handler:
// function to call if operator new can't allocate enough memory
void nomorememory()
{
cerr << "unable to satisfy request for memory
";
abort();
}
int main()
{
set_new_handler(nomorememory);
int *pbigdataarray = new int[100000000];
...
}
假如operator new不能為100,000,000個整數分配空間,nomorememory將會被調用,程序發出一條出錯信息後終止。這就比簡單地讓系統內核產生錯誤信息來結束程序要好。(順便考慮一下,假如cerr在寫錯誤信息的過程中要動態分配內存,那將會發生什麼...)
operator new不能滿足內存分配請求時,new-handler函數不只調用一次,而是不斷重復,直至找到足夠的內存。實現重復調用的代碼在條款8裡可以看到,這裡我用描述性的的語言來說明:一個設計得好的new-handler函數必須實現下面功能中的一種。
·產生更多的可用內存。這將使operator new下一次分配內存的嘗試有可能獲得成功。實施這一策略的一個方法是:在程序啟動時分配一個大的內存塊,然後在第一次調用new-handler時釋放。釋放時伴隨著一些對用戶的警告信息,如內存數量太少,下次請求可能會失敗,除非又有更多的可用空間。
·安裝另一個不同的new-handler函數。假如當前的new-handler函數不能產生更多的可用內存,可能它會知道另一個new- handler函數可以提供更多的資源。這樣的話,當前的new-handler可以安裝另一個new-handler來取代它(通過調用 set_new_handler)。下一次operator new調用new-handler時,會使用最近安裝的那個。(這一策略的另一個變通辦法是讓new-handler可以改變它自己的運行行為,那麼下次調用時,它將做不同的事。方法是使new-handler可以修改那些影響它自身行為的靜態或全局數據。)
·卸除new-handler。也就是傳遞空指針給set_new_handler。沒有安裝new-handler,operator new分配內存不成功時就會拋出一個標准的std::bad_alloc類型的異常。
·拋出std::bad_alloc或從std::bad_alloc繼續的其他類型的異常。這樣的異常不會被operator new捕捉,所以它們會被送到最初進行內存請求的地方。(拋出別的不同類型的異常會違反operator new異常規范。規范中的缺省行為是調用abort,所以new-handler要拋出一個異常時,一定要確信它是從std::bad_alloc繼續來的。想更多地了解異常規范,參見條款m14。)
·沒有返回。典型做法是調用abort或exit。abort/exit可以在標准c庫中找到(還有標准c++庫,參見條款49)。
上面的選擇給了你實現new-handler函數極大的靈活性。
處理內存分配失敗的情況時采取什麼方法,取決於要分配的對象的類:
class x {
public:
static void
outofmemory();
...
};
class y {
public:
static void outofmemory();
...
};
x* p1 = new x; // 若分配成功,調用x::outofmemory
y* p2 = new y; // 若分配不成功,調用y::outofmemory
c++不支持專門針對於類的new-handler函數,而且也不需要。你可以自己來實現它,只要在每個類中提供自己版本的set_new_handler和operator new。類的set_new_handler可以為類指定new-handler(就象標准的set_new_handler指定全局new-handler一樣)。類的operator new則保證為類的對象分配內存時用類的new-handler取代全局new-handler。
假設處理類x內存分配失敗的情況。因為operator new對類型x的對象分配內存失敗時,每次都必須調用出錯處理函數,所以要在類裡聲明一個new_handler類型的靜態成員。那麼類x看起來會象這樣:
class x {
public:
static new_handler set_new_handler(new_handler p);
static void * operator new(size_t size);
private:
static new_handler currenthandler;
};
類的靜態成員必須在類外定義。因為想借用靜態對象的缺省初始化值0,所以定義x::currenthandler時沒有去初始化。
new_handler x::currenthandler; //缺省設置currenthandler為0(即null)
類x中的set_new_handler函數會保存傳給它的任何指針,並返回在調用它之前所保存的任何指針。這正是標准版本的set_new_handler所做的:
new_handler x::set_new_handler(new_handler p)
{
new_handler oldhandler = currenthandler;
currenthandler = p;
return oldhandler;
}
最後看看x的operator new所做的:
1. 調用標准set_new_handler函數,輸入參數為x的出錯處理函數。這使得x的new-handler函數成為全局new-handler函數。注重下面的代碼中,用了"::"符號顯式地引用std空間(標准set_new_handler函數就存在於std空間)。
2. 調用全局operator new分配內存。假如第一次分配失敗,全局operator new會調用x的new-handler,因為它剛剛(見1.)被安裝成為全局new-handler。假如全局operator new最終未能分配到內存,它拋出std::bad_alloc異常,x的operator new會捕捉到它。x的operator new然後恢復最初被取代的全局new-handler函數,最後以拋出異常返回。
3. 假設全局operator new為類型x的對象分配內存成功,, x的operator new會再次調用標准set_new_handler來恢復最初的全局出錯處理函數。最後返回分配成功的內存的指針。
c++是這麼做的:
void * x::operator new(size_t size)
{
new_handler globalhandler = // 安裝x的new_handler
std::set_new_handler(currenthandler);
void *memory;
try { // 嘗試分配內存
memory = ::operator new(size);
}
catch (std::bad_alloc&) { // 恢復舊的new_handler
std::set_new_handler(globalhandler);
throw; // 拋出異常
}
std::set_new_handler(globalhandler); // 恢復舊的new_handler
return memory;
}
假如你對上面重復調用std::set_new_handler看不順眼,可以參見條款m9來除去它們。
使用類x的內存分配處理功能時大致如下:
void nomorememory();// x的對象分配內存失敗時調用的new_handler函數的聲明
x::set_new_handler(nomorememory);
// 把nomorememory設置為x的
// new-handling函數
x *px1 = new x;
// 如內存分配失敗,
// 調用nomorememory
string *ps = new string;
// 如內存分配失敗,調用全局new-handling函數
x::set_new_handler(0);
// 設x的new-handling函數為空
x *px2 = new x;
// 如內存分配失敗,立即拋出異常
// (類x沒有new-handling函數)
你會注重到,處理以上類似情況,假如不考慮類的話,實現代碼是一樣的,這就很自然地想到在別的地方也能重用它們。正如條款41所說明的,繼續和模板可以用來設計可重用代碼。在這裡,我們把兩種方法結合起來使用,從而滿足了你的要求。
你只要創建一個“混合風格”(mixin-style)的基類,這種基類答應子類繼續它某一特定的功能——這裡指的是建立一個類的new-handler的功能。之所以設計一個基類,是為了讓所有的子類可以繼續set_new_handler和operator new功能,而設計模板是為了使每個子類有不同的currenthandler數據成員。這聽起來很復雜,不過你會看到代碼其實很熟悉。區別只不過是它現在可以被任何類重用了。
template // 提供類set_new_handler支持的
class newhandlersupport { // 混合風格”的基類
public:
static new_handler set_new_handler(new_handler p);
static void * operator new(size_t size);
private:
static new_handler currenthandler;
};
template
new_handler newhandlersupport::set_new_handler(new_handler p)
{
new_handler oldhandler = currenthandler;
currenthandler = p;
return oldhandler;
}
template
void * newhandlersupport::operator new(size_t size)
{
new_handler globalhandler =
std::set_new_handler(currenthandler);
void *memory;
try {
memory = ::operator new(size);
}
catch (std::bad_alloc&) {
std::set_new_handler(globalhandler);
throw;
}
std::set_new_handler(globalhandler);
return memory;
}
// this sets each currenthandler to 0
template
new_handler newhandlersupport::currenthandler;
有了這個模板類,對類x加上set_new_handler功能就很簡單了:只要讓x從newhandlersupport繼續:
// note inheritance from mixin base class template. (see
// my article on counting objects for information on why
// private inheritance might be preferable here.)
class x: public newhandlersupport {
... // as before, but no declarations for
}; // set_new_handler or operator new
使用x的時候依然不用理會它幕後在做些什麼;老代碼依然工作。這很好!那些你常不去理會的東西往往是最可信賴的。
使用set_new_handler是處理內存不夠情況下一種方便,簡單的方法。這比把每個new都包裝在try模塊裡當然好多了。而且, newhandlersupport這樣的模板使得向任何類增加一個特定的new-handler變得更簡單。“混合風格”的繼續不可避免地將話題引入到多繼續上去,在轉到這個話題前,你一定要先閱讀條款43。
1993年前,c++一直要求在內存分配失敗時operator new要返回0,現在則是要求operator new拋出std::bad_alloc異常。很多c++程序是在編譯器開始支持新規范前寫的。c++標准委員會不想放棄那些已有的遵循返回0規范的代碼,所以他們提供了另外形式的operator new(以及operator new[]——見條款8)以繼續提供返回0功能。這些形式被稱為“無拋出”,因為他們沒用過一個throw,而是在使用new的入口點采用了nothrow對象:
class widget { ... };
widget *pw1 = new widget;// 分配失敗拋出std::bad_alloc if
if (pw1 == 0) ... // 這個檢查一定失敗
widget *pw2 = new (nothrow) widget; // 若分配失敗返回0
if (pw2 == 0) ... // 這個檢查可能會成功
不管是用“正規”(即拋出異常)形式的new還是“無拋出”形式的new,重要的是你必須為內存分配失敗做好預備。最簡單的方法是使用set_new_handler,因為它對兩種形式都有用。
4.寫operator new和operator delete時要遵循常規
自己重寫operator new時(條款10解釋了為什麼有時要重寫它),很重要的一點是函數提供的行為要和系統缺省的operator new一致。實際做起來也就是:要有正確的返回值;可用內存不夠時要調用出錯處理函數(見條款7);處理好0字節內存請求的情況。此外,還要避免不小心隱藏了標准形式的new,不過這是條款9的話題。
有關返回值的部分很簡單。假如內存分配請求成功,就返回指向內存的指針;假如失敗,則遵循條款7的規定拋出一個std::bad_alloc類型的異常。
但事情也不是那麼簡單。因為operator new實際上會不只一次地嘗試著去分配內存,它要在每次失敗後調用出錯處理函數,還期望出錯處理函數能想辦法釋放別處的內存。只有在指向出錯處理函數的指針為空的情況下,operator new才拋出異常。
另外,c++標准要求,即使在請求分配0字節內存時,operator new也要返回一個合法指針。(實際上,這個聽起來怪怪的要求確實給c++語言其它地方帶來了簡便)
這樣,非類成員形式的operator new的偽代碼看起來會象下面這樣:
void * operator new(size_t size) // operator new還可能有其它參數
{
if (size == 0) { // 處理0字節請求時,
size = 1; // 把它當作1個字節請求來處理
}
while (1) {
分配size字節內存;
if (分配成功)
return (指向內存的指針);
// 分配不成功,找出當前出錯處理函數
new_handler globalhandler = set_new_handler(0);
set_new_handler(globalhandler);
if (globalhandler) (*globalhandler)();
else throw std::bad_alloc();
}
}
處理零字節請求的技巧在於把它作為請求一個字節來處理。這看起來也很怪,但簡單,合法,有效。而且,你又會多久碰到一次零字節請求的情況呢?
你又會希奇上面的偽代碼中為什麼把出錯處理函數置為0後又立即恢復。這是因為沒有辦法可以直接得到出錯處理函數的指針,所以必須通過調用set_new_handler來找到。辦法很笨但也有效。
條款7提到operator new內部包含一個無限循環,上面的代碼清楚地說明了這一點——while (1)將導致無限循環。跳出循環的唯一辦法是內存分配成功或出錯處理函數完成了條款7所描述的事件中的一種:得到了更多的可用內存;安裝了一個新的new -handler(出錯處理函數);卸除了new-handler;拋出了一個std::bad_alloc或其派生類型的異常;或者返回失敗。現在明白了為什麼new-handler必須做這些工作中的一件。假如不做,operator new裡面的循環就不會結束。
很多人沒有熟悉到的一點是operator new經常會被子類繼續。這會導致某些復雜性。上面的偽代碼中,函數會去分配size字節的內存(除非size為0)。size很重要,因為它是傳遞給函數的參數。但是大多數針對類所寫的operator new(包括條款10中的那種)都是只為特定的類設計的,不是為所有的類,也不是為它所有的子類設計的。這意味著,對於一個類x的operator new來說,函數內部的行為在涉及到對象的大小時,都是精確的sizeof(x):不會大也不會小。但由於存在繼續,基類中的operator new可能會被調用去為一個子類對象分配內存:
class base {
public:
static void * operator new(size_t size);
...
};
class derived: public base // derived類沒有聲明operator new
{ ... }; //
derived *p = new derived; // 調用base::operator new
假如base類的operator new不想費功夫專門去處理這種情況——這種情況出現的可能性不大——那最簡單的辦法是把這個“錯誤”數量的內存分配請求轉給標准operator new來處理,象下面這樣:
void * base::operator new(size_t size)
{
if (size != sizeof(base)) // 假如數量“錯誤”,讓標准operator new
return ::operator new(size); // 去處理這個請求
//
... // 否則處理這個請求
}
“停!”我聽見你在叫,“你忘了檢查一種雖然不合理但是有可能出現的一種情況——size有可能為零!”是的,我沒檢查,但拜托下次再叫出聲的時候不要這麼文绉绉的。:)但實際上檢查還是做了,只不過融合到size != sizeof(base)語句中了。c++標准很怪異,其中之一就是規定所以獨立的(freestanding)類的大小都是非零值。所以sizeof(base)永遠不可能是零(即使base類沒有成員),假如size為零,請求會轉到::operator new,由它來以一種合理的方式對請求進行處理。(有趣的是,假如base不是獨立的類,sizeof(base)有可能是零,具體說明參見"my article on counting objects")。
假如想控制基於類的數組的內存分配,必須實現operator new的數組形式——operator new[](這個函數常被稱為“數組new”,因為想不出"operator new[]")該怎麼發音)。寫operator new[]時,要記住你面對的是“原始”內存,不能對數組裡還不存在的對象進行任何操作。實際上,你甚至還不知道數組裡有多少個對象,因為不知道每個對象有多大。基類的operator new[]會通過繼續的方式被用來為子類對象的數組分配內存,而子類對象往往比基類要大。所以,不能想當然認為base::operator new[]裡的每個對象的大小都是sizeof(base),也就是說,數組裡對象的數量不一定就是(請求字節數)/sizeof(base)。關於operator new[]的具體介紹參見條款m8。
重寫operator new(和operator new[])時所有要遵循的常規就這些。對於operator delete(以及它的伙伴operator delete[]),情況更簡單。所要記住的只是,c++保證刪除空指針永遠是安全的,所以你要充分地應用這一保證。下面是非類成員形式的operator delete的偽代碼:
void operator delete(void *rawmemory)
{
if (rawmemory == 0) return; file://如/果指針為空,返回
//
釋放rawmemory指向的內存;
return;
}
這個函數的類成員版本也簡單,只是還必須檢查被刪除的對象的大小。假設類的operator new將“錯誤”大小的分配請求轉給::operator new,那麼也必須將“錯誤”大小的刪除請求轉給::operator delete:
class base { // 和前面一樣,只是這裡聲明了
public: // operator delete
static void * operator new(size_t size);
static void operator delete(void *rawmemory, size_t size);
...
};
void base::operator delete(void *rawmemory, size_t size)
{
if (rawmemory == 0) return; // 檢查空指針
if (size != sizeof(base)) { // 假如size"錯誤",
::operator delete(rawmemory); // 讓標准operator來處理請求
return;
}
釋放指向rawmemory的內存;
return;
}
可見,有關operator new和operator delete(以及他們的數組形式)的規定不是那麼麻煩,重要的是必須遵守它。只要內存分配程序支持new-handler函數並正確地處理了零內存請求,就差不多了;假如內存釋放程序又處理了空指針,那就沒其他什麼要做的了。至於在類成員版本的函數裡增加繼續支持,那將很快就可以完成。
5.避免隱藏標准形式的new
因為內部范圍聲明的名稱會隱藏掉外部范圍的相同的名稱,所以對於分別在類的內部
和全局聲明的兩個相同名字的函數f來說,類的成員函數會隱藏掉全局函數:
void f(); // 全局函數
class x {
public:
void f(); // 成員函數
};
x x;
f(); // 調用 f
x.f(); // 調用 x::f
這不會令人驚奇,也不會導致混淆,因為調用全局函數和成員函數時總是采用不同的
語法形式。然而假如你在類裡增加了一個帶多個參數的operator new函數,結果就有
可能令人大吃一驚。
class x {
public:
void f();
// operator new的參數指定一個
// new-hander(new的出錯處理)函數
static void * operator new(size_t size, new_handler p);
};
void specialerrorhandler(); // 定義在別的地方
x *px1 =
new (specialerrorhandler) x; // 調用x::operator new
x *px2 = new x; // 錯誤!
在類裡定義了一個稱為“operator new”的函數後,會不經意地阻止了對標准new的訪
問。條款50解釋了為什麼會這樣,這裡我們更關心的是如何想個辦法避免這個問題。
一個辦法是在類裡寫一個支持標准new調用方式的operator new,它和標准new做同樣
的事。這可以用一個高效的內聯函數來封裝實現。
class x {
public:
void f();
static void * operator new(size_t size, new_handler p);
static void * operator new(size_t size)
{ return ::operator new(size); }
};
x *px1 =
new (specialerrorhandler) x; // 調用 x::operator
// new(size_t, new_handler)
x* px2 = new x; // 調用 x::operator
// new(size_t)
另一種方法是為每一個增加到operator new的參數提供缺省值(見條款24):
class x {
public:
void f();
static
void * operator new(size_t size, // p缺省值為0
new_handler p = 0); //
};
x *px1 = new (specialerrorhandler) x; // 正確
x* px2 = new x; // 也正確
無論哪種方法,假如以後想對“標准”形式的new定制新的功能,只需要重寫這個函數。
調用者重新編譯鏈接後就可以使用新功能了。
6. 假如寫了operator new就要同時寫operator delete
讓我們回過頭去看看這樣一個基本問題:為什麼有必要寫自己的operator new和operator delete?
答案通常是:為了效率。缺省的operator new和operator delete具有非常好的通用性,它的這種靈活性也使得在某些特定的場合下,可以進一步改善它的性能。尤其在那些需要動態分配大量的但很小的對象的應用程序裡,情況更是如此。
例如有這樣一個表示飛機的類:類airplane只包含一個指針,它指向的是飛機對象的實際描述(此技術在條款34進行說明):
class airplanerep { ... }; // 表示一個飛機對象
//
class airplane {
public:
...
private:
airplanerep *rep; // 指向實際描述
};
一個airplane對象並不大,它只包含一個指針(正如條款14和m24所說明的,假如airplane類聲明了虛函數,會隱式包含第二個指針)。但當調用operator new來分配一個airplane對象時,得到的內存可能要比存儲這個指針(或一對指針)所需要的要多。之所以會產生這種看起來很希奇的行為,在於operator new和operator delete之間需要互相傳遞信息。
因為缺省版本的operator new是一種通用型的內存分配器,它必須可以分配任意大小的內存塊。同樣,operator delete也要可以釋放任意大小的內存塊。operator delete想弄清它要釋放的內存有多大,就必須知道當初operator new分配的內存有多大。有一種常用的方法可以讓operator new來告訴operator delete當初分配的內存大小是多少,就是在它所返回的內存裡預先附帶一些額外信息,用來指明被分配的內存塊的大小。也就是說,當你寫了下面的語句,
airplane *pa = new airplane;
你不會得到一塊看起來象這樣的內存塊:
pa——> airplane對象的內存
而是得到象這樣的內存塊:
pa——> 內存塊大小數據 + airplane對象的內存
對於象airplane這樣很小的對象來說,這些額外的數據信息會使得動態分配對象時所需要的的內存的大小翻番(非凡是類裡沒有虛擬函數的時候)。
假如軟件運行在一個內存很寶貴的環境中,就承受不起這種奢侈的內存分配方案了。為airplane類專門寫一個operator new,就可以利用每個airplane的大小都相等的特點,不必在每個分配的內存塊上加上附帶信息了。
具體來說,有這樣一個方法來實現你的自定義的operator new:先讓缺省operator new分配一些大塊的原始內存,每塊的大小都足以容納很多個airplane對象。airplane對象的內存塊就取自這些大的內存塊。當前沒被使用的內存塊被組織成鏈表——稱為自由鏈表——以備未來airplane使用。聽起來好象每個對象都要承擔一個next域的開銷(用於支持鏈表),但不會:rep 域的空間也被用來存儲next指針(因為只是作為airplane對象來使用的內存塊才需要rep指針;同樣,只有沒作為airplane對象使用的內存塊才需要next指針),這可以用union來實現。
具體實現時,就要修改airplane的定義,從而支持自定義的內存治理。可以這麼做:
class airplane { // 修改後的類 — 支持自定義的內存治理
public: //
static void * operator new(size_t size);
...
private:
union {
airplanerep *rep; // 用於被使用的對象
airplane *next; // 用於沒被使用的(在自由鏈表中)對象
};
// 類的常量,指定一個大的內存塊中放多少個
// airplane對象,在後面初始化
static const int block_size;
static airplane *headoffreelist;
};
上面的代碼增加了的幾個聲明:一個operator new函數,一個聯合(使得rep和next域占用同樣的空間),一個常量(指定大內存塊的大小),一個靜態指針(跟蹤自由鏈表的表頭)。表頭指針聲明為靜態成員很重要,因為整個類只有一個自由鏈表,而不是每個airplane對象都有。
下面該寫operator new函數了:
void * airplane::operator new(size_t size)
{
// 把“錯誤”大小的請求轉給::operator new()處理;
// 詳見條款8
if (size != sizeof(airplane))
return ::operator new(size);
airplane *p = // p指向自由鏈表的表頭
headoffreelist; //
// p 若合法,則將表頭移動到它的下一個元素
//
if (p)
headoffreelist = p->next;
else {
// 自由鏈表為空,則分配一個大的內存塊,
// 可以容納block_size個airplane對象
airplane *newblock =
static_cast(::operator new(block_size *
sizeof(airplane)));
// 將每個小內存塊鏈接起來形成一個新的自由鏈表
// 跳過第0個元素,因為它要被返回給operator new的調用者
//
for (int i = 1; i < block_size-1; ++i)
newblock[i].next = &newblock[i+1];
// 用空指針結束鏈表
newblock[block_size-1].next = 0;
// p 設為表的頭部,headoffreelist指向的
// 內存塊緊跟其後
p = newblock;
headoffreelist = &newblock[1];
}
return p;
}
假如你讀了條款8,就會知道在operator new不能滿足內存分配請求時,會執行一系列與new-handler函數和例外有關的例行性動作。上面的代碼沒有這些步驟,這是因為operator new治理的內存都是從::operator new分配來的。這意味著只有::operator new失敗時,operator new才會失敗。而假如::operator new失敗,它會去執行new-handler的動作(可能最後以拋出異常結束),所以不需要airplane的operator new也去處理。換句話說,其實new-handler的動作都還在,你只是沒看見,它隱藏在::operator new裡。
有了operator new,下面要做的就是給出airplane的靜態數據成員的定義:
airplane *airplane::headoffreelist;
const int airplane::block_size = 512;
沒必要顯式地將headoffreelist設置為空指針,因為靜態成員的初始值都被缺省設為0。block_size決定了要從::operator new獲得多大的內存塊。
這個版本的operator new將會工作得非常好。它為airplane對象分配的內存要比缺省operator new更少,而且運行得更快,可能會快2次方的等級。這沒什麼希奇的,通用型的缺省operator new必須應付各種大小的內存請求,還要處理內部外部的碎片;而你的operator new只用操作鏈表中的一對指針。拋棄靈活性往往可以很輕易地換來速度。
下面我們將討論operator delete。還記得operator delete嗎?本條款就是關於operator delete的討論。但直到現在為止,airplane類只聲明了operator new,還沒聲明operator delete。想想假如寫了下面的代碼會發生什麼:
airplane *pa = new airplane; // 調用
// airplane::operator new
...
delete pa; // 調用 ::operator delete
讀這段代碼時,假如你豎起耳朵,會聽到飛機撞毀燃燒的聲音,還有程序員的哭泣。問題出在operator new(在airplane裡定義的那個)返回了一個不帶頭信息的內存的指針,而operator delete(缺省的那個)卻假設傳給它的內存包含頭信息。這就是悲劇產生的原因。
這個例子說明了一個普遍原則:operator new和operator delete必須同時寫,這樣才不會出現不同的假設。假如寫了一個自己的內存分配程序,就要同時寫一個釋放程序。(關於為什麼要遵循這條規定的另一個理由,參見article on counting objects一文的the sidebar on placement章節)
因而,繼續設計airplane類如下:
class airplane { // 和前面的一樣,只不過增加了一個
public: // operator delete的聲明
...
static void operator delete(void *deadobject,
size_t size);
};
// 傳給operator delete的是一個內存塊, 假如
// 其大小正確,就加到自由內存塊鏈表的最前面
//
void airplane::operator delete(void *deadobject,
size_t size)
{
if (deadobject == 0) return; // 見條款 8
if (size != sizeof(airplane)) { // 見條款 8
::operator delete(deadobject);
return;
}
airplane *carcass =
static_cast(deadobject);
carcass->next = headoffreelist;
headoffreelist = carcass;
}
因為前面在operator new裡將“錯誤”大小的請求轉給了全局operator new(見條款8),那麼這裡同樣要將“錯誤”大小的對象交給全局operator delete來處理。假如不這樣,就會重現你前面費盡心思想避免的那種問題——new和delete句法上的不匹配。
有趣的是,假如要刪除的對象是從一個沒有虛析構函數的類繼續而來的,那傳給operator delete的size_t值有可能不正確。這就是必須保證基類必須要有虛析構函數的原因,此外條款14還列出了第二個、理由更充足的原因。這裡只要簡單地記住,基類假如遺漏了虛擬構函數,operator delete就有可能工作不正確。
所有一切都很好,但從你皺起的眉頭我可以知道你一定在擔心內存洩露。有著大量開發經驗的你不會沒注重到,airplane的operator new調用::operator new 得到了大塊內存,但airplane的operator delete卻沒有釋放它們。內存洩露!內存洩露!我分明聽見了警鐘在你腦海裡回響。
但請仔細聽我回答,這裡沒有內存洩露!
引起內存洩露的原因在於內存分配後指向內存的指針丟失了。假如沒有垃圾處理或其他語言之外的機制,這些內存就不會被收回。但上面的設計沒有內存洩露,因為它決不會出現內存指針丟失的情況。每個大內存塊首先被分成airplane大小的小塊,然後這些小塊被放在自由鏈表上。當客戶調用 airplane::operator new時,小塊被自由鏈表移除,客戶得到指向小塊的指針。當客戶調用operator delete時,小塊被放回到自由鏈表上。采用這種設計,所有的內存塊要不被airplane對象使用(這種情況下,是由客戶來負責避免內存洩露),要不就在自由鏈表上(這種情況下內存塊有指針)。所以說這裡沒有內存洩露。
然而確實,::operator new返回的內存塊是從來沒有被airplane::operator delete釋放,這個內存塊有個名字,叫內存池。但內存洩漏和內存池有一個重要的不同之處。內存洩漏會無限地增長,即使客戶循規蹈矩;而內存池的大小決不會超過客戶請求內存的最大值。
修改airplane的內存治理程序使得::operator new返回的內存塊在不被使用時自動釋放並不難,但這裡不會這麼做,這有兩個原因:第一個原因和你自定義內存治理的初衷有關。你有很多理由去自定義內存治理,最基本的一條是你確認缺省的operator new和operator delete使用了太多的內存或(並且)運行很慢。和采用內存池策略相比,跟蹤和釋放那些大內存塊所寫的每一個額外的字節和每一條額外的語句都會導致軟件運行更慢,用的內存更多。在設計性能要求很高的庫或程序時,假如你預計內存池的大小會在一個合理的范圍之內,那采用內存池的方法再好不過了。
第二個原因和處理一些不合理的程序行為有關。假設airplane的內存治理程序被修改了,airplane的operator delete可以釋放任何沒有對象存在的大塊的內存。那看下面的程序: int main()
{
airplane *pa = new airplane; // 第一次分配: 得到大塊內存,
// 生成自由鏈表,等
delete pa; // 內存塊空;
// 釋放它
pa = new airplane; // 再次得到大塊內存,
// 生成自由鏈表,等
delete pa; // 內存塊再次空,
// 釋放
... // 你有了想法...
return 0;
}
這個糟糕的小程序會比用缺省的operator new和operator delete寫的程序運行得還慢,占用還要多的內存,更不要和用內存池寫的程序比了。
當然有辦法處理這種不合理的情況,但考慮的非凡情況越多,就越有可能要重新實現內存治理函數,而最後你又會得到什麼呢?內存池不能解決所有的內存治理問題,在很多情況下是很適合的。
實際開發中,你會經常要給許多不同的類實現基於內存池的功能。你會想,“一定有什麼辦法把這種固定大小內存的分配器封裝起來,從而可以方便地使用”。是的,有辦法。雖然我在這個條款已經唠叨這麼長時間了,但還是要簡單介紹一下,具體實現留給讀者做練習。
下面簡單給出了一個pool類的最小接口(見條款18),pool類的每個對象是某類對象(其大小在pool的構造函數裡指定)的內存分配器。
class pool {
public:
pool(size_t n); // 為大小為n的對象創建
// 一個分配器
void * alloc(size_t n) ; // 為一個對象分配足夠內存
// 遵循條款8的operator new常規
void free( void *p, size_t n); // 將p所指的內存返回到內存池;
// 遵循條款8的operator delete常規
~pool(); // 釋放內存池中全部內存
};
這個類支持pool對象的創建,執行分配和釋放操作,以及被摧毀。pool對象被摧毀時,會釋放它分配的所有內存。這就是說,現在有辦法避免 airplane的函數裡所表現的內存洩漏似的行為了。然而這也意味著,假如pool的析構函數調用太快(使用內存池的對象沒有全部被摧毀),一些對象就會發現它正在使用的內存猛然間沒了。這造成的結果通常是不可猜測的。
有了這個pool類,即使Java程序員也可以不費吹灰之力地在airplane類裡增加自己的內存治理功能:
class airplane {
public:
... // 普通airplane功能
static void * operator new(size_t size);
static void operator delete(void *p, size_t size);
private:
airplanerep *rep; // 指向實際描述的指針
static pool mempool; // airplanes的內存池
};
inline void * airplane::operator new(size_t size)
{ return mempool.alloc(size); }
inline void airplane::operator delete(void *p,
size_t size)
{ mempool.free(p, size); }
// 為airplane對象創建一個內存池,
// 在類的實現文件裡實現
pool airplane::mempool(sizeof(airplane));
這個設計比前面的要清楚、干淨得多,因為airplane類不再和非airplane的代碼混在一起。union,自由鏈表頭指針,定義原始內存塊大小的常量都不見了,它們都隱藏在它們應該呆的地方——pool類裡。讓寫pool的程序員去操心內存治理的細節吧,你的工作只是讓airplane類正常工作。
現在應該明白了,自定義的內存治理程序可以很好地改善程序的性能,而且它們可以封裝在象pool這樣的類裡。但請不要忘記主要的一點,operator new和operator delete需要同時工作,那麼你寫了operator new,就也一定要寫operator delete。
版權問題:Effective c++ second edition中文版