More Effective C++ 10:在構造函數中防止資源泄漏

如果你正在開發一個具有多媒體功能的通訊錄程序。這個通訊錄除了能存儲通常的文字信息如姓名、地址、電話號碼外,還能存儲照片和聲音。
可以這樣設計:

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<PhoneNumber> thePhones; // 他的電話號碼 
	Image *theImage; // 他們的圖像 
	AudioClip *theAudioClip; // 他們的一段聲音片段 
};

編寫 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; 
}

構造函數把指針 theImagetheAudioClip 初始化爲空,然後如果其對應的構造函數參數不是空,就讓這些指針指向真實的對象。

析構函數負責刪除這些指針,確保 BookEntry對象不會發生資源泄漏。因爲 C++確保刪除空指針是安全的,所以BookEntry 的析構函數在刪除指針前不需要檢測這些指針是否指向了某些對象。

問題是:如果 BookEntry 的構造函數正在執行中,一個異常被拋出,會發生什麼情況呢?

	 if (audioClipFileName != "") 
	 { 
		 theAudioClip = new AudioClip(audioClipFileName); 
	 } 

一個異常被拋出,可以是因爲 operator new不能給 AudioClip 分配足夠的內存,也可以因爲 AudioClip 的構造函數自己拋出一個異常。
不論什麼原因,如果在BookEntry 構造函數內拋出異常,這個異常將傳遞到建立 BookEntry 對象的地方。

假設建立 theAudioClip 對象建立時,一個異常被拋出,那麼誰來負責刪除 theImage 已經指向的對象呢?答案顯然應該是由 BookEntry 來做,但是這個想當然的答案是錯的。~BookEntry()根本不會被調用,永遠不會。

C++僅僅能刪除被完全構造的對象, 只有一個對象的構造函數完全運行完畢,這個對象才被完全地構造。所以如果一個 BookEntry 對象 b 做爲局部對象建立,如下:

void testBookEntryClass() 
{ 
	 BookEntry b("Addison-Wesley Publishing Company", 
	 "One Jacob Way, Reading, MA 01867"); 
... 
}

並且在構造 b 的過程中,一個異常被拋出,b 的析構函數不會被調用。而且如果你試圖採取主動手段處理異常情況,即當異常發生時調用 delete,如下所示:

void testBookEntryClass() 
{ 
	 BookEntry *pb = nullptr; 
	 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 進行賦值操作。

C++拒絕爲沒有完成構造操作的對象調用析構函數是有一些原因的,而不是故意爲你製造困難。
原因是:在很多情況下這麼做是沒有意義的,甚至是有害的。如果爲沒有完成構造操作的對象調用析構函數,析構函數如何去做呢?僅有的辦法是在每個對象里加入一些字節
來指示構造函數執行了多少步?然後讓析構函數檢測這些字節並判斷該執行哪些操作。這樣的記錄會減慢析構函數的運行速度,並使得對象的尺寸變大。C++避免了這種開銷,但是代價是不能自動地刪除被部分構造的對象

因爲當對象在構造中拋出異常後 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 
	 { 
		 if (imageFileName != "") 
		 { 
			 theImage = new Image(imageFileName); 
		 } 
		 if (audioClipFileName != "") 
		 { 
			 theAudioClip = new AudioClip(audioClipFileName); 
		 } 
	 } 
	 catch (...) // 捕獲所有異常 
	 { 
		 delete theImage; // 完成必要的清除代碼 
		 delete theAudioClip; 
		 throw; // 繼續傳遞異常 
	 } 
}

你可能已經注意到 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(); 
}

這似乎行了,但是它沒有考慮到下面這種情況。假設我們略微改動一下設計,讓theImagetheAudioClip 是常量指針類型:

class BookEntry 
{ 
public: 
	 ... // 同上 
private: 
	 ... 
	 Image * const theImage; // 指針現在是 const 類型 
	 AudioClip * const theAudioClip; // 指針現在是 const 類型
};

必須通過 BookEntry 構造函數的成員初始化表來初始化這樣的指針,因爲再也沒有其它地方可以給 const 指針賦值.
通常會這樣初始化theImagetheAudioClip

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 所指的對象不會被釋放。而且我們不能通過在構造函數中增加 trycatch 語句來解決問題,因爲 trycatch 是語句,而成員初始化表僅允許有表達式(這也是爲什麼我們必須在 theImagetheAudioClip 的初始化中使用?:以代替 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 nullptr; 
} 
// theAudioClip 被第二個初始化, 所以如果在 theAudioClip 
// 初始化過程中拋出異常,它必須確保 theImage 的資源被釋放。 
// 因此這個函數使用 try...catch 。 
AudioClip * BookEntry::initAudioClip(const string& 
audioClipFileName) 
{ 
	 try 
	 { 
		 if (audioClipFileName != "") 
		 { 
			 return new AudioClip(audioClipFileName); 
		 } 
		 else return nullptr; 
	 } 
	 catch (...) 
	 { 
		 delete theImage; 
		 throw; 
	 } 
}

上面的程序的確不錯,也解決了令我們頭疼不已的問題。不過也有缺點,在原則上應該屬於構造函數的代碼卻分散在幾個函數裏,這令我們很難維護.
更好的解決方法是採用條款 09的建議,theImagetheAudioClip 指向的對象做爲一個資源,被一些局部對象管理。

class BookEntry 
{ 
public: 
	 ... // 同上 
private: 
 ... 
	 const unique_ptr<Image> theImage; // 它們現在是 
	 const unique_ptr<AudioClip> theAudioClip; // unique_ptr 對象 
};

這樣做使得 BookEntry 的構造函數即使在存在異常的情況下也能做到不泄漏資源,而且讓我們能夠使用成員初始化表來初始化 theImagetheAudioClip,如下所示:

BookEntry::BookEntry(const string& name, const string& address, const string& imageFileName, const string& audioClipFileName) : theName(name), theAddress(address), 
 theImage(imageFileName != "" ? new Image(imageFileName) 
 : nullptr), theAudioClip(audioClipFileName != "" ? new AudioClip(audioClipFileName) : nullptr){}

在這裏,如果在初始化 theAudioClip 時拋出異常,theImage 已經是一個被完全構造的對象,所以它能被自動刪除掉,就象 theName, theAddressthePhones 一樣。而且因爲theImagetheAudioClip 現在是包含在 BookEntry 中的對象,當 BookEntry 被刪除時它們能被自動地刪除。因此不需要手工刪除它們所指向的對象。可以這樣簡化 BookEntry 的析構函數:

BookEntry::~BookEntry(){}

總結

在對象構造中,處理各種拋出異常的可能,是一個棘手的問題,但是智能指針能化繁爲簡。它不僅把令人不好理解的代碼隱藏起來,而且使得程序在面對異常的情況下也能保持正常運行。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章