這是我在大改版《C# 本事》電子書的時候所增加的一章。從 C# 7 到目前的 C# 10 都沒有對例外處理增加新功能,故本文對 C# 熟手來說,大概僅止於複習吧。若能有溫故知新的效果,那也不錯。
程式執行的過程中,任何時候都有可能發生意外,也就是正常情況下不會發生的情形,或者一些難以預料的狀況。這些意外的狀況,統稱為「例外」(exceptions)。現代程式語言大多有提供一組語法來專門處理例外狀況,以便適當劃分「正常流程」與「例外流程」的程式碼,避免它們彼此糾纏,也讓程式碼更容易閱讀、更好維護。
在 C# 中,用來處理例外的關鍵字主要有四個:try
、catch
、finally
、和 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~7 行:開啟一個文字檔,然後讀取檔案內容,再輸出至螢幕。如果一切順利,跳至第 3 步(
finally
區塊)。如果讀取檔案的過程發生FileIOException
類型的例外,則跳至相應的catch
區塊。 - 第 8~11 行:處理
FileIOException
類型的例外,並將錯誤訊息輸出至螢幕,然後進入finally
區塊。 - 第 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
:拋出例外
我們已經看過例外處理的三個主要關鍵字:try
、catch
、finally
。現在要介紹最後一個: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
,代表「系統層級」的例外,像是 ArgumentException
、NullReferenceException
、IOException
等等。這些由 .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 與其他電子書平台。等到所有章節內容大致底定,會在這裡和臉書發布消息。歡迎關注、點讚、糾錯與建議。
沒有留言: