避免在析構函數中編寫代碼

上篇文章中,我們介紹了爲什麼應該徹底避免編寫拷貝構造函數和賦值操作符。今天這篇我們討論下爲什麼應該避免在析構函數中編寫代碼。即讓析構函數爲空。

例如:

virtual ~MyClass()
{
}

我們用空析構函數這個術語表示花括號內沒有代碼的析構函數。

需要編寫析構函數可能有如下幾個原因:

  • 在基類中,可能需要聲明虛擬析構函數,這樣就可以使用一個指向基類的指針指向一個派生類的實例。
  • 在派生類中,並不需要把析構函數聲明爲虛擬函數,但是爲了增強可讀性,也可以這樣做。
  • 可能需要聲明析構函數並不拋出任何異常。
對於最後一種情況,我們將詳細討論。在C++中,從析構函數中拋出異常被認爲是不好的思路。這是因爲析構函數常常是在一個異常已經被拋出的情況下被調用的,在這個過程中再次拋出異常將導致程序終止(或崩潰),這很可能違背程序員的初衷。因此,在有些類中,析構函數被聲明爲如下:

virtual ~ScppAssertFailedException() throw ()
{
}

這意味着我們保證不會從這個析構函數中拋出異常。因此,我們可以看到有時候需要編寫析構函數。現在我們可以討論析構函數爲什麼應該爲空。何時需要在析構函數中出現實質性的代碼呢?只有在析構函數或類的其他方法中獲取了一些資源,並且在這個類的對象被銷燬時應該釋放這些資源時才應該這樣,例如:

class PersonDescription
{
public:
	PersonDescription(const char* first_name, const char* last_name)
		: first_name_(NULL), last_name_(NULL)
	{
		if(first_name != NULL)
			first_name_ = new string(first_name);
		if(last_name != NULL)
			last_name_ = new string(last_name);
	}

	~PersonDescription()
	{
		delete first_name_;
		delete last_name_;
	}

private:
	PersonDescription(const PersonDescription&);
	PersonDescription& operator = (const PersonDescription&);

	string* first_name_;
	string* last_name_;
};

這個類的設計違背了我們在前幾篇文章中所討論的原則。首先,我們看到每次添加一個表述個人描述的新元素時,都需要在析構函數中 添加對應的清理代碼,這就違背了“不要迫使程序記住某些事情”的原則。以下是改進的設計代碼:

class PersonDescription
{
public:
	PersonDescription(const char* first_name, const char* last_name)
		: first_name_(NULL), last_name_(NULL)
	{
		if(first_name != NULL)
			first_name_ = new string(first_name);
		if(last_name != NULL)
			last_name_ = new string(last_name);
	}
private:
	PersonDescription(const PersonDescription&);
	PersonDescription& operator = (const PersonDescription&);

	string* first_name_;
	string* last_name_;
};

在這個例子中,我們根本不需要編寫析構函數,因爲編譯器會爲我們自動生成一個析構函數完成這些任務,在減少工作量的同時,也減少了出現脆弱代碼的可能性。但是,這並不是選擇第二種設計的主要原因。在第一個例子當中,存在一種更爲嚴重的潛在危害。

假設我們決定增加安全檢查,檢查調用者是否提供了名字和姓氏:

class PersonDescription
{
public:
	PersonDescription(const char* first_name, const char* last_name)
		: first_name_(NULL), last_name_(NULL)
	{
		<span style="color:#ff0000;">SCPP_ASSERT(first_name != NULL ,"First name must be provided");
		first_name_ = new string(first_name);
        
		SCPP_ASSERT(last_name != NULL ,"Last name must be provided");
		last_name_ = new string(last_name);</span>
	}
	
	~PersonDescription()
	{
		delete first_name_;
		delete last_name_;
	}
private:
	PersonDescription(const PersonDescription&);
	PersonDescription& operator = (const PersonDescription&);

	string* first_name_;
	string* last_name_;
};

正如我們之前討論的那樣,程序中的錯誤可能會終止程序,但也有可能拋出一個異常。現在我們就陷入這種麻煩之中:從構造函數拋出異常是一種不好的思路。爲何呢?如果我們試圖在堆棧上創建一個對象,並且構造函數正常的完成了它的任務(不拋出異常),那麼當這個對象離開作用域之後,它的析構函數會被調用。但是,如果構造函數並沒有完成它的任務,而是拋出了一個異常,析構函數將不會被調用。

因此,在前面的例子當中,如果我們假設提供了名字卻沒有提供姓氏,表示名字的字符串將被分配內存,但永遠不會被刪除,因此導致了內存泄露。但是,情況還不至於無可挽回。更進一步觀察,如果我們有一個包含了其他對象的對象,一個重要的問題是:哪些析構函數將被調用?哪些析構函數將不被調用?

以下是用一個小試驗來說明:

class A 
{
public:
	A()
	{
		cout<<"Creating A"<<endl;
	}
	~A()
	{
		cout<<"Destroying A"<<endl;
	}
};

class B 
{
public:
	B()
	{
		cout<<"Creating B"<<endl;
	}
	~B()
	{
		cout<<"Destroying B"<<endl;
	}
};

class C  : public A
{
public:
	C()
	{
		cout<<"Creating C"<<endl;
		Throw "Don't like C";
	}
	~C()
	{
		cout<<"Destroying C"<<endl;
	}

private:
	B b_;
};

注意,C類通過合成(即C類擁有一個B類型的數據成員)包含了B類。它還通過繼承包含了A類型的對象(即在C類型的對象內部有一個A類型的對象)。現在,如果C的構造函數拋出一個異常,會發生什麼情況呢?看以下的代碼:

int main()
{
	cout<<"Testing throwing from constructor."<<endl;
	try{
		C c;
	}catch(...)
	{
		cout<<"Caught an exception."<<endl;
	}

	return 0;
}

運行後將產生下面的輸出:

Testing throwing from constuctor.
Creating A
Creating B
Creating C
Destroying B
Destroying A
Caught an exception.

注意,只有C的析構函數沒有被執行,A和B的析構函數都被調用。因此上面問題的答案既簡單又符合邏輯:對於允許構造函數正常結束的對象,析構函數將會被調用,即使這些對象是一個更大對象的一部分,而後者的構造函數並沒有正常結束。因此,讓我們用智能指針重寫上面的示例代碼,引入安全檢查:

class PersonDescription
{
public:
	PersonDescription(const char* first_name, const char* last_name)
		: first_name_(NULL), last_name_(NULL)
	{
		SCPP_ASSERT(first_name != NULL ,"First name must be provided");
		first_name_ = new string(first_name);
        
		SCPP_ASSERT(last_name != NULL ,"Last name must be provided");
		last_name_ = new string(last_name);
	}
	
	~PersonDescription()
	{
		delete first_name_;
		delete last_name_;
	}
private:
	PersonDescription(const PersonDescription&);
	PersonDescription& operator = (const PersonDescription&);

	<span style="color:#ff0000;">scpp::ScopedPtr<string> first_name_;
	scpp::ScopedPtr<string> last_name_;</span>
};

即使第2個安全檢查拋出一個異常,指向first_name_的智能指針的析構函數仍然會被調用,並執行它的清理工作。另一個附帶的好處是,我們並不需要操心把這些智能指針初始化爲NULL,這是自動完成的。因此,我們看到從構造函數拋出異常是一種潛在的危險行爲:對應的析構函數將不會被調用,因此可能會存在問題,除非析構函數是空函數。

總結:

從構造函數中拋出異常時爲了避免內存泄露,在設計類的時候,使析構函數保持爲空函數。

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