摘 要
人非聖賢,孰能無過。代碼是人寫的,當然也不可能不出錯,我們只能期望代碼更健壯,不可能追求完美,能做更多的就是如何從錯誤中恢複或尋找替代方案。CLR提供了異常處理機制,它不僅能讓代碼在出錯的時候更優雅地讓人們去解決異常,也能在必要的時候拋出異常。那麼,如何更規範的定義和使用異常訊息呢?拋出異常會不會影響效能呢?
第一節 CLR中的異常
在早期的Win32 API設計中是通過返回true/false來表示一個過程(方法、函數)是否執行成功,在COM中是使用HRESULT來表示一個過程是否正確執行,然而這種處理異常的方式使開發人員對哪裡出錯,為什麼出錯,出什麼樣的錯這些問題很難找到明確的答案,再一點,調用者很容易忽略一個過程執行的結果,如果調用者丟棄了過程執行結果,則代碼將“按照期望的狀態正常執行”,這是很危險的。後來,在.NET Framework中,已經不再使用這種簡單的以狀態代碼來表示執行結果的處理方式,而是使用拋出異常的方式來告訴調用者:兄弟,出錯了!如果你不趕緊修複,系統將讓你無法進入決賽!
CLR中的異常是基於SEH的結構化異常處理機制構建的,在基礎的SEH機制中是向系統註冊出錯時的回呼函數,當在監視區內出錯時,系統會取得當前線程的控制權處理回呼函數,處理完畢後,系統釋放控制權給當前線程,當前線程繼續執行,如果未處理異常的程式碼片段,會導致進程中止。有關SEH的詳細內容請參考MSDN文檔。
CLR在對SEH封裝的基礎上以更優雅的方式向開發人員提供良好的編程體驗,它把異常處理分為三塊try、catch、finally。
Try塊是監視區,其內放置一些正常實現編程功能的代碼、資源清除的代碼、狀態維護(狀態改變和狀態恢複)的代碼等。
Catch塊捕獲區,當try塊出現異常時,如果異常類型與該地區期望的類型一致,則執行此地區的代碼,可以進行狀態恢複,也可以重新拋出異常。一個try塊可以個catch塊,也可以無catch塊。
Finally塊作最後清理工作,在一個try/catch 結構中,無論try是否拋出異常,無論catch是否破獲到異常,如果有finally塊,在最後都會執行,通常在這裡放置資源清理的代碼。一個try結構可有finally塊,也可以沒有。
如下是一個使用try/catch/finally塊的樣本:
FileStream fs = null; try { fs = new FileStream("c:\\file.txt", FileMode.Open); fs.ReadByte(); } catch (IOException) { } catch { } finally { if (fs != null) { fs.Close(); fs = null; } }
需要注意的是,一個try塊必須有一個catch塊或finally塊與其對應,如下幾種使用方式都是可以的:try…catch 、try…finally、try…catch.finally。
第二節 CLR處理異常
前面已經說到,一個try塊可以有對應的0個或多個catch塊,如果try塊中無異常拋出,則CLR不會執行任何catch塊。Catch關鍵字後的圓括弧中的運算式稱為捕捉類型,每個catch塊只能指定一個捕捉類型,C#要求捕捉類型只能是 System.Ex ception類或其衍生類別。當try塊出現了異常,CLR會以catch編碼的順序從上向尋找與異常類型相匹配的catch 塊,所以“窄”的異常類型應該放在最前面的catch 塊中,最“寬”的異常類型應該放在最後面的catch塊中。假如有如下繼承關係的虛擬碼:
Class 類A:類B:類C:System.Exception
我信通常應該如下來放置catch篩選器類型:
try{ //}catch(A){}catch(B){}catch(C){}finally{}
如果尋找完catch 塊都沒有發現有匹配的類型,CLR會去向更高一層的調用棧尋找相匹配的異常類型,如果到了棧的最頂部還未找到,CLR會拋出“未處理異常”。在堆棧上尋找的過程中,如果找到了相匹配的catch塊,則執行從拋出異常的try塊開始到匹配異常的catch 塊為止這個範圍內的所有的finally塊(如果有)。注意,此時匹配的catch關聯的finally還未執行,執行完該匹配catch塊內的代碼後才執行此finally塊。
無論拋出哪種異常,其實都是CLR拋出經過封裝後的.NET異常,也就是說CLR已經在內部對這些異常進行過處理,只是以更優雅且強制的形勢拋出來。假如有如下的調用過程:
方法Method0內調用方法Method1,方法Method1內調用方法Method2。如果在方法Method2方法內拋出一個異常,且無與此異常類型匹配的catch塊,則CLR會回溯呼叫堆疊向Method2、Method1、Method0尋找匹配的異常類型,如果找到了則執行相關的finally和catch塊, 如果還未找到,則拋出未處理異常,如下代碼:
public void Method0() { try { Method1(); } catch { Debug.WriteLine("Method0 catch"); } finally { Debug.WriteLine("Method0 finally"); } } public void Method1() { try { Method2(); } catch (NullReferenceException) { Debug.WriteLine("Method1 catch NullReferenceException"); } catch (FileNotFoundException fileNot) { Debug.WriteLine("Method1 catch FileNotFoundException"); throw fileNot; } finally { Debug.WriteLine("Method1 finally"); } } public void Method2() { FileStream fs = null; try { //假如c:\\file.txt不存在,這裡一定會拋出檔案未找到異常 fs = new FileStream("c:\\file.txt", FileMode.Open); fs.ReadByte(); } catch (ArgumentException) { Debug.WriteLine("Method2 ArgumentException"); } finally { Debug.WriteLine("Method2 finally"); if (fs != null) fs.Close(); fs = null; } }
在方法Method2內我們僅僅期望捕獲 ArgumentException類型異常,很顯然逮不到任何東西。在方法Method1內我們先期望捕獲NullReferenceException類型異常,如果未逮到,我們期望捕獲FileNotFoundException類型異常,這時可能真的逮到了,接著我們又將該異常拋出,而在上一級調用中,Method0方法內我們使用了異常的基類捕獲範圍超級廣的異常!在APP內調用Method0方法通過列印出來的記錄來看一下執行流程:
public void DoWork() { Method0(); }
列印:
Method2 finallyMethod1 catch FileNotFoundExceptionMethod1 finallyMethod0 catchMethod0 finally
結果驗證了CLR對異常處理的回溯過程。為了減少回溯的“長度”,建議在方法Method2內趕緊有目的地捕獲可能的異常類型,這樣讓CLR少走點路。
第三節 定義異常
在上面我們已經講到,CLR是以拋出異常的方式來報告程式出現問題。早期微軟定義了一個異常的基類:System.Exception,並且還定義了兩個衍生類別:System.SystemException和System.ApplicationException。它們希望所有系統異常(CLR拋出)都必須派生於System.SystemException類,所有應用程式拋出的異常必須派生於System.ApplicationException類,但後來地方人員不聽從中央人員的管理,結果從上到下都沒很好的遵從這個原則,以至於我們後來在定義自己的異常類型時,直接從System.Exception類派生。大勢已成定局,至於你喜歡從哪個類派生,看你的愛好了,其實在MSDN文檔中已經建議我們應該從System.Exception類派生我們自訂的異常類型。
CLR要求自訂的異常類型必須從System.Exception繼承。
FCL已經定義了很多可能用在各種情景下的異常類型,如常用的:
System. ArgumentException 參數無效時的異常System. FileNotFoundException 訪問磁碟上不存在的檔案時引用的異常System. IndexOutOfRangeException試圖訪問索引超出數組界限的數組元素時引發的異常
儘管FCL還有很多的異常類型,但這些不一定適合我們開發的需要,比如方便記錄日誌、方便調查項目層次、WCF中的異常訊息等等,這些特殊的要求可能需要我們定義自己的異常類型。
關於異常System.Exception類本身的資訊(如:Message、TargetSite、InnerException等成員)這裡就不再描述,可查詢MSDN文檔擷取更多資訊。除了自訂異常類必須繼承於System.Exception類以外,這裡還給出一些自訂異常類型的建議:
(1) 所有的自訂異常類型名稱應該以Exception尾碼;
(2) 類型及其成員據,應該支援可序列化(實際上System.Exception類型是支援序列化的);
(3) 要提供以下三個建構函式:
public MyException() { } public MyException(string message) { } public MyException(string message, Exception inner) {}
(4) 建議重寫ToString()方法來擷取異常的格式化資訊。
(5) 在跨越應用程式邊界的開發環境中,如面向服務開發環境,應該考慮異常的相容性。
第四節 合理地拋出異常
定義異常的目的是在合適的時候拋異常來告訴客戶程式:這裡出異常了。通常是在一個方法內拋異常,當方法無法完成預定任務時應該拋出異常,拋出異常時應該拋出明確的異常類型,而永遠不要拋出異常的基底類型System.Exception、System.SystemException和System.ApplicationException。在第二節中,我們已經描述過,CLR是自上而下的尋找catch塊,所以我們拋出異常的時候,也應該拋出意義明確的“窄”且“淺”的異常類型,這樣可能會讓CLR儘快找到。另外,在拋出異常類型時,我們應該詳細地描述出現異常的原因、狀況、可能的修複措施等,這樣有利於調用者儘快定位問題,當然在面向服務開發的時候,可能出於安全考慮而不願意詳細描述,此時可以以提前約定的編碼形式拋出異常碼,此時可能需要你向客戶程式提供一個與異常碼對應的描述資訊列表。盡量不要使用返回錯誤碼的方式來代替異常,前面已經講過,客戶程式很可能忽略你的返回結果。如程式遇到了致命性的錯誤,請大膽地使用System.Environment.FailFast()方法來毫不留情的終止程式,否則程式以錯誤的狀態運行可能會帶來災難,比如你的無人駕駛飛機可能去火星上找“好奇號”談戀愛。
以下幾個系統保留的異常類型,應該盡量避免拋出:StackOverflowException、OutOfMemoryException、ComException 和 SEHException、ExecutionEngineException。
第五節 合理地處理異常
catch塊是專為處理異常而生的,CLR賦予它很強悍無比但不是至高無上的權力,所以我們應該使用catch塊合理恰當的捕獲我們有能力處理的異常,這裡給一些建議或許能更好地讓代碼從異常中恢複:
(1) 如果你開發的是基礎類庫,出現異常時,哪怕是客戶程式提供的資料無法讓你完成功能流程時,要雄赳赳氣昂昂地拋出異常,不要忍氣吞聲地吃掉它;
(2) 不要在catch塊內編寫可能再出異常的代碼,除非是拋出異常,也要盡量保證不在finally塊內編寫可能出新異常的代碼;
(3) 不要捕獲你沒能力處理的異常,關閉你的大門,讓CLR儘快點離開向上回溯找到那個像鐵籠賽中拍打著籠門急等著出來拼戰的猛士,他或許能拯救這個世界;
(4) 盡量不要使用catch(Exception)撒天網,它將死的很慘;
(5) 捕獲了異常後,你應該儘快讓代碼資料從異常中恢複,如果不能回複,你應該想辦法讓狀態復原,否則,要麼你就繼續拋出異常,要麼就拿出尚方寶劍System.Environment.FailFast();
(6) 如果在捕獲異常後出於某種目的,想再次拋出異常,請保持堆棧資訊,這樣方便在上層排查異常;
第六節 幾個易混淆的地方
(1) catch塊和catch(Exception)塊
CLS要求所有面向CLR的程式設計語言都必須拋出繼承於System.Exception的異常類型。在CLR 2.0以前的版本中,catch塊只能捕獲與CLS相容的異常,它無法捕獲與CLS不相容的任何異常(包括其他面向CLR的程式設計語言拋出的異常)。在CLR2.0中,微軟有了一個新的類型System.Runtime.CompilerServices.RuntimeWrappedException,當一個與CLS不相容的類型拋出時,CLR會執行個體化一個RuntimeWrappedException類型的對象,將在其私人欄位WrappedException中放置非CLS相容的異常,接著拋出RuntimeWrappedException。
catch(Exception e){}塊,在CLR2.0以前可以捕獲所有與CLS相容和不相容的異常,但CLR2.0及以後的版本中,只能捕獲與CLS相容的異常。
Catch{}塊在所有版本中可以捕獲所有與CLS相容和不相容的異常。
(2) throw和throw ex
在前面我們講過,CLR有可能通過回溯堆棧來尋找與異常類型一致的catch塊,System.Exception類有一個屬性StackTrace,它記錄了發生異常的堆疊追蹤資訊,它描述的是異常發生前調用的方法。當一個異常拋出時,CLR會記錄拋出異常(throw)的位置,經過CLR回溯找到對應的catch塊後,CLR會再記錄catch的位置,然後在內部會使用StackTrace記錄這兩個起止位置之間的調用(方法)過程。我們對第2節中的方法Method1進行改造,在捕獲了FileNotFoundException類型的異常後,重新拋出該異常:
catch (FileNotFoundException fileNot) { Debug.WriteLine("Method1 catch FileNotFoundException"); throw fileNot; //throw; }
先用throw fileNot;看看在Method0方法中捕獲到的異常堆棧資訊:
再來看看使用throw;拋出異常的堆棧資訊:
可以看到,使用throw fileNot;後的堆棧資訊是從那個方法為異常堆棧資訊的起點,使用throw;後的規模資訊是從System.IO.__Error.WinIOError方法為起點,二者無非就是CLR確定異常的起始位置不一樣。
最後要明確一點的是,無論在什麼情景下拋出什麼樣的異常,Windows都會重設堆棧的起點,我們所拿到的堆棧資訊都是最新的起止方法調用記錄資訊。
小 結