上篇文章中,我們介紹了爲什麼應該徹底避免編寫拷貝構造函數和賦值操作符。今天這篇我們討論下爲什麼應該避免在析構函數中編寫代碼。即讓析構函數爲空。
例如:
virtual ~MyClass()
{
}
我們用空析構函數這個術語表示花括號內沒有代碼的析構函數。
需要編寫析構函數可能有如下幾個原因:
- 在基類中,可能需要聲明虛擬析構函數,這樣就可以使用一個指向基類的指針指向一個派生類的實例。
- 在派生類中,並不需要把析構函數聲明爲虛擬函數,但是爲了增強可讀性,也可以這樣做。
- 可能需要聲明析構函數並不拋出任何異常。
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,這是自動完成的。因此,我們看到從構造函數拋出異常是一種潛在的危險行爲:對應的析構函數將不會被調用,因此可能會存在問題,除非析構函數是空函數。
總結:
從構造函數中拋出異常時爲了避免內存泄露,在設計類的時候,使析構函數保持爲空函數。