假如你正在開發一個具有多媒體功能的通訊錄程序。這個通訊錄除了能存儲通常的文字信息如姓名、地址、電話號碼外,還能存儲照片和聲音(可以給出他們名字的正確發音)。
為了實現這個通信錄,你可以這樣設計:
class Image { // 用於圖像數據
public:
Image(const string& imageDataFileName);
...
};
class AudioClip { // 用於聲音數據
public:
AudioClip(const string& audioDataFileName);
...
};
class PhoneNumber { ... }; // 用於存儲電話號碼
class BookEntry { // 通訊錄中的條目
public:
BookEntry(const string& name,
const string& address = "",
const string& imageFileName = "",
const string& audioClipFileName = "");
~BookEntry();
// 通過這個函數加入電話號碼
void addPhoneNumber(const PhoneNumber& number);
...
private:
string theName; // 人的姓名
string theAddress; // 他們的地址
list thePhones; // 他的電話號碼
Image *theImage; // 他們的圖像
AudioClip *theAudioClip; // 他們的一段聲音片段
};
通訊錄的每個條目都有姓名數據,所以你需要帶有參數的構造函數(參見條款3),不過其它內容(地址、圖像和聲音的文件名)都是可選的。注重應該使用鏈表類(list)存儲電話號碼,這個類是標准C++類庫(STL)中的一個容器類(container classes)。(參見Effective C++條款49 和本書條款35)
編寫BookEntry 構造函數和析構函數,有一個簡單的方法是:
BookEntry::BookEntry(const string& name,const string& address,
const string& imageFileName,
Const string& audioClipFileName)
: theName(name), theAddress(address),
theImage(0), theAudioClip(0)
{
if (imageFileName != "") {
theImage = new Image(imageFileName);
}
if (audioClipFileName != "") {
theAudioClip = new AudioClip(audioClipFileName);
}
}
BookEntry::~BookEntry()
{
delete theImage;
delete theAudioClip;
}
構造函數把指針theImage和theAudioClip初始化為空,然後假如其對應的構造函數參數不是空,就讓這些指針指向真實的對象。析構函數負責刪除這些指針,確保BookEntry對象不會發生資源洩漏。因為C++確保刪除空指針是安全的,所以BookEntry的析構函數在刪除指針前不需要檢測這些指針是否指向了某些對象。
看上去似乎一切良好,在正常情況下確實不錯,但是在非正常情況下(例如在有異常發生的情況下)它們恐怕就不會良好了。
請想一下假如BookEntry的構造函數正在執行中,一個異常被拋出,會發生什麼情況呢?:
if (audioClipFileName != "") {
theAudioClip = new AudioClip(audioClipFileName);
}
一個異常被拋出,可以是因為operator new(參見條款8)不能給AudioClip分配足夠的內存,也可以因為AudioClip的構造函數自己拋出一個異常。不論什麼原因,假如在BookEntry構造函數內拋出異常,這個異常將傳遞到建立BookEntry對象的地方(在構造函數體的外面。 譯者注)。
現在假設建立theAudioClip對象建立時,一個異常被拋出(而且傳遞程序控制權到BookEntry構造函數的外面),那麼誰來負責刪除theImage已經指向的對象呢?答案顯然應該是由BookEntry來做,但是這個想當然的答案是錯的。BookEntry根本不會被調用,永遠不會。
C++僅僅能刪除被完全構造的對象(fully contrUCted objects), 只有一個對象的構造函數完全運行完畢,這個對象才能被完全地構造。所以假如一個BookEntry對象b做為局部對象建立,如下:
void testBookEntryClass()
{
BookEntry b("Addison-Wesley Publishing Company","One Jacob Way, Reading, MA 01867");
...
}
並且在構造b的過程中,一個異常被拋出,b的析構函數不會被調用。而且假如你試圖采取主動手段處理異常情況,即當異常發生時調用delete,如下所示:
void testBookEntryClass()
{
BookEntry *pb = 0;
try {
pb = new BookEntry("Addison-Wesley Publishing Company","One Jacob Way, Reading, MA 01867");
...
}
catch (...) { // 捕捉所有異常
delete pb; // 刪除pb,當拋出異常時
throw; // 傳遞異常給調用者
}
delete pb; // 正常刪除pb
}
你會發現在BookEntry構造函數裡為Image分配的內存仍然被丟失了,這是因為假如new操作沒有成功完成,程序不會對pb進行賦值操作。假如BookEntry的構造函數拋出一個異常,pb將是一個空值,所以在catch塊中刪除它除了讓你自己感覺良好以外沒有任何作用。用靈巧指針(smart pointer)類auto_ptr
(參見條款9)代替raw BookEntry*也不會也什麼作用,因為new操作成功完成前,也沒有對pb進行賦值操作。
C++拒絕為沒有完成構造操作的對象調用析構函數是有一些原因的,而不是故意為你制造困難。原因是:在很多情況下這麼做是沒有意義的,甚至是有害的。假如為沒有完成構造操作的對象調用析構函數,析構函數如何去做呢?僅有的辦法是在每個對象裡加入一些字節來指示構造函數執行了多少步?然後讓析構函數檢測這些字節並判定該執行哪些操作。這樣的記錄會減慢析構函數的運行速度,並使得對象的尺寸變大。C++避免了這種開銷,但是代價是不能自動地刪除被部分構造的對象。(類似這種在程序行為與效率這間進行折衷處理的例子還可以參見Effective C++條款13)
因為當對象在構造中拋出異常後C++不負責清除對象,所以你必須重新設計你的構造函數以讓它們自己清除。經常用的方法是捕捉所有的異常,然後執行一些清除代碼,最後再重新拋出異常讓它繼續轉遞。如下所示,在BookEntry構造函數中使用這個方法:
BookEntry::BookEntry(const string& name,
const string& address,
const string& imageFileName,
const string& audioClipFileName)
: theName(name), theAddress(address),
theImage(0), theAudioClip(0)
{
try { // 這try block是新加入的
if (imageFileName != "") {
theImage = new Image(imageFileName);
}
if (audioClipFileName != "") {
theAudioClip = new AudioClip(audioClipFileName);
}
}
catch (...) { // 捕捉所有異常
delete theImage; // 完成必要的清除代碼
delete theAudioClip;
throw; // 繼續傳遞異常
}
}
不用為BookEntry中的非指針數據成員操心,在類的構造函數被調用之前數據成員就被自動地初始化。所以假如BookEntry構造函數體開始執行,對象的theName, theAddress 和 thePhones數據成員已經被完全構造好了。這些數據可以被看做是完全構造的對象,所以它們將被自動釋放,不用你介入操作。當然假如這些對象的構造函數調用可能會拋出異常的函數,那麼哪些構造函數必須去考慮捕捉異常,在答應它們繼續傳遞之前完成必需的清除操作。
你可能已經注重到BookEntry構造函數的catch塊中的語句與在BookEntry的析構函數的語句幾乎一樣。這裡的代碼重復是絕對不可容忍的,所以最好的方法是把通用代碼移入一個私有helper function中,讓構造函數與析構函數都調用它。
class BookEntry {
public:
... // 同上
private:
...
void cleanup(); // 通用清除代碼
};
void BookEntry::cleanup()
{
delete theImage;
delete theAudioClip;
}
BookEntry::BookEntry(const string& name,const string& address,
const string& imageFileName,
const string& audioClipFileName)
: theName(name), theAddress(address),
theImage(0), theAudioClip(0)
{
try {
... // 同上
}
catch (...) {
cleanup(); // 釋放資源
throw; // 傳遞異常
}
}
BookEntry::~BookEntry()
{
cleanup();
}
這就行了,但是它沒有考慮到下面這種情況。假設我們略微改動一下設計,讓theImage 和theAudioClip是常量(constant)指針類型:
class BookEntry {
public:
... // 同上
private:
...
Image * const theImage; // 指針現在是
AudioClip * const theAudioClip; // const類型
};
必須通過BookEntry構造函數的成員初始化表來初始化這樣的指針,因為再也沒有其它地方可以給const指針賦值。通常會這樣初始化theImage和theAudioClip:
// 一個可能在異常拋出時導致資源洩漏的實現方法
BookEntry::BookEntry(const string& name,const string& address,const string& imageFileName,
const string& audioClipFileName)
: theName(name), theAddress(address),
theImage(imageFileName != ""
? new Image(imageFileName)
: 0),
theAudioClip(audioClipFileName != ""
? new AudioClip(audioClipFileName)
: 0)
{}
這樣做導致我們原先一直想避免的問題重新出現:假如theAudioClip初始化時一個異常被拋出,theImage所指的對象不會被釋放。而且我們不能通過在構造函數中增加try和catch 語句來解決問題,因為try和catch是語句,而成員初始化表僅答應有表達式(這就是為什麼我們必須在 theImage 和 theAudioClip的初始化中使用?:以代替if-then-else的原因)。
無論如何,在異常傳遞之前完成清除工作的唯一的方法就是捕捉這些異常,所以假如我們不能在成員初始化表中放入try和catch語句,我們把它們移到其它地方。一種可能是在私有成員函數中,用這些函數返回指針,指向初始化過的theImage 和 theAudioClip對象。
class BookEntry {
public:
... // 同上
private:
... // 數據成員同上
Image * initImage(const string& imageFileName);
AudioClip * initAudioClip(const string&
audioClipFileName);
};
BookEntry::BookEntry(const string& name,const string& address,const string& imageFileName,
const string& audioClipFileName): theName(name), theAddress(address),
theImage(initImage(imageFileName)),
theAudioClip(initAudioClip(audioClipFileName)){}
// theImage 被首先初始化,所以即使這個初始化失敗也
// 不用擔心資源洩漏,這個函數不用進行異常處理。
Image * BookEntry::initImage(const string& imageFileName)
{
if (imageFileName != "") return new Image(imageFileName);
else return 0;
}
// theAudioClip被第二個初始化, 所以假如在theAudioClip
// 初始化過程中拋出異常,它必須確保theImage的資源被釋放。
// 因此這個函數使用try...catch 。
AudioClip * BookEntry::initAudioClip(const string& audioClipFileName)
{
try {
if (audioClipFileName != "") {
return new AudioClip(audioClipFileName);
}
else return 0;
}
catch (...) {
delete theImage;
throw;
}
}
上面的程序的確不錯,也解決了令我們頭疼不已的問題。不過也有缺點,在原則上應該屬於構造函數的代碼卻分散在幾個函數裡,這令我們很難維護。
更好的解決方法是采用條款9的建議,把theImage 和 theAudioClip指向的對象做為一個資源,被一些局部對象治理。這個解決方法建立在這樣一個事實基礎上:theImage 和theAudioClip是兩個指針,指向動態分配的對象,因此當指針消失的時候,這些對象應該被刪除。auto_ptr類就是基於這個目的而設計的。(參見條款9)因此我們把theImage 和 theAudioClip raw指針類型改成對應的auto_ptr類型。
class BookEntry {
public:
... // 同上
private:
...
const auto_ptr theImage; // 它們現在是
const auto_ptr theAudioClip; // auto_ptr對象
};
這樣做使得BookEntry的構造函數即使在存在異常的情況下也能做到不洩漏資源,而且讓我們能夠使用成員初始化表來初始化theImage 和 theAudioClip,如下所示:
BookEntry::BookEntry(const string& name,const string& address,const string& imageFileName,const string& audioClipFileName): theName(name), theAddress(address),theImage(imageFileName != ""? new Image(imageFileName)
: 0),theAudioClip(audioClipFileName != ""? new AudioClip(audioClipFileName): 0){}
在這裡,假如在初始化theAudioClip時拋出異常,theImage已經是一個被完全構造的對象,所以它能被自動刪除掉,就象theName, theAddress和thePhones一樣。而且因為theImage 和 theAudioClip現在是包含在BookEntry中的對象,當BookEntry被刪除時它們能被自動地刪除。因此不需要手工刪除它們所指向的對象。可以這樣簡化BookEntry的析構函數:
BookEntry::~BookEntry()
{} // nothing to do!
這表示你能完全去掉BookEntry的析構函數。
綜上所述,假如你用對應的auto_ptr對象替代指針成員變量,就可以防止構造函數在存在異常時發生資源洩漏,你也不用手工在析構函數中釋放資源,並且你還能象以前使用非const指針一樣使用const指針,給其賦值。
在對象構造中,處理各種拋出異常的可能,是一個棘手的問題,但是auto_ptr(或者類似於auto_ptr的類)能化繁為簡。它不僅把令人不好理解的代碼隱藏起來,而且使得程序在面對異常的情況下也能保持正常運行。