前言
自從.NET出現后,關於CLR異常機制的討論就幾乎從未停止過。迄今為止,CLR異常機制讓人關注最多的一點就是“效率”問題。其實,這里存在認識上的誤區,因為正常控制流程下的代碼運行並不會出現問題,只有引發異常時才會帶來效率問題。基於這一點,很多開發者已經達成共識:不應將異常機制用於正常控制流中。達成的另一個共識是:CLR異常機制帶來的“效率”問題不足以“抵消”它帶來的巨大收益。CLR異常機制至少有一下幾個優點:
1、正常控制流會倍立即中止,無效值或狀態不會在系統中繼續傳播。
2、提供了統一處理錯誤的方法。
3、提供了在構造函數、操作符重載及屬性中報告異常的便利機制。
4、提供了異常堆棧,便於開發者定位異常發生的位置。
另外,“異常”其名稱本身就說明了它的發生是一個小概率事件。所以,因異常帶來的效率問題會倍限制在一個很小的范圍內。實際上,try catch所帶來的效率問題幾乎忽略的。在某些特定的場合,如Int32的Parse方法中, 確實存在這因為濫用而導致的效率問題。在這種情況下,我們就應該考慮提供一個TryParse方法,從設計的角度讓用戶選擇讓程序運行得更快。另一種規避因為異常而影響效率的方法是:Tester-doer模式,下文將詳細闡述。
本章將給出一些在C#中處理CLR異常方面的通用建議,一幫助大家構建和開發一個運行良好和可靠的應用系統。
本文已同步到http://www.cnblogs.com/aehyok/p/3624579.html。本文主要來學習以下幾點建議
建議58、用拋出異常代替返回錯誤代碼
建議59、不要在不恰當的場合下引發異常
建議60、重新引發異常時使用inner Exception
58、用拋出異常代替返回錯誤代碼
在異常機制出現之前,應用程序普遍采用返回錯誤代碼的方式來通知調用者發生了異常。本建議首先闡述為什么要用拋出異常的方式來代替返回錯誤代碼的方式。
對於一個成員方法來說,它要么執行成功,要么執行失敗。成員方法成功的情況很容易理解。但是如果執行失敗了卻沒有那么簡單,因為我們需要將導致執行失敗的原因通知調用者。拋出異常和返回錯誤代碼都是用來通知調用者的手段。
假設我們要實現這樣一個簡單的功能:應用程序需要完成一次保存新建用戶的操作。這是一個分布式的操作,保存動作除了需要將用戶保存在本地外,還需要通過WCF在遠程服務器上保存數據。負責保存用戶的成員方法如下:
public int SaveUser(User user) { if (!SaveToFile(user)) { return 1; } if (!SaveToDataBase(user)) { return 2; } return 0; } public bool SaveToFile(User user) { return true; } public bool SaveToDataBase(User user) { return true; }
如果單純的看SaveUser方法,似乎一切都還不錯,在約定好了錯誤代碼后,調用者只要接收到1或2,就知道到底是那里出現了問題。但仔細研究會發現,如果方法執行失敗,似乎還可以挖掘出更多的原因。
假設在SaveToFile方法中,我們可能會遇到:
1、程序無數據存儲文件寫權限導致的失敗。
2、硬盤空間不足導致的失敗。
在SaveToDataBase方法中,我們可能會遇到:
1、服務不存在導致的失敗。
2、網絡連接不正常導致的失敗。
當我們想要告訴調用者更多的細節的時候,就需要與調用者約定更多的錯誤代碼。於是我們很快就會發現,錯誤代碼飛速膨脹,直到看起來似乎無法維護。因為我們總在查找並確認錯誤代碼。
采用接下來的方法,可能會省略很大一部分的錯誤代碼:
public bool SaveUser1(User user,ref string errorMessage) { if (!SaveToFile(user)) { errorMessage = "本地保存失敗"; return false; } if (!SaveToDataBase(user)) { errorMessage = "遠程保存失敗"; return false; } return true; }
這看上去不錯,即使存在更多的錯誤也可以將失敗信息呈現給調用者或者上層用戶。然后僅僅呈現失敗信息就可以了嗎?我們來看看這樣一種情況:給失敗通知增加稍微復雜一點的功能。
如果本地保存失敗,要完成“通知運行本段代碼的客戶機管理員”的功能。通常情況下,僅僅只需要顯示類似的信息:“本地保存失敗,請檢查用戶權限”。如果遠程保存失敗,應用程序需要“發送一封郵件給遠程服務器的系統管理員”。總金額個增加的功能導致我們不能像處理“本地保存失敗”那樣來處理“遠程保存失敗”。
一切仿佛又回到了起點,在沒有異常處理機制之前,我們只能返回錯誤代碼,但是現在有了另一種選擇,即使用異常機制。如果使用異常機制,那么最終的代碼看起來應該是下面這樣的:
static void Main(string[] args) { try { SaveUser(new User()); } catch (IOException e) { ///IO異常,通知當前用戶 } catch (UnauthorizedAccessException e) { ////權限異常,通知客戶端管理員 } catch (CommunicationException e) { ///網絡異常,通知發送給網絡管理員 } } public static void SaveUser(User user) { SaveToFile(user); SaveToDataBase(user); }
使用CLR異常機制后,我們會發現代碼變得更清晰、更易於理解了。至於效率問題,還可以重新審視“效率”的立足點:throw exception產生的那點效率損耗與等待網絡連接異常相比,簡直微不足道,而CLR異常機制帶來的好處卻是顯而易見的。
這里需要稍加強調的是,在catch(CommunicationException)這個代碼塊中,代碼所完成的功能是“通知發送”而不是“發送”本身,因為我們要確保在catch和finally中所執行的代碼是可以倍執行的。換句話說,盡量不要在catch和finally中再讓代碼“出錯”,那么讓異常堆棧信息變得復雜和難以理解。
在本例的catch代碼塊中,不要真得編寫發送郵件的代碼,因為發送郵件這個行為可能會產生更多的異常,而“通知發送”這個行為穩定性更高(即不“出錯”)。
以上通過實際的案例闡述了拋出異常相比於返回錯誤代碼的優越性,以及在某些情況下錯誤代碼將無用武之地,如構造函數、操作符重載及屬性。語法特性決定了其不能具備任何返回值,於是異常機制倍當作取代錯誤代碼的首要選擇。
59、不要在不恰當的場合下引發異常
最常見不易引發異常的情況是對在可控范圍內的輸入和輸出引發異常。如下面的代碼所示:
public void SaveUser2(User user) { if (user.Age < 0) { throw new ArgumentOutOfRangeException("Age不能為負數"); } }
暫時可以發現此方法有兩處不妥:
1、判斷Age為負數。這是一個正常的業務邏輯,它不應該倍處理為一個異常。
2、應該采用Tester-Doer來驗證輸入。
我們現在來添加一個Tester方法
public static bool CheckAge(int age,ref string errorMessage) { if (age < 0) { errorMessage = "Age不能為負數"; return false; } else if (age > 100) { errorMessage = "Age不能大於100"; return false; } return true; }
而調用的地方看起來是這樣的
string errorMessage = string.Empty; if (CheckAge(30, ref errorMessage)) { SaveUser(new User()); }
程序員,尤其是類庫開發程序員,要掌握的兩條首要原則是:
正常的業務流程不應使用異常來處理。
不要總是嘗試去捕獲異常或引發異常,而應該允許異常向調用堆棧往上傳播。
那么到底應該在什么情況下引發異常呢?
第一種情況 如果運行代碼后會造成內存泄漏、資源不可用,或者應用程序狀態不可恢復,則引發異常。
第二種情況 在捕獲異常的時候,如果需要包裝一些更有用的信息, 則引發異常。
這類異常的引發在UI層特別有用。系統引發的異常所帶的信息往往更傾向於技術性的描述;而在UI層,面對異常的很可能是最終的用戶。如果需要將異常信息呈現給用戶,更好的做法是先包裝異常,然后引發一個包含友好信息的新異常。
第三種情況 如果底層異常在高層操作的上下文中沒有意義,則可以考慮捕獲這些底層異常,並引發新的有意義的異常。
例如下面的代碼中:
public 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"); } }
如果拋出InvalidCastException則沒有任何意義,甚至會造成誤解,所以更好的方式是拋出一個ArgumentException。
需要重點介紹的正確引發異常的典型例子就是捕獲底層API錯誤代碼,並拋出。查看如下代碼:
public void Test() { int errorCode=Marshal.GetLastWin32Error(); if (errorCode == 6) { throw new InvalidOperationException("具體錯誤"); } }
很顯然當需要調用WIndows API或第三方API提供的接口時,如果對方的異常報告機制使用的是錯誤代碼,最好重新引發該接口提供的錯誤,因為你需要讓自己的團隊更好地理解這些錯誤。
建議60、重新引發異常時使用inner Exception
當捕獲了某個異常,將其包裝或重新引發異常的時候,如果其中包含了Inner Exception,則有助於程序員分析內部信息,方便調試。
可以先來查看以下代碼
static void Main(string[] args) { try { Test(); } catch (Exception err) { Console.WriteLine(err.Message); if (err.InnerException != null) { Console.WriteLine(err.InnerException.Message); } } } public static void Test() { try { SaveUser(new User()); } catch (Exception err) { var ex = new Exception("網絡鏈接失敗,請稍后再試",err); //throw err; //這樣拋出異常會丟掉異常原有的堆棧信息 throw ex; } }
如果不想使用Inner Exception,可以使用如下方式
static void Main(string[] args) { try { Test(); } catch (Exception err) { Console.WriteLine(err.Data["SockInfo"].ToString()); } } public static void Test() { try { SaveUser(new User()); } catch (Exception err) { err.Data.Add("SockInfo", "網絡鏈接失敗,請稍后再試"); throw err; } }
相當於把Test方法中的異常當作Inner Exception,然后向上拋出。
意思其實也就是將異常進行簡單的封裝,然后繼續向上拋出,讓上層來捕獲異常信息。