寫Java代碼的時候,遇到錯誤總是喜歡抛出異常,簡單實用。最近開始寫C++代碼,發現異常沒那麼簡單,使用須謹慎。
翻閱了《Effective C++》 《More Effective C++》《Inside The C++ Object Model》的相關章節,大概弄明白了一些東東,總結在本文。
本文不是總結普适的C++異常機制,還沒有這個内力哈! 主要是結合構造函數和析構函數,來總結異常對他倆的影響。構造函數和析構函數本來就很折磨腦筋,再疊加上異常機制,确實比較複雜。
異常與析構函數
本節内容較少,是以先說。構造函數放到下一節讨論。
絕對不要将異常抛出析構函數
這一條在《Effective C++》 《More Effective C++》中均被作為獨立章節講解,可見其重要性。
有一點不要誤解:析構函數的代碼當然可以throw異常,隻是這個異常不要被抛出析構函數之外。如果在析構函數中catch住異常,并且不再抛出,這就不會帶來問題。
至于原因,有兩點。我們先看第一點。
異常被抛出析構函數之外,往往意味着析構函數的工作沒有做完。如果析構函數需要釋放一些資源,異常可能導緻資源洩露,使得程式處于一個不安全的狀态。
如下面的僞代碼所示,異常導緻p不能free,進而造成記憶體洩露。
class A
{
public:
~A()
{
throw exception;
free(p);
}
};
OK,這個問題好辦,我好好寫代碼,確定析構函數釋放所有的資源之後,才抛出異常。這還不行嗎?
class A
{
public:
~A()
{
free(p);
throw exception;
}
};
嗯,确實不行。我們來看第二個原因。
如果兩個異常同時存在:第一個異常還沒有被catch,第二個異常又被抛出,這會導緻C++會調用terminate函數,把程式結束掉!
這簡直是災難,遠比資源洩漏要嚴重。
那麼,什麼時候會同時出現兩個異常呢?看下面的代碼。
void f()
{
A a; // 沒錯,就是前面的class A
throw exception;
}
f()抛出異常後,會進行stack-unwinding。在這個過程中,會析構所有的active local object。所謂active local object,就是已經構造完成的局部對象,例如上面的對象a。
調用a的析構函數時,(第一個)異常還沒有被catch。可是a的析構函數也抛出了(第二個)異常。這時,兩個異常同時存在了。程式會毫不留情地結束!
這個理由足夠充分了:再也不要讓異常逃離你的析構函數!
異常與構造函數
構造函數本來就是一件難以琢磨的東東,背後做了很多事情:成員對象的構造、基類成分的構造、虛表指針的設定等。這些事情本來就很糾結了,再讓構造函數抛出異常,會出現怎樣的悲劇呢?
有一點比較安慰:異常即使被抛出構造函數之外,也不會造成程式結束。那麼,是否存在資源洩漏的問題呢?不可一概而論,我們分情況分析。
對象自身的記憶體如何釋放
對象有可能在棧上,也可能在堆上,我們分兩種情況讨論。
// 對象在棧上
f()
{
A a;
}
// 對象在堆上
f()
{
A * a = new A();
}
如果對象是在棧上,那麼函數退棧自然會釋放a占用的空間,無需多慮。
如果對象是在堆上,我們還得兩種情況讨論:
- 如果是new運算符抛出的異常,那麼堆空間還沒有配置設定成功,也就無需釋放
- 如果是構造函數抛出的異常,堆空間已經配置設定成功,那麼編譯器會負責釋放堆空間(Inside The C++ Object Model, p301)
可見,對象本身的記憶體,是不會洩露的。
成員對象和基類成分怎麼辦
成員對象和基類成分的記憶體,會随着對象自身記憶體的釋放而被一起釋放,沒什麼問題。
但是,有一點需要謹記:如果一個對象的構造函數抛出異常,那麼該對象的析構函數不會被調用。
原因很簡單:如果對象沒有被構造完整,析構函數中的某些代碼可能會有風險。為了避免這類意外問題,編譯器拒絕生成調用析構函數的代碼。
那麼,成員對象的基類成員對象的析構函數,會被調用嗎?如果不會調用,則可能出現資源洩漏。答案是,會被調用。見下面的代碼。
class B : class C
{
A a;
A * pa;
public:
B()
{
pa = new A();
}
~B()
{
delete pa;
}
};
如果B的構造函數抛出異常,編譯器保證:成員對象a的析構函數、基類C的析構函數會被調用(Inside The C++ Object Model, p301)。
成員指針怎麼辦
注意上述代碼中的pa,它指向一塊堆空間,由于B的析構函數不會被調用了,記憶體就會出現洩漏。
這還真是一個問題,編譯器也不能幫我們做更多事情,隻能由程式員自己負責釋放記憶體。
我們可能要這樣寫代碼
class B : class C
{
A a;
A * pa;
public:
B()
{
pa = new A();
try {
throw exception;
} catch(...)
{
delete pa; //確定釋放pa
throw;
}
}
~B()
{
delete pa;
}
};
這樣的代碼難看很多,有一種建議的做法就是:用智能指針包裝pa。智能指針作為B的成員對象,其析構函數是可以被自動調用的,進而釋放pa。
析構函數如何被自動調用
上面提到:
- 普通函數抛出異常時,所有active local object的析構函數都會被調用
- 構造函數抛出異常時,所有成員對象以及基類成分的析構函數都會被調用
那麼,這是怎麼實作的呢?
我們以第一種情況為例,分析實作細節。看下面的代碼:
f()
{
A a1;
if (...) { // 某些條件下,抛出異常
throw exception;
}
A a2;
throw exception; // 總會抛出異常
}
如果L5抛出異常,那麼對象a1會被析構。如果L8抛出異常,那麼對象a1 a2都要被析構。編譯器是怎麼知道,什麼時候該析構哪些對象的呢?
支援異常機制的編譯器,會做一些”簿記“工作,将需要被析構的對象登記在特定的資料結構中。編譯器将上述代碼分成不同的區段,每個區段中需要被析構的對象,都不相同。
例如,上述代碼中,L3 L4~L7 L8就是三個不同的區段:
- 如果L3抛出異常,那麼沒有對象需要析構
- 如果L4~L7抛出異常,那麼a1需要被析構
- 如果L8抛出異常,那麼a1和a2都要析構
編譯器通過分析代碼,簿記這些區段以及需要析構的object list。運作時,根據異常抛出時所在的區段,查找上述的資料結構,就可以知道哪些對象需要被析構。
構造函數抛出異常時,成員對象及基類成分被析構的原理,是類似的。在C++運作時看來,構造函數隻是普通的函數而已。
總結
C++的異常機制,給編譯器和運作時均帶來了一定的複雜度和代價。上述的”簿記“工作,隻是冰上一角。
關于異常的使用,也有很多坑。怎麼throw 怎麼catch,都是有講究的。有空下次再做總結。