# 必備 .NET - C# 異常處理
作者?[Mark Michaelis](https://msdn.microsoft.com/zh-cn/magazine/mt149362?author=Mark+Michaelis)?| 2015 年 11 月

歡迎查看首個“必備.NET”專欄。您可以在其中了解 Microsoft .NET Framework 領域的所有最新動態,無論是 C# vNext 的最新進展(當前是 C# 7.0)、改進的 .NET 內部結構,還是 Roslyn 和 .NET 核心前端的最新動態(如轉為開放源代碼的 MSBuild)。
自 .NET 于 2000 年發布預覽版以來,我一直在撰寫和開發與 .NET 有關的內容。我撰寫的大部分內容不僅限于新生事物,而是關于如何利用相應技術,并著眼于最佳做法。
我住在美國華盛頓州斯波坎市,我是 IntelliTect 高端咨詢公司 ([IntelliTect.com](http://intellitect.com/)) 的“首席電腦癡”。IntelliTect 專門從事開發“難度大的產品”,做得很出色。20 年來,我一直是 Microsoft MVP(目前領域是 C#),并且在其中的 8 年里,我還是一名 Microsoft 區域總監。今天,本專欄將啟動探討更新后的異常處理指南。
C# 6.0 新增了兩種異常處理功能。首先,它支持異常條件,即能提供表達式通過在堆棧展開之前進入 catch 塊,篩選出異常。其次,它在 catch 塊內添加了異步支持。在將異步添加到 C# 5.0 語言時,這是無法實現的。此外,之前五版 C# 和相應的 .NET Framework 中也有其他許多變更,在某些情況下這些變更非常重要,需要對 C# 編碼指南進行編輯。在本期內容中,我將回顧許多變更,并提供更新后的編碼指南,因為這些指南與異常處理(即捕獲異常)相關。
## 捕獲異常: 回顧
很好理解的是,引發特定的異常類型可以讓捕獲程序使用異常類型本身來確定問題。換言之,其實沒有必要捕獲異常,也沒有必要通過對異常消息使用 switch 語句來確定采取什么措施處理異常。相反,C# 支持多個 catch 塊,每個 catch 塊都能定位特定的異常類型,如圖 1?所示。
圖 1:捕獲不同的異常類型
~~~
using System;
public sealed class Program
{
? public static void Main(string[] args)
??? try
??? {
?????? // ...
????? throw new InvalidOperationException(
???????? "Arbitrary exception");
?????? // ...
?? }
?? catch(System.Web.HttpException exception)
???? when(exception.GetHttpCode() == 400)
?? {
???? // Handle System.Web.HttpException where
???? // exception.GetHttpCode() is 400.
?? }
?? catch (InvalidOperationException exception)
?? {
???? bool exceptionHandled=false;
???? // Handle InvalidOperationException
???? // ...
???? if(!exceptionHandled)
?????? // In C# 6.0, replace this with an exception condition
???? {
??????? throw;
???? }
??? }??
?? finally
?? {
???? // Handle any cleanup code here as it runs
???? // regardless of whether there is an exception
?? }
?}
}
~~~
當異常發生時,執行會跳至可以處理此異常的第一個 catch 塊。如果有多個 catch 塊與 try 相關聯,則匹配接近程度依繼承鏈而定(假設不含 C# 6.0 異常條件),且首個匹配項將處理異常。例如,即使引發的異常具有類型 System.Exception,這也是“一種”繼承關系,因為 System.Invalid-OperationException 最終源自 System.Exception。由于 InvalidOperationException 最接近匹配引發的異常,因此是 catch(InvalidOperationException...) 會捕獲異常,而不是 catch(Exception...) 塊(如果有的話)。
catch 塊必須按從最具體到最籠統的順序顯示(同樣假設不含 C# 6.0 異常條件),以免出現編譯時錯誤。例如,將 catch(Exception...) 塊添加到其他所有異常之前會導致編譯錯誤,因為之前的所有異常都源自繼承鏈上某處的 System.Exception。另請注意,catch 塊不要求使用命名參數。實際上,最終捕獲即使沒有參數類型也是允許的,不過這只限常規 catch 塊。
有時,在捕獲異常后,您可能會發現實際上無法充分處理異常。在這種情況下,您主要有兩種選擇。第一種選擇是重新引發其他異常。在以下三種常見方案中,您可以這樣做:
方案 1:捕獲的異常無法充分確定異常觸發問題。例如,當使用有效 URL 調用 System.Net.WebClient.DownloadString 時,運行時可能會在沒有網絡連接的情況下引發 System.Net.WebException,不存在的 URL 也會引發同種異常。
方案 2:捕獲的異常包含不得在調用鏈前端公開的專用數據。例如,很早以前的 CLR v1 版本(甚至是初期測試版)有諸如“安全異常: 您無權確定 c:\temp\foo.txt 的路徑”之類的異常。
方案 3:異常類型過于具體,以至于調用方無法處理。例如,當調用 Web 服務查找郵政編碼時,服務器發生 System.IO 異常(如 Unauthorized-AccessException、IOException、FileNotFoundException、DirectoryNotFoundException、PathTooLongException、NotSupportedException、SecurityException 或 ArgumentException)。
重新引發其他異常時,請注意,您可能會丟失原始異常(可能就會發生方案 2 中的情況)。為了避免這種情況,請使用已捕獲的異常設置包裝異常的 InnerException 屬性,通常可以通過構造函數進行分配,除非這樣做會公開不得在調用鏈前端公開的專用數據。這樣一來,原始堆棧跟蹤仍可用。
如果您不設置內部異常,但仍在 throw 語句(引發異常)后面指定異常實例,則異常實例上會設置位置堆棧跟蹤。即使您重新引發之前捕獲的異常(已設置堆棧跟蹤),系統也會進行重置。
第二種選擇是在捕獲異常時,確定您實際上是否無法適當處理異常。在這種情況下,您需要重新引發完全相同的異常,并將它發送給調用鏈前端的下一個處理程序。圖 1?的 InvalidOperationException catch 塊展示的就是這種情況。throw 語句沒有確定要引發的異常(完全依靠自身引發),即使異常實例(異常)出現在可以重新引發的 catch 塊范圍內,也是如此。引發特定的異常會將所有堆棧信息更新為匹配新的引發位置。結果就是,所有指明調用站點(即異常的最初發生位置)的堆棧信息都會丟失,這會導致問題更加難以診斷。在確定 catch 塊無法充分處理異常后,應使用空的 throw 語句重新引發異常。
無論您是要重新引發相同的異常,還是要包裝異常,常規指南是避免在調用堆棧的下端報告或記錄異常。換言之,不要每次捕獲和重新引發異常都進行記錄。這樣做會在日志文件中造成不必要的混亂,并且也不會增加價值,因為每次記錄的內容都相同。此外,異常還包含引發異常時的堆棧跟蹤數據,所以無需每次都進行記錄。請務必記錄處理的異常,或者在不處理的情況下,在關閉進程之前,對異常進行記錄。
## 在不替換堆棧信息的情況下引發現有異常
C# 5.0 中新增了一種機制,可以在不丟失原始異常中的堆棧跟蹤信息的情況下,引發之前已引發的異常。這樣,您便可以重新引發異常(例如,從 catch 塊外部引發),因此無需使用空的 throw。盡管需要這樣做的情況很少,但有時在程序執行移至 catch 塊外部之前,異常可能已包裝或保存。例如,多線程代碼可能使用 AggregateException 包裝異常。.NET Framework 4.5 提供了專門用于處理這種情況的 System.Runtime.ExceptionServices.ExceptionDispatchInfo 類,它是通過使用靜態 Capture 和實例 Throw 方法。圖 2?展示了如何在不重置堆棧跟蹤信息或不使用空的 throw 語句的情況下,重新引發異常。
圖 2:使用 ExceptionDispatchInfo 重新引發異常
~~~
using System
using System.Runtime.ExceptionServices;
using System.Threading.Tasks;
Task task = WriteWebRequestSizeAsync(url);
try
{
? while (!task.Wait(100))
{
??? Console.Write(".");
? }
}
catch(AggregateException exception)
{
? exception = exception.Flatten();
? ExceptionDispatchInfo.Capture(
??? exception.InnerException).Throw();
}
~~~
借助 ExeptionDispatchInfo.Throw 方法,編譯器不會將它看作 return 語句,就像是對正常的 throw 語句一樣。例如,如果方法簽名返回了值,但使用 ExceptionDispatchInfo.Throw 沒有從代碼路徑返回任何值,則編譯器會發出錯誤來指明沒有值返回。有時,開發者可能不得不遵循含 return 語句的 ExceptionDispatchInfo.Throw,即使在運行時此類語句從不執行,而是會引發異常,也是如此。
## 在 C# 6.0 中捕獲異常
常規的異常處理指南是避免捕獲您無法完全處理的異常。然而,由于 C# 6.0 之前的捕獲表達式只能按異常類型進行篩選,因此在檢查異常之前,catch 塊必須是異常的處理程序,才能夠在堆棧展開之前,在 catch 塊處檢查異常數據和上下文。可惜的是,在決定不處理異常后,編寫代碼以便相同上下文內的不同 catch 塊能夠處理異常是一項很繁瑣的做法。此外,重新引發相同的異常會導致不得不再次調用雙步異常進程。此進程涉及的第一步是在調用鏈前端提供異常,直至發現可處理異常的對象;涉及的第二步是為在異常和 catch 位置之間的每個框架展開調用堆棧。
引發異常后,與其因為進一步檢查異常后發現無法充分處理異常,而在 catch 塊處展開調用堆棧,只是為了重新引發異常,不要一開始就捕獲異常明顯是更可取的做法。對于 C# 6.0 及更高版本,catch 塊可以使用額外的條件表達式。C# 6.0 支持條件子句,不再限制 catch 塊是否只能根據異常類型進行匹配。借助 when 子句,您可以提供布爾表達式進一步篩選 catch 塊,僅在條件為 true 時處理異常。圖 1?中的 System.Web.HttpException 塊通過相等比較運算符展示了這一功能。
使用異常條件的有趣結果是,當有異常條件時,編譯器不會強制 catch 塊按繼承鏈中的順序顯示。例如,附帶異常條件的 System.ArgumentException 類型 catch 現在可以顯示在更具體的 System.ArgumentNullException 類型之前,即使后者源自前者,也是如此。這一點非常重要,因為這樣您便可以編寫與常規異常類型(后面是更具體的異常類型,帶有或不帶異常條件)配對的具體異常條件。運行時行為仍然與早期版本的 C# 保持一致;異常由首個匹配的 catch 塊捕獲。增加的復雜性僅僅是,catch 塊是否匹配由類型和異常條件的組合決定,并且編譯器只會強制實施與不帶異常條件的 catch 塊相關的順序。例如,帶有異常條件的 catch(System.Exception) 可以顯示在帶有或不帶異常條件的 catch(System.Argument-Exception) 之前。然而,在不帶異常條件的異常類型的 catch 顯示后,不可能再出現更具體的異常 catch 塊(如 catch(System.ArgumentNullException)),無論其是否帶有異常條件。這樣一來,程序員可以“靈活地”對可能亂序的異常條件進行編碼,早期的異常條件可以捕獲為后面的異常條件而設的異常,甚至可以呈現無意中無法訪問的后期異常。最終,catch 塊的順序與 if-else 語句的順序相似。在條件符合后,系統會忽略其他所有 catch 塊。然而,與 if-else 語句中的條件不同的是,所有的 catch 塊都必須包含異常類型檢查。
## 更新后的異常處理指南
雖然圖 1?中的比較運算符示例非常容易,但異常條件并不只是簡單而已。例如,您可以進行方法調用來驗證條件。唯一的要求是表達式必須是謂詞,可以返回布爾值。換言之,您基本上可以在 catch 異常調用鏈內部執行所需的任何代碼。這樣一來,您就有機會再也不捕獲和重新引發相同的異常;從根本上講,您可以在捕獲異常前,充分地縮小上下文的范圍,這樣就可以僅在這樣做有效時才捕獲異常。因此,避免捕獲您無法完全處理的異常這一指南就可以真正落實。實際上,任何有關空的 throw 語句的條件檢查都可以用代碼進行標記,并且是可以避免的。請考慮添加異常條件,支持使用空的 throw 語句,在進程終止前保持可變的狀態除外。
也就是說,開發者應該將條件子句限制為只檢查上下文。這一點非常重要,因為如果條件表達式本身引發異常,則新的異常會遭到忽略,并且條件會被視為 false。因此,您應該避免在異常條件表達式中引發異常。
## 常規 catch 塊
C# 要求代碼引發的所有對象都必須源自 System.Exception。然而,此要求并不通用于所有語言。例如,C/C++ 允許引發任何對象類型,包括不是源自 System.Exception 的托管異常或基元類型(如整數或字符串)。對于 C# 2.0 及更高版本,所有異常都會作為源自 System.Exception 的異常傳播到 C# 程序集中,無論異常是否源自 System.Exception。結果就是,System.Exception catch 塊會捕獲所有未被之前的 catch 塊捕獲的“合理處理”異常。然而,在 C# 1.0 之前,如果通過方法調用(駐留在程序集中,而不是在 C# 中編寫)引發非源自 System.Exception 的異常,則 catch(System.Exception) 塊不會捕獲異常。因此,C# 也支持行為現在與 catch(System.Exception exception) 塊完全相同的常規 catch 塊 (catch{ }),除非沒有類型或變量名稱。此類塊的缺點就是,沒有可訪問的異常實例,因此沒有辦法了解相應的行動措施。甚至無法記錄異常或確定并不多見的情形(即此類異常無關緊要)。
在實踐中,catch(System.Exception) 塊和常規 catch 塊(本文通常稱為 catch System.Exception 塊)都是可以避免的,只需在關閉進程前記錄異常即可,“處理”異常的幌子除外。遵循只捕獲您可以處理的異常這一基本原則,而編寫程序員聲明的代碼似乎很冒失(此 catch 可以處理所有可能引發的異常)。首先,登記所有異常(特別是在 Main 主體中,其中執行代碼的量是最多的,而且上下文的量似乎是最少的)的工作量似乎非常巨大,最簡單的程序除外。其次,有許多可能意外引發的異常。
在 C# 4.0 之前,程序通常無法恢復第三組的損壞狀態異常。然而,對于 C# 4.0 及更高版本,這個組就不太受到關注,因為 catch System.Exception 塊(或常規 catch 塊)實際上不會捕獲此類異常(就技術而言,您可以使用 System.Runtime.ExceptionServices.HandleProcessCorruptedStateExceptions 修飾方法,這樣即使這些異常被捕獲,您可以充分解決此類異常的可能性也極低。有關詳細信息,請訪問[bit.ly/1FgeCU6](http://bit.ly/1FgeCU6))。
有關損壞狀態異常需要注意的一個技術問題是,只有當異常是由運行時引發時,才會跳過 catch System.Exception 塊。實際上,顯式引發的損壞狀態異常(如 System.StackOverflowException 或其他 System.SystemException)會被捕獲。不過,引發此類異常極具誤導性,獲得支持的原因僅限向后兼容性。如今,指南是不引發任何損壞狀態異常(包括 System.StackOverflowException、System.SystemException、System.OutOfMemoryException、System.Runtime.Interop-Services.COMException、System.Runtime.InteropServices.SEH-Exception 和 System.ExecutionEngineException)。
總之,請避免使用 catch System.Exception 塊,除非是要使用一些清理代碼處理異常,并在重新引發或順暢地關閉應用程序之前,對異常進行記錄。例如,如果 catch 塊可以在關閉應用程序或重新引發異常之前,成功保存任意可變數據(不一定能被假設,因為內容很可能已損壞)。當遇到因為繼續執行不安全而應終止應用程序的情況時,代碼應調用 System.Environment.FailFast 方法。請避免使用 System.Exception 和常規 catch 塊,除非在關閉應用程序前,順暢地記錄異常。
## 總結
在本文中,我介紹了更新后的異常處理指南(與捕獲異常有關),主要是由于過去幾個版本中的 C# 和 .NET Framework 改進才需要更新的。盡管有一些新的指南,但許多指南仍像以前一樣明確可靠。下面介紹了異常捕獲指南的摘要:
* 避免捕獲無法完全處理的異常。
* 避免隱藏(放棄)未完全處理的異常。
* 務必使用 throw 重新引發異常;而不是在 catch 塊內引發 。
* 務必使用已捕獲的異常設置包裝異常的 InnerException 屬性,除非這樣做會公開專用數據。
* 考慮使用異常條件,支持在捕獲無法處理的異常后,重新引發異常。
* 避免通過異常條件表達式引發異常。
* 謹慎重新引發其他異常。
* 盡量少使用 System.Exception 和常規 catch 塊,除非在關閉應用程序前,對異常進行記錄。
* 避免在調用堆棧的下端報告或記錄異常。
若要回顧這些指南的詳細信息,請轉至?[itl.tc/ExceptionGuidelinesForCSharp](http://itl.tc/ExceptionGuidelinesForCSharp)。在未來的專欄中,我打算更加關注異常引發指南。一言以蔽之,引發異常的主題就是: 異常的預期接收方是程序員,而不是程序的最終用戶。
請注意,本文的大部分內容摘取自我的下一版書籍“必備 C# 6.0(第 5 版)”(Addison-Wesley,2015 年)。有關此書的內容,請訪問?[itl.tc/EssentialCSharp](http://itl.tc/EssentialCSharp)。
* * *
Mark Michaelis?*是 IntelliTect 的創始人,擔任首席技術架構師和培訓師。在近二十年的時間里,他一直是 Microsoft MVP,并且自 2007 年以來一直擔任 Microsoft 區域總監。Michaelis 還是多個 Microsoft 軟件設計評審團隊(包括 C#、Microsoft Azure、SharePoint 和 Visual Studio ALM)的成員。他在開發者會議上發表了演講,并撰寫了大量書籍,包括最新的“必備 C# 6.0(第 5 版)”。 可通過他的 Facebook?[facebook.com/Mark.Michaelis](http://facebook.com/Mark.Michaelis)、博客?[IntelliTect.com/Mark](http://intellitect.com/Mark)、Twitter?[@markmichaelis](https://twitter.com/@markmichaelis)?或電子郵件?[mark@IntelliTect.com](mailto:mark@IntelliTect.com)?與他取得聯系。*
- 介紹
- Essential .NET - C# 異常處理
- 崛起 - 具備批判精神
- Windows 10 - 通過搜索索引器加快文件操作速度
- 最前沿的技術 - 利用用戶體驗驅動設計改善體系結構
- 異步編程 - 從頭開始執行異步操作
- 數據點 - Aurelia 與 DocumentDB 結合: 結合之旅
- ASP.NET - 將 ASP.NET 用作高性能文件下載器
- 測試運行 - 使用 C# 執行 t-檢驗
- Microsoft Azure - 通過 SonarQube 和 TFS 管理技術債務
- 孜孜不倦的程序員 - 如何成為 MEAN: Express 路由
- 別讓我打開話匣子 - Alan Turing 和 Ashley Madison
- 編輯寄語 - 歡迎使用 Essential .NET