1.簡介
異常是由語言提供的運行時刻錯誤處理的一種方式。提到錯誤 處理,即使不提到異常,你大概也已經有了豐富的經驗,但是為了可以清楚的看 到異常的好處,我們還是不妨來回顧一下常用的以及不常用的錯誤處理方式。
1.1 常用的錯誤處理方式
返回值。我們常用函數的返回值來標志成功或 者失敗,甚至是失敗的原因。但是這種做法最大的問題是如果調用者不主動檢查 返回值也是可以被編譯器接受的,你也奈何不了他:) 這在C++中還導致另外一個 問題,就是重載函數不能只有不同的返回值,而有相同的參數表,因為如果調用 者不檢查返回值,則編譯器會不知道應該調用哪個重載函數。當然這個問題與本 文無關,我們暫且放下。只要謹記返回值可能被忽略的情況即可。
全局 狀態標志。例如系統調用使用的errno。返回值不同的是,全局狀態標志可以讓 函數的接口(返回值、參數表)被充分利用。函數在退出前應該設置這個全局變 量的值為成功或者失敗(包括原因),而與返回值一樣,它隱含的要求調用者要 在調用後檢查這個標志,這種約束實在是同樣軟弱。全局變量還導致了另外一個 問題,就是多線程不安全:如果多個線程同時為一個全局變量賦值,則調用者在 檢查這個標志的時候一定會非常迷惑。如果希望線程安全,可以參照errno的解 決辦法,它是線程安全的。
1.2 不常用的處理方式
setjmp()/longjmp() 。可以認為它們是遠程的goto語句。根據我的經驗,它們好象確實不常被用到, 也許是多少破壞了結構化編程風格的原因吧。在C++中,應該是更加的不要用它 們,因為致命的弱點是longjmp()雖然會unwinding stack(這個詞後面再說), 但是不會調用棧中對象的析構函數--夠致命吧。對於不同的編譯器,可能可以通 過加某個編譯開關來解決這個問題,但太不通用了,會導致程序很難移植。
1.3 異常
現在我們再來看看異常能解決什麼問題。對於返回值和 errno遇到的尴尬,對異常來說基本上不存在,如果你不捕獲(catch)程序中拋出 的異常,默認行為是導致abort()被調用,程序被終止(core dump)。因此你的函 數如果拋出了異常,這個函數的調用者或者調用者的調用者,也就是在當前的 call stack上,一定要有一個地方捕獲這個異常。而對於setjmp()/longjmp()帶 來的棧上對象不被析構的問題對異常來說也是不存在的。那麼它是否破壞了結構 化(對於OO paradigms,也許應該說是破壞了流程?)呢?顯然不是,有了異常 之後你可以放心的只書寫正確的邏輯,而將所有的錯誤處理歸結到一個地方,這 不是更好麼?
綜上所述,在C++中大概異常可以全面替代其它的錯誤處理 方式了,可是如果代碼中到處充斥著try/throw/catch也不是件好事,欲知異常 的使用技巧,請保持耐心繼續閱讀:)
2. 異常的語法
在這裡我們只討論一 些語法相關的問題。
2.1 try
try總是與catch一同出現,伴隨一個try語 句,至少應該有一個catch()語句。try隨後的block是可能拋出異常的地方。
2.2 catch
catch帶有一個參數,參數類型以及參數名字都由程序指定, 名字可以忽略,如果在catch隨後的block中並不打算引用這個異常對象的話。參 數類型可以是build-in type,例如int, long, char等,也可以是一個對象,一 個對象指針或者引用。如果希望捕獲任意類型的異常,可以使用 “...”作為catch的參數。
catch不一定要全部捕獲try block中拋出的異常,剩下沒有捕獲的可以交給上一級函數處理。
2.3 throw
throw後面帶一個類型的實例,它和catch的關系就象是函數調用, catch指定形參,throw給出實參。編譯器按照catch出現的順序以及catch指定的 參數類型確定一個異常應該由哪個catch來處理。
throw不一定非要出現 在try隨後的block中,它可以出現在任何需要的地方,只要最終有catch可以捕 獲它即可。即使在catch隨後的block中,仍然可以繼續throw。這時候有兩種情 況,一是throw一個新類型的異常,這與普通的throw一樣。二是要rethrow當前 這個異常,在這種情況下,throw不帶參數即可表達。例如:
try{
2.4 函數聲明
...
}
catch(int){
throw MyException ("hello exception"); // 拋出一個新的異常
}
catch(float){
throw; // 重新拋出當前的浮 點數異常
}
還有一個地方與throw關鍵字有關,就 是函數聲明。例如:
void foo() throw (int); // 只能拋出int型 異常
void bar() throw (); // 不拋出任何異常
void baz(); // 可以拋出任意類型的異常或者不拋出異常
如果一個函數的聲明中帶有throw限定符,則在函數體中也必須同樣出現:
void foo() throw (int)
{
...
}
這裡有一個問題,非常隱蔽,就是即使你象上面一樣編寫了foo()函數,指定它 只能拋出int異常,而實際上它還是可能拋出其他類型的異常而不被編譯器發現 :
void foo() throw (int)
{
throw float; // 錯誤!異常類型錯誤!會被編譯器指出
...
baz(); // 正確!baz()可能拋出非int異常而編譯器又不能發現!
}
void baz()
{
throw float;
}
這種情況的直 接後果就是如果baz()拋出了異常,而調用foo()的代碼又嚴格遵守foo()的聲明 來編寫,那麼程序將abort()。這曾經讓我很惱火,認為這種機制形同虛設,但 是還是有些解決的辦法,請參照“使用技巧”中相關的問題。
3. 異常使用技巧
3.1 異常是如何工作的
為了可以有把握的使 用異常,我們先來看看異常處理是如何工作的。
3.1.1 unwinding stack
我們知道,每次函數調用發生的時候,都會執行保護現場寄存器、參數壓棧、為 被調用的函數創建堆棧這幾個對堆棧的操作,它們都使堆棧增長。每次函數返回 則是恢復現場,使堆棧減小。我們把函數返回過程中恢復現場的過程稱為 unwinding stack。
異常處理中的throw語句產生的效果與函數返回相同 ,它也引發unwinding stack。如果catch不是在throw的直接上層函數中,那麼 這個unwinding的過程會一直持續,直到找到合適的catch。如果沒有合適的 catch,則最後std::unexpected()函數被調用,說明發現了一個沒想到的異常, 這個函數會調用std::terminate(),這個terminate()調用abort(),程序終止 (core dump)。
在“簡介”中提到的longjmp()也同樣會 unwinding stack,但是這是一個C函數,它就象free()不會調用對象的析構函數 一樣,它也不知道在unwinding stack的過程中調用棧上對象的析構函數。這是 它與異常的主要區別。
3.1.2 RTTI
在unwinding stack的過程中,程序會 一直試圖找到一個“合適”的catch來處理這個異常。前面我們提到 throw和catch的關系很象是函數調用和函數原型的關系,多個catch就好象一個 函數被重載為可以接受不同的類型。根據這樣的猜測,好象找到合適的catch來 處理異常與函數重載的過程中找到合適的函數原型是一樣的,沒有什麼大不了的 。但實際情況卻很困難,因為重載的調用在編譯時刻就可以確定,而異常的拋出 卻不能,考慮下面的代碼:
void foo() throw (int)
{
throw int;
}
void bar()
{
try{
foo();
}
catch(int){
...
}
catch(float){
...
}
}
void baz()
{
try{
foo();
}
catch(int){
...
}
catch(float){
...
}
}
foo()在兩個地方被調用,這兩次異常 被不同的catch捕獲,所以在為throw產生代碼的時候,無法明確的指出要由哪個 catch捕獲,也就是說,無法在編譯時刻確定。
仍然考慮這個例子,讓我 們來看看既然不能在編譯時刻確定throw的去向,那麼在運行時刻如何確定。在 bar()中,一列catch就象switch語句中的case一樣排列,實際上是一系列的判斷 過程,依次檢查當前異常的類型是否滿足catch指定的類型,這種動態的,在運 行時刻確定類型的技術就是RTTI(Runtime Type Identification/Information) 。深度探索C++對象模型[1]中提到,RTTI就是異常處理的副產品。關於RTTI又是 一個話題,在這裡就不詳細討論了。
3.2 是否繼承std::exception?
是 的。而且std::exception已經有了一些派生類,如果需要可以直接使用它們,不 需要再重復定義了。
3.3 每個函數後面都要寫throw()?
盡管前面已經分 析了這樣做也有漏洞,但是它仍然是一個好習慣,可以讓調用者從頭文件得到非 常明確的信息,而不用翻那些可能與代碼不同步的文檔。如果你提供一個庫,那 麼在庫的入口函數中應該使用catch(...)來捕獲所有異常,在catch(...)中捕獲 的異常應該被轉換(rethrow)為throw列表中的某一個異常,這樣就可以保證不 會產生意外的異常。
3.4 guard模式
異常處理在unwinding stack的時候 ,會析構所有棧上的對象,但是卻不會自動刪除堆上的對象,甚至你的代碼中雖 然寫了delete語句,但是卻被throw跳過,導致內存洩露,或者其它資源的洩露 。例如:
void foo()
{
...
MyClass * p = new MyClass();
bar(p);
...
delete p; // 如果bar()中拋出異常,則不會運行到這裡!
}
void bar (MyClass * p)
{
throw MyException();
}
對 於這種情況,C++提供了std::auto_ptr這個模板來解決問題。這個常被稱為 “智能指針”的模板原理就是,將原來代碼中的指針用一個棧上的模 板實例保護起來,當發生異常unwinding stack的時候,這個模板實例會被析構 ,而在它的析構函數中,指針將被delete,例如:
void foo()
{
...
std::auto_ptr<MyClass> p(new MyClass ());
bar(p.get());
...
// delete p; // 這句不再需要了
}
void bar(MyClass * p)
{
throw MyException();
}
不論bar()是否拋出異常,只要p被析 構,內存就會被釋放。
不光對於內存,對於其他資源的管理也可以參照 這個方法來完成。在ACE[2]中,這種方式被稱為Guard,用來對鎖進行保護。
3.5 構造函數和析構函數
構造函數沒有返回值,很多地方都推薦通過拋 出異常來通知調用者構造失敗。這是肯定是個好的辦法,但是也不很完美。主要 是因為在構造函數中拋出異常並不會引發析構函數的調用,例如:
class foo
{
public:
~foo() {} // 這個函數 將被調用
};
class bar
{
public:
bar() { c_ = new char[10]; throw -1;}
~bar() { delete c_;} // 這個函 數不會被調用!
private:
char * c_;
foo f_;
};
void baz()
{
try{
bar b;
}
catch(int){
}
}
在這個例子中,bar 的析構函數不會被調用,但是盡管如此,foo的析構函數還是可以被調用。危險 的是在構造函數中分配空間的c_,因為析構函數沒有被調用而變成了leak。最好 的解決辦法還是auto_ptr,使用auto_ptr後,bar類的聲明變成:
class bar
{
public:
bar() { c_.reset(new char[10]); throw -1;}
~bar() { } // 不需要再delete c_了!
private:
auto_ptr<char> c_;
foo f_;
};
析構函數中則不要拋出異常,這一點在Thinking In C++ Volume 2[3]中有明確表述。如果析構函數中調用了可能拋出異常的函數,則應該在析構 函數內部catch它。
3.6 什麼時候使用異常
到現在為止,我們已經討 論完了異常的大部分問題,可以實際操作操作了。實際應用中遇到的最讓我頭疼 的問題就是什麼時候應該使用異常,是否應該用異常全面代替“簡介 ”中提到的其它錯誤處理方式呢?
首先,不能用異常完全代替返回 值,因為返回值的含義不一定只是成功或失敗,有時候是一個可選擇的狀態,例 如:
if(customer->status() == active){
...
}
else{
...
}
在這種情況下,不論返回值是 什麼,都是程序可以接受的正常的結果。而異常只能用來表達“異常 ”-- 也就是錯誤的狀態。這好象是顯而易見的事情,但是實際編程的過程 中有很多更加模稜兩可的時候,遇到這樣的情況,首先要考慮的就是這個原則。
第二,看看在特定的情況下異常是否會發揮它的優點,而這個優點正好 又不能使用其他技術達到(或者簡單的達到)。比如,如果你正在為電信公司寫 一個復雜計費邏輯,那麼你當然希望在整個計算費用的過程中集中精力去考慮業 務邏輯方面的問題,而不是到處需要根據當前返回值判斷是否釋放前面步驟中申 請的資源。這時候使用異常可以讓你的代碼非常清晰,即使你有100處申請資源 的地方,只要一個地方集中釋放他們就好了。例如:
bool bar1 ();
bool bar2();
bool bar3();
bool foo()
{
...
char * p1 = new char[10];
...
if(! bar1()){
delete p1;
return false;
}
...
char * p2 = new char[10];
...
if(!bar2()){
delete p1; // 要釋放前面申請 的所有資源
delete p2;
return false;
}
...
char * p3 = new char[10];
...
if(!bar2()){
delete p1; // 要釋放前面申請 的所有資源
delete p2;
delete p3;
return false;
}
}
這種流程顯然不如:
void bar1() throw(int);
void bar2() throw(int);
void bar3() throw(int);
void foo() throw (int)
{
char * p1 = NULL;
char * p2 = NULL;
char * p3 = NULL;
try{
char * p1 = new char[10];
bar1();
char * p2 = new char[10];
bar2 ();
char * p3 = new char[10];
bar3();
}
catch(int){
delete p1; // 集中釋放 資源
delete p2;
delete p3;
throw;
}
}
第三,在Thinking In C++ Volume 2[3] 中列了一個什麼時候不應該用,什麼時候應該用的表,大家可以參考一下。
最後,說一個與異常無關的東西,但也跟程序錯誤有關的,就是斷言 (assert),我在開發中使用了異常後,很快發現有的人將應該使用assert處理的 錯誤定義成了異常。這裡稍微提醒一下assert的用法,非常簡單的原則:只有對 於那些可以通過改進程序糾正的錯誤,才可以用assert。返回值、異常顯然與其 不在一個層面上,這是C的入門知識。