C# 例外處理(Exception Handling)

這是我在大改版《C# 本事》電子書的時候所增加的一章。從 C# 7 到目前的 C# 10 都沒有對例外處理增加新功能,故本文對 C# 熟手來說,大概僅止於複習吧。若能有溫故知新的效果,那也不錯。


程式執行的過程中,任何時候都有可能發生意外,也就是正常情況下不會發生的情形,或者一些難以預料的狀況。這些意外的狀況,統稱為「例外」(exceptions)。現代程式語言大多有提供一組語法來專門處理例外狀況,以便適當劃分「正常流程」與「例外流程」的程式碼,避免它們彼此糾纏,也讓程式碼更容易閱讀、更好維護。

在 C# 中,用來處理例外的關鍵字主要有四個:trycatchfinally、和 throw。稍後介紹相關語法時,還會提到例外篩選器(exception filters)、釋放資源的標準寫法(using 陳述句)、基礎類別 System.Exception 與其家族成員,以及例外處理的實務建議。

先來看一個典型的寫法:用 try 陳述式包住一個正常流程的程式碼區塊,並將處理例外狀況的程式碼寫在 catch 區塊。像這樣:

try
{
    ... // 正常流程的程式碼
}
catch
{
    ... // 意外狀況發生時,會執行此區塊的程式碼
}

這個 try...catch 的寫法可以這樣來理解:「嘗試執行一些程式碼,看看會不會出錯。萬一真的發生意外狀況,就中斷目前的正常流程,並且把捕捉到的例外交給 catch 區塊來繼續處理。」

剛才的範例只是例外處理的一種常見形式。實務上,我們還可以更細緻地針對不同類型的例外來個別處理。像這樣:

try
{
    ... // 正常流程的程式碼
}
catch (FileNotFoundException fileEx)
{
    ... // 處理「檔案找不到」的意外狀況。
}
catch (SqlException sqlEx)
{
    ... // 處理資料庫相關操作的意外狀況。
}
catch (Exception ex)
{
    ... // 前面幾張「網子」都沒抓到的漏網之魚,全都在此處理。
}

由上例可知,try 區塊之後可以有多個 catch 區塊,而且每個 catch 區塊可以指定要捕捉的特定例外類型。

值得一提的是 catch 區塊之間的順序:愈特殊的例外類型要寫在愈上面,而愈是一般的例外類型應該寫在愈下面。以上面的例子來說,如果把第一個和第三個 catch 區塊的位置交換,在語意上就會變成優先捕捉 Exception 類型的例外;由於 .NET 中的任何例外類型都是 Exception 的後代,所以任何例外狀況都會被 catch (Exception ex) 捕捉到,那麼寫在下方的其他 catch 區塊就等於完全沒作用了。不過你也不用太擔心自己會寫錯 catch 的順序,因為當編譯器發現這種情況時就會出現編譯失敗的錯誤訊息。

catch 子句的幾種寫法

catch 子句有多種寫法,前面範例所展示的只是其中一種。方便起見,這裡再重複貼一次:

catch (FileIOException ex)   // 捕捉到例外時,把例外物件指派給變數 ex,
{                            // 以便稍後可以存取例外的屬性或方法。    
    Console.WriteLine(ex.Message);
}

如果在 catch 區塊中不需要操作例外物件,則可以省略變數,像這樣:

catch (FileIOException) // 僅指定欲捕捉的例外類型。
{
    ... 
}

又或者寫得更簡略,就如本節最早的範例所用的寫法:

catch     // 捕捉任何例外,且不在乎何種例外。
{
    ...
}

例外篩選器

在 catch 子句中還可以加上 when 子句來指定篩選條件,例如:

catch (WebException ex) when (ex.Status == WebExceptionStatus.Timeout)
{
    ...
}

在上面的範例中,如果 try 區塊拋出了 WebException,被 catch 區塊捕捉到,在此同時,就會檢查 when 子句中的篩選條件是否為真。若結果為真,才會執行該 catch 區塊的程式碼;若結果為假,則會跳過此 catch 區塊,並繼續往下處理其他 catch 子句(如果有的話)。

加入了例外篩選條件之後,我們將能夠更細緻地處理各種不同的例外狀況,甚至還可以對同一種例外捕捉兩次以上,例如:

catch (WebException ex) when (ex.Status == WebExceptionStatus.Timeout)
{ 
    ... 
}
catch (WebException ex) when (ex.Status == WebExceptionStatus.SendFailure)
{
    ...
}

finally 子句:最終必定會執行

try 區塊底下一定要跟著至少一個 catch 區塊,要不然就必須是 finally 區塊——其作用為:無論執行過程是否發生例外狀況,最終都要執行 finally 區塊中的程式碼。一種常見的寫法是在 finally 區塊中撰寫資源回收的工作,例如關閉檔案、關閉網路連線或資料庫連線等等。參考以下範例:

StreamReader reader = null;
try
{
    reader = new StreamReader("app.config");
    var content = reader.ReadToEnd();
    Console.WriteLine(content);
}
catch (FileIOException ex)
{
    Console.WriteLine(ex.Message);
}
finally
{
    if (reader != null)
    {
        reader.Dispose();
    }    
}

程式碼說明:

  1. 第 1~7 行:開啟一個文字檔,然後讀取檔案內容,再輸出至螢幕。如果一切順利,跳至第 3 步(finally 區塊)。如果讀取檔案的過程發生 FileIOException 類型的例外,則跳至相應的 catch 區塊。
  2. 第 8~11 行:處理 FileIOException 類型的例外,並將錯誤訊息輸出至螢幕,然後進入 finally 區塊。
  3. 第 12~18 行:關閉先前已經開啟的檔案,並釋放相關資源。

有兩個細節值得提出來說一下。

首先,try 區塊中的任何一行程式碼都有可能發生我們意想不到的錯誤,而當某一行程式碼出錯時,try 區塊中剩下的程式碼就不會被執行到,因為此時會立刻跳到某個 catch 區塊(如果有的話);待某 catch 區塊執行完畢,便會執行 finally 區塊的程式碼。

其次,如果 try 區塊中的程式碼在執行過程中發生例外,而那個例外並沒有被任何 catch 區塊捕捉並處理,則程式會跳到 finally 區塊,等到此區塊的程式碼執行完畢,那個尚未被處理的例外依然存在,所以會被拋到上一層程式碼區塊。如果上一層程式碼也沒有捕捉並處理那個例外,則又會繼續往上拋,直到它被處理為止。要是某個例外狀況一路往上拋,直到應用程式最外層的主程式區塊都沒有被處理,此時應用程式可能就會意外中止,而使用者可能會看到程式底層 API 拋出的錯誤訊息。

簡言之,只要例外沒有被目前的程式區塊所捕捉並處理,就一定會往外層拋。因此,如果在 catch 區塊中的程式碼又引發了例外,那個新產生的例外自然也會往外層拋。實務上,在 catch 區塊中處理例外時,應注意避免再度引發新的例外(除非是刻意為之)。

使用 using 來釋放資源

上一個小節的範例中,finally 區塊裡面使用了 reader.Dispose() 來釋放檔案資源,這是 .NET 程式很常見的寫法。其實不只是檔案,其他像是網路連線、資料庫連線等等,這些都是屬於無法由 .NET 執行環境自動回收的資源(即所謂的 unmanaged resources),故必須在寫程式的時候明確呼叫特定的方法來釋放資源。由於釋放資源是相當常見的工作,於是 .NET 基礎類別庫定義了一個介面來規範一致的寫法: System.IDisposable。此介面只定義了一個方法,即用來釋放資源的 Dispose

也就是說,只要是用來處理 unmanaged resources 的類別(例如剛才提到的檔案、資料庫連線等等),都必須實作 IDisposable,以便在適當時機呼叫 Dispose 方法來釋放物件所占用的資源。於是,我們經常會撰寫類似底下的程式碼:

StreamReader reader = null;
try
{
    reader = new StreamReader("app.config");
    ...
}
finally
{
    if (reader != null)
    {
        reader.Dispose();
    }    
}

上述寫法堪稱標準範本,只是程式碼超過 10 行,顯得過於繁瑣、笨重。因此,C# 提供了一個簡便的語法:using 宣告,讓我們能夠用一行程式碼就完成上述工作:

using var reader = new StreamReader("app.config");

或者你也可以加上一對大括弧來明確限定物件的存活範圍:

using (var reader = new StreamReader("app.config"))
{
    ... // 在此區塊內皆可使用 reader 物件
}

一旦程式離開了 using 區塊,reader 就會被自動釋放(自動呼叫其 Dispose 方法)。

那麼,如果使用了單行 using 宣告的語法,reader 物件又是何時釋放呢?答案是:在它所屬的區塊結束時。請看以下範例:

if (File.Exists("app.config"))
{
    using var reader = new StreamReader("app.config");
    Console.WriteLine(reader.ReadToEnd());
}

當程式離開 if 區塊時,reader 便會自動釋放。

throw:拋出例外

我們已經看過例外處理的三個主要關鍵字:trycatchfinally。現在要介紹最後一個:throw

當你在程式的某處需要引發例外來中斷正常流程時,便可以使用 throw 來拋出一個例外。範例:

void Print(string name)
{
    if (name == null)
    {
        throw new ArgumentNullException(nameof(name));
    }
    Console.WriteLine(name);
}

Print 方法會先檢查參數 name 是否為 null,如果是的話,便拋出一個 ArgumentNullException 類型的例外,讓呼叫端知道此函式堅決不接受傳入的參數為 null。

第 3~6 行程式碼也可以用一行解決:ArgumentNullException.ThrowIfNull(name);

順便提及,當你需要拋出 NullReferenceException,除了用 throw new NullReferenceException(),也可以這樣寫:

throw null;

再度拋出例外

你也可以在 catch 區塊裡面使用 throw 來將目前的例外再度拋出:

try
{
    DoSomething();
}
catch (Exception ex)
{
    Log(ex); // 把當前的例外資訊寫入 log。
    throw;   // 再次拋出同一個例外。
}

這裡有個細節值得一提:在上述範例中,如果把倒數第二行寫成 throw ex 也可以通過編譯,但其作用稍微不同:

  • 單單寫 throw 會保留原本的堆疊追蹤資訊(stack trace)。也就是說,透過堆疊追蹤資訊,便能抓到原始的例外。
  • 如果寫 throw ex,則會重設堆疊追蹤資訊,這將導致呼叫端無法透過例外物件的 StackTrace 屬性得知引發例外的源頭是哪一行程式碼。基於此緣故,通常不建議採用此寫法。

如果你想觀察兩種寫法在執行時有何差異,可用瀏覽器開啟此連結:ThrowAndThrowEx.cs,然後按照其中的提示來修改程式碼並觀察執行結果。

呼叫堆疊與堆疊追蹤

堆疊追蹤(stack trace)是一個字串,裡面包含了當前呼叫堆疊(call stack)裡面的所有方法的名稱,以及方法所在的程式碼行號——如果編譯時有啟用除錯資訊的話。
呼叫堆疊指的是一個記憶體區塊,其中保存了某一條執行緒當時的所有方法呼叫的資訊,例如傳入方法的參數、方法的區域變數等等。


於 catch 區塊中再次拋出例外時,你也可以拋出一個新的、不同類型的例外:

DateTime StringToDate(string input)
{
    try
    {
        return Convert.ToDateTime(input);
    }
    catch (FormatException ex)
    {
        throw new ArgumentException($"無效的引數: {nameof(input)}", ex);
    }
}

請注意上述範例在拋出一個新的 ArgumentException 時,有把當前的例外 ex 傳入建構子的第二個參數,這會把當前的例外保存於新例外的 InnerException 屬性。也就是說,在拋出新例外的同時,依然保存原始的例外資訊(也許呼叫端在診斷錯誤的時候會用到)。

一般來說,以下幾種場合會在 catch 區塊中拋出不同的例外:

  • 在處理當前的例外時,又發生了其他意外狀況。
  • 讓外層接收到更一般的例外類型,以便隱藏底層的細節(不想讓外界知道太多、避免駭客攻擊)。
  • 讓外層接收到更特殊的例外類型,以便呼叫端更明確知道發生錯誤的原因。

Exception 類別及其家族成員

System.Exception 類別是所有例外的共同祖先(基礎類別),其常用的屬性有:

  • StackTrace:是一個字串,裡面包含了發生例外當下的函式呼叫堆疊(call stack)中的所有方法名稱。
  • Message:描述錯誤訊息的字串。
  • InnerException:內部例外。如果不是 null 的話,則是引發當前例外的上一個例外。此屬性的型別也是 Exception,故每個內部例外都可能還有另一個內部例外。

這裡無法完整呈現 System.Exception 的所有子類別與繼承關係,但是有個概略的認識還是有幫助的,如下圖(命名空間 System 已省略):

由上而下依序來看,Exception 繼承自所有 .NET 類別的共同祖先,也就是 Object,並且有兩個子類別,分別代表例外類型的兩個大分類:SystemException 和 ApplicationException

SystemException 繼承自 Exception,代表「系統層級」的例外,像是 ArgumentExceptionNullReferenceExceptionIOException 等等。這些由 .NET 平台所拋出的例外,都是所謂的「系統例外」,它們通常代表無法回復的嚴重錯誤。

ApplicationException 也是繼承自 Exception,但是它沒有增加任何功能,只是單純用來「分類」,作為應用程式自訂例外的基礎類別。換言之,應用程式如果需要定義自己的例外型別,便可以優先考慮繼承自 ApplicationException。不過,由於 Exception 即代表所有執行時期發生的錯誤,所以實務上在設計自訂例外類別的時候,許多人也會直接繼承自 Exception

除了拋出例外,你還有其他選擇

本節要探討一個實務上的問題:在寫程式時,是不是碰到任何狀況都應該拋出例外?若答案為否,那我們還有什麼選擇?

比如說,當你在撰寫一個函式,你可能會在處理關鍵工作之前預先檢查各個相關的參數或變數值,確保它們不能為 null 或者不合理的數值(例如員工薪資不可為負數),以免函式傳回不正確的結果而導致重大災難。也就是說,一旦發現任何異常狀況,就要立刻阻止程式繼續往下執行。此時你有兩個選擇:傳回某種形式的錯誤碼(可能是數字或字串),或者拋出例外。

一般而言,如果是正常流程不會出現的狀況(例如寫入檔案時,磁碟突然被拔出),或者是無法修正、無法回復的錯誤,這些情形都可以拋出例外。至於其他「情節輕微」或者容易修正的小問題,則可以考慮傳回某種旗號或錯誤碼來通知呼叫端。

如果要更貼心一點,也可以同時提供兩種口味。我們在 .NET 標準類別庫裡面也能看到這樣貼心的設計,例如 int 類別的 Parse 方法就有兩種口味:

public int  Parse   (string input);
public bool TryParse(string input, out int returnValue);

當 Parse 剖析字串的動作發生錯誤,它會拋出例外;TryParse 則不會拋出例外,而是傳回 false 來讓呼叫端根據不同的情形來執行不同的程式路徑,類似這樣:

int result;
if (int.TryParse(input, out result))
{
    Console.WriteLine(result);
}
else 
{
    // 無法將字串轉成整數時,有其他應對方式。
}

相較於拋出例外,TryParse 這種傳回成功或失敗旗號的作法就像是在告訴你:「如果字串無法轉成整數也是你預期的合理狀況之一,而且你會根據不同狀況作出相應的處理,那選擇我就對了。」


Keep coding! 💪


💬備註:這是我在改版《C# 本事》電子書的時候所增加的一章。此次改版幅度頗大,所以我是在一個獨立分支裡面進行改版工作。也因為這個緣故,目前新增的章節還不會出現在 Leanpub 與其他電子書平台。等到所有章節內容大致底定,會在這裡和臉書發布消息。歡迎關注、點讚、糾錯與建議。

沒有留言:

技術提供:Blogger.
回頂端⬆️