前言
迄今為止,CLR異常機制讓人關注最多的一點就是“效率”問題。其實,這里存在認識上的誤區,因為正常控制流程下的代碼運行並不會出現問題,只有引發異常時才會帶來效率問題。基於這一點,很多開發者已經達成共識:不應將異常機制用於正常控制流中。達成的另一個共識是:CLR異常機制帶來的“效率”問題不足以“抵消”它帶來的巨大收益。
CLR異常機制至少有以下幾個優點:
- 正常控制流會被立即中止,無效值或狀態不會在系統中繼續傳播。
- 提供了統一處理錯誤的方法。
- 提供了在構造函數、操作符重載及屬性中報告異常的便利機制。
- 提供了異常堆棧,便於開發者定位異常發生的位置。
另外,“異常”其名稱本身就說明了它的發生是一個小概率事件。所以,因異常帶來的效率問題會被限制在一個很小的范圍內。實際上,try catch所帶來的效率問題幾乎是可以忽略的。在某些特定的場合,如Int32的Parse方法中,確實存在着因為濫用而導致的效率問題。在這種情況下,我們就應該考慮提供一個TryParse方法,從設計的角度讓用戶選擇讓程序運行得更快。另一種規避因為異常而影響效率的方法是:Tester-doer模式
正文
### 1.用拋出異常代替返回錯誤代碼 在異常機制出現之前,應用程序普遍采用返回錯誤代碼的方式來通知調用者發生了異常。本建議首先闡述為什么要用拋出異常的方式來代替返回錯誤代碼的方式。對於一個成員方法而言,它要么執行成功,要么執行失敗。成員方法執行成功的情況很容易理解,但是如果執行失敗了卻沒有那么簡單,因為我們需要將導致執行失敗的原因通知調用者。拋出異常和返回錯誤代碼都是用來通知調用者的手段。但是當我們想要告訴調用者更多細節的時候,就需要與調用者約定更多的錯誤代碼。於是我們很快就會發現,錯誤代碼飛速膨脹,直到看起來似乎無法維護,因為我們總在查找並確認錯誤代碼。
在沒有異常處理機制之前,我們只能返回錯誤代碼。但是,現在有了另一種選擇,即使用異常機制。如果使用異常機制,那么最終的代碼看起來應該是下面這樣的:
static void Main(string[]args)
{
try
{
SaveUser(user);
}
catch(IOException)
{
//IO異常,通知當前用戶
}
catch(UnauthorizedAccessException)
{
//權限失敗,通知客戶端管理員
}
catch(CommunicationException)
{
//網絡異常,通知發送E-mail給網絡管理員
}
}
private static void SaveUser(User user)
{
SaveToFile(user);
SaveToDataBase(user);
}
使用CLR異常機制后,我們會發現代碼變得更清晰、更易於理解了。至於效率問題,還可以重新審視“效率”的立足點:throw exception產生的那點效率損耗與等待網絡連接異常相比,簡直微不足道,而CLR異常機制帶來的好處卻是顯而易見的。
這里需要稍加強調的是,在catch(CommunicationExcep-tion)這個代碼塊中,代碼所完成的功能是“通知發送”而不是“發送”本身,因為我們要確保在catch和finally中所執行的代碼是可以被執行的。換句話說,盡量不要在catch和finally中再讓代碼“出錯”,那會讓異常堆棧信息變得復雜和難以理解。
在本例的catch代碼塊中,不要真的編寫發送郵件的代碼,因為發送郵件這個行為可能會產生更多的異常,而“通知發送”這個行為穩定性更高(即不“出錯”)。
以上通過實際的案例闡述了拋出異常相比於返回錯誤代碼的優越性,以及在某些情況下錯誤代碼將無用武之地,如構造函數、操作符重載及屬性。語法特性決定了其不能具備任何返回值,於是異常機制被當做取代錯誤代碼的首要選擇。
2.不要在不恰當的場合下引發異常
程序員,尤其是類庫開發人員,要掌握的兩條首要原則是:
正常的業務流程不應使用異常來處理。
不要總是嘗試去捕獲異常或引發異常,而應該允許異常向調用堆棧往上傳播。
那么,到底應該在怎樣的情況下引發異常呢?
第一類情況 如果運行代碼后會造成內存泄漏、資源不可用,或者應用程序狀態不可恢復,則應該引發異常。
在微軟提供的Console類中有很多類似這樣的代碼:
if((value<1)||(value>100))
{
throw new ArgumentOutOfRangeException("value",value, Environment.GetResourceString("ArgumentOutOfRange_CursorSize"));
}
或者:
if(value==null)
{
throw new ArgumentNullException("value");
}
在開頭首先提到的就是:對在可控范圍內的輸入和輸出不引發異常。沒錯,區別就在於“可控”這兩個字。所謂“可控”,可定義為:發生異常后,系統資源仍可用,或資源狀態可恢復。
第二類情況 在捕獲異常的時候,如果需要包裝一些更有用的信息,則引發異常。
這類異常的引發在UI層特別有用。系統引發的異常所帶的信息往往更傾向於技術性的描述;而在UI層,面對異常的很可能是最終用戶。如果需要將異常的信息呈現給最終用戶,更好的做法是先包裝異常,然后引發一個包含友好信息的新異常。
第三類情況 如果底層異常在高層操作的上下文中沒有意義,則可以考慮捕獲這些底層異常,並引發新的有意義的異常。
例如在下面的代碼中,如果拋出InvalidCastException,則沒有任何意義,甚至會造成誤解,所以更好的方式是拋出一個ArgumentException:
private void CaseSample(object o)
{
if(o==null)
{
throw new ArgumentNullException("o");
}
}
User user=null;
try
{
user=(User)o;
}
catch(InvalidCastException)
{
throw new ArgumentException("輸入參數不是一個User","o");
}
//do something}
需要重點介紹的正確引發異常的典型例子就是捕獲底層API錯誤代碼,並拋出。查看Console這個類,還會發現很多地方有類似的代碼:
int errorCode=Marshal.GetLastWin32Error();
if(errorCode==6)
{
throw new InvalidOperationException(Environment.GetResourceString("InvalidOperation_ConsoleKeyAvailableOnFile"));
}
Console為我們封裝了調用Windows API返回的錯誤代碼,而讓代碼引發了一個新的異常。
很顯然,當需要調用Windows API或第三方API提供的接口時,如果對方的異常報告機制使用的是錯誤代碼,最好重新引發該接口提供的錯誤,因為你需要讓自己的團隊更好地理解這些錯誤。
3.重新引發異常時使用Inner Exception
當捕獲了某個異常,將其包裝或重新引發異常的時候,如果其中包含了Inner Exception,則有助於程序員分析內部信息,方便代碼調試。
以一個分布式系統為例,在進行遠程通信的時候,可能會發生的情況有:
1)網卡被禁用或網線斷開,此時會拋出SocketException,消息為:“由於目標計算機積極拒絕,無法連接。”
2)網絡正常,但是要連接的目標機沒有端口沒有處在偵聽狀態,此時,會拋出SocketException,消息為:“由於連接方在一段時間后沒有正確答復或連接的主機沒有反應,連接嘗試失敗。”
3)連接超時,此時需要通過代碼實現關閉連接,並拋出一個SocketException,消息為:“連接超過約定的時長。”
發生以上三種情況中的任何一種情況,在返回給最終用戶的時候,我們都需要將異常信息包裝成為“網絡連接失敗,請稍候再試”。
所以,一個分布式系統的業務處理方法,看起來應該是這樣的:
try
{
SaveUser5(user);
}
catch(SocketException err)
{
throw new CommucationFailureException("網絡連接失敗,請稍后再試",err);
}
但是,在提示這條消息的時候,我們可能需要將原始異常信息記錄到日志里,以供開發者分析具體的原因(因為如果這種情況頻繁出現,這有可能是一個Bug)。那么,在記錄日志的時候,就非常有必要記錄導致此異常出現的內部異常或是堆棧信息。
上文代碼中的:就是將異常重新包裝成為一個CommucationFailureException,並將SocketException作為Inner Exception(即err)向上傳遞。
此外還有一個可以采用的技巧,如果不打算使用Inner Exception,但是仍然想要返回一些額外信息的話,可以使用Exception的Data屬性。如下所示:
try
{
SaveUser5(user);
}
catch(SocketException err)
{
err.Data.Add("SocketInfo","網絡連接失敗,請稍后再試");
throw err;
}
在上層進行捕獲的時候,可以通過鍵值來得到異常信息:
catch(SocketException err)
{
Console.WriteLine(err.Data["SocketInfo"].ToString());
}
4.避免在finally內撰寫無效代碼
你應該始終認為finally內的代碼會在方法return之前執行,哪怕return是在try塊中。
C#編譯器會清理那些它認為完全沒有意義的C#代碼。
private static int TestIntReturnInTry()
{
int i;
try
{
return i=1;
}
finally
{
i=2;
Console.WriteLine("\t將int結果改為2,finally執行完畢");
}
}
5.避免嵌套異常
應該允許異常在調用堆棧中往上傳播,不要過多使用catch,然后再throw。過多使用catch會帶來兩個問題:
- 代碼更多了。這看上去好像你根本不知道該怎么處理異常,所以你總在不停地catch。
- 隱藏了堆棧信息,使你不知道真正發生異常的地方。
嵌套異常會導致 調用堆棧被重置了。最糟糕的情況是:如果方法捕獲的是Exception。所以也就是說,如果這個方法中還存在另外的異常,在UI層將永遠不知道真正發生錯誤的地方。
除了第3點提到的需要包裝異常的情況外,無故地嵌套異常是我們要極力避免的。當然,如果真的需要捕獲這個異常來恢復一些狀態,然后重新拋出,代碼看起來應該是這樣的:
try{
MethodTry();
}
catch(Exception)
{
//工作代碼
throw;
}
或者:
try
{
MethodTry();
}
catch
{
//工作代碼
throw;
}
盡量避免像下面這樣引發異常:
catch(Exception err)
{
//工作代碼
throw err;
}
直接throw err而不是throw將會重置堆棧信息。
6.避免“吃掉”異常
嵌套異常是很危險的行為,一不小心就會將異常堆棧信息,也就是真正的Bug出處隱藏起來。但這還不是最嚴重的行為,最嚴重的就是“吃掉”異常,即捕獲,然后不向上層throw拋出。如果你不知道如何處理某個異常,那么千萬不要“吃掉”異常,如果你一不小心“吃掉”了一個本該往上傳遞的異常,那么,這里可能誕生一個Bug,而且,解決它會很費周折。
避免“吃掉”異常,並不是說不應該“吃掉”異常,而是這里面有個重要原則:該異常可被預見,並且通常情況它不能算是一個Bug。 比如有些場景存在你可以預見的但不重要的Exception,這個就不算一個bug。
7.為循環增加Tester-Doer模式而不是將try-catch置於循環內
如果需要在循環中引發異常,你需要特別注意,因為拋出異常是一個相當影響性能的過程。應該盡量在循環當中對異常發生的一些條件進行判斷,然后根據條件進行處理。
8.總是處理未捕獲的異常
處理未捕獲的異常是每個應用程序應具備的基本功能,C#在AppDomain提供了UnhandledException事件來接收未捕獲到的異常的通知。常見的應用如下:
static void Main(string[]args)
{
AppDomain.CurrentDomain.UnhandledException+=new UnhandledExceptionEventHandler(CurrentDomain_UnhandledException);
}
static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
{
Exception error=(Exception)e.ExceptionObject;
Console.WriteLine("MyHandler caught:"+error.Message);
}
未捕獲的異常通常就是運行時期的Bug,我們可以在App-Domain.CurrentDomain.UnhandledException的注冊事件方法CurrentDomain_UnhandledException中,將未捕獲異常的信息記錄在日志中。值得注意的是,UnhandledException提供的機制並不能阻止應用程序終止,也就是說,執行CurrentDomain_UnhandledException方法后,應用程序就會被終止。
9.正確捕獲多線程中的異常
多線程的異常處理需要采用特殊的方法。以下的處理方式會存在問題:
try{
Thread t=new Thread((ThreadStart)delegate
{
throw new Exception("多線程異常");
});
t.Start();
}
catch(Exception error)
{
MessageBox.Show(error.Message+Environment.NewLine+error.StackTrace);
}
應用程序並不會在這里捕獲線程t中的異常,而是會直接退出。從.NET 2.0開始,任何線程上未處理的異常,都會導致應用程序的退出(先會觸發AppDomain的UnhandledException)。上面代碼中的try-catch實際上捕獲的還是當前線程的異常,而t屬於新起的異常,所以,正確的做法應該是把 try-catch放在線程里面
Thread t=new Thread((ThreadStart)delegate
{
try
{
throw new Exception("多線程異常");
}
catch(Exception error) { .... });
t.Start();
10.慎用自定義異常
除非有充分的理由,否則一般不要創建自定義異常。如果要對某類程序出錯信息做特殊處理,那就自定義異常。需要自定義異常的理由如下:
1)方便調試。通過拋出一個自定義的異常類型實例,我們可以使捕獲代碼精確地知道所發生的事情,並以合適的方式進行恢復。
2)邏輯包裝。自定義異常可包裝多個其他異常,然后拋出一個業務異常。
3)方便調用者編碼。在編寫自己的類庫或者業務層代碼的時候,自定義異常可以讓調用方更方便處理業務異常邏輯。例如,保存數據失敗可以分成兩個異常“數據庫連接失敗”和“網絡異常”。
4)引入新異常類。這使程序員能夠根據異常類在代碼中采取不同的操作。
11.從System.Exception或其他常見的基本異常中派生異常
這個不說了,自定義異常一般是從System.Exception派生。。事實上,現在如果你在Visual Studio中輸入Exception,然后使用快捷鍵Tab,VS會自動創建一個自定義異常類。
12.應使用finally避免資源泄漏
前面已經提到過,除非發生讓應用程序中斷的異常,否則finally總是會先於return執行。finally的這個語言特性決定了資源釋放的最佳位置就是在finally塊中;另外,資源釋放會隨着調用堆棧由下往上執行(即由內到外釋放)。
13.避免在調用棧較低的位置記錄異常
即避免在內部深處處理記錄異常。最適合記錄異常和報告的是應用程序的最上層,這通常是UI層。
並不是所有的異常都要被記錄到日志,一類情況是異常發生的場景需要被記錄,還有一類就是未被捕獲的異常。未被捕獲的異常通常被視為一個Bug,所以,對於它的記錄,應該被視為系統的一個重要組成部分。
如果異常在調用棧較低的位置被記錄或報告,並且又被包裝后拋出;然后在調用棧較高位置也捕獲記錄異常。這就會讓記錄重復出現。在調用棧較低的情況下,往往異常被捕獲了也不能被完整的處理。所以,綜合考慮,應用程序在設計初期,就應該為開發成員約定在何處記錄和報告異常。
總結
推薦看一下篇! 《多綫程/異步/並行/任務》