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(){}

总结

在对象构造中,处理各种抛出异常的可能,是一个棘手的问题,但是智能指针能化繁为简。它不仅把令人不好理解的代码隐藏起来,而且使得程序在面对异常的情况下也能保持正常运行。

猜你喜欢

转载自blog.csdn.net/qq_44800780/article/details/106142965