程式執行時,不一定每件事情都能正常完成。
例如:
檔案不存在
外部服務連線失敗
輸入格式錯誤
Method 收到不合法的參數
當操作無法正常完成時,程式會怎麼執行?
如果目前的 Method 無法處理錯誤,又該由誰處理?
今天要學的就是:
Exception Handling
→ 例外處理
今天的核心:
理解 Exception 如何改變程式執行流程,以及如何在適當的位置處理或傳遞錯誤。
Exception
→ 例外發生後,正常流程如何改變?
try / catch
→ 如何捕捉與處理例外?
throw
→ 如何主動拋出例外?
Exception Propagation
→ 例外如何沿著 Method 呼叫鏈傳遞?
finally
→ 哪些工作不論成功或失敗都需要執行?
實務判斷
→ 什麼時候該 catch、throw,或使用正常流程?
今天最重要的觀念不是:
有錯誤
→ 全部 try-catch
而是:
在真正有能力處理錯誤的位置處理;如果目前無法處理,就讓 Exception 繼續往適當的上層傳遞。
先看一段程式:
Console.WriteLine("A");
int number =
int.Parse("abc");
Console.WriteLine("B");
int.Parse() 會嘗試把字串轉成整數。
例如:
"123"
→ 可以轉成 int
但:
"abc"
→ 無法轉成 int
因此:
int.Parse("abc");
會拋出:
FormatException
執行流程:
輸出 A
↓
int.Parse("abc")
↓
拋出 FormatException
↓
目前正常流程中斷
↓
尋找可以處理 Exception 的位置
所以:
A
會輸出,但:
B
不會執行。
可以先理解成:
Exception 是用來表示程式執行期間發生異常狀況的機制;當 Exception 被拋出時,原本的正常執行流程會被中斷。
正常情況:
Method
↓
完成工作
↓
正常結束
發生 Exception:
Method
↓
無法正常完成
↓
拋出 Exception
↓
錯誤處理流程接手
如果目前沒有任何地方處理它,Exception 會繼續往呼叫端傳遞。
如果最後仍然沒有被處理,就會成為:
Unhandled Exception
→ 沒有被處理的例外
並可能造成目前程式流程終止。
try / catch:捕捉與處理 Exception如果某段程式可能拋出 Exception,可以使用:
try
{
}
catch
{
}
例如:
try
{
Console.WriteLine("A");
int number =
int.Parse("abc");
Console.WriteLine("B");
}
catch (FormatException)
{
Console.WriteLine(
"C:輸入格式錯誤"
);
}
Console.WriteLine("D");
執行結果:
A
C:輸入格式錯誤
D
| 步驟 | 執行內容 |
|---|---|
| 1 | 進入 try,輸出 A |
| 2 | int.Parse("abc") 拋出 FormatException |
| 3 | 跳過 try 中尚未執行的 B |
| 4 | 找到符合的 catch |
| 5 | 輸出 C |
| 6 | catch 正常完成後,繼續執行 D |
流程:
try
↓
輸出 A
↓
int.Parse("abc")
↓
FormatException
↓
跳過 B
↓
catch
↓
輸出 C
↓
離開 try-catch
↓
輸出 D
所以:
try
→ 執行可能拋出 Exception 的程式
catch
→ 發生指定 Exception 時進行處理
要特別注意:
catch不會讓程式回到發生 Exception 的那一行重新執行。
如果 catch 正常完成,程式會從整個 try-catch 後面繼續。
catch 可以取得這次發生的 Exception:
try
{
int number =
int.Parse("abc");
}
catch (FormatException ex)
{
Console.WriteLine(
ex.Message
);
}
這裡的:
ex
就是這次發生的 FormatException。
例如:
ex.Message
可以取得錯誤訊息。
這些資訊在實務上常用於:
Logging
除錯
問題追蹤
而對使用者顯示的訊息,通常會另外整理成比較容易理解的內容。
catch不同問題可能拋出不同的 Exception。
例如:
try
{
int number =
int.Parse(
"999999999999999999999"
);
}
catch (FormatException)
{
Console.WriteLine(
"輸入格式不正確。"
);
}
catch (OverflowException)
{
Console.WriteLine(
"數字超出 int 的範圍。"
);
}
整理:
FormatException
→ 格式無法轉換
OverflowException
→ 數值超過可接受範圍
因此:
優先捕捉你知道如何處理的具體 Exception。
如果同時存在具體與一般的 Exception:
catch (FormatException)
{
}
catch (Exception ex)
{
}
順序應該是:
Specific
↓
General
不要因為:
catch (Exception)
能捕捉很多錯誤,就到處使用它。
throw:表示 Method 無法正常完成前面的 Exception 是由:
int.Parse()
拋出的。
我們自己的 Method 也可以主動拋出 Exception。
假設建立訂單時:
Quantity 必須大於 0
可以寫:
void CreateOrder(int quantity)
{
if (quantity <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(quantity),
"訂單數量必須大於 0。"
);
}
Console.WriteLine(
$"建立訂單,數量:{quantity}"
);
}
呼叫:
CreateOrder(0);
因為:
quantity = 0
↓
quantity <= 0
↓
true
所以會執行:
throw new ArgumentOutOfRangeException(
nameof(quantity),
"訂單數量必須大於 0。"
);
接著:
CreateOrder()
↓
正常流程中斷
↓
後面的程式不再執行
↓
Exception 往外傳遞
throw 和顯示訊息不一樣下面這段:
Console.WriteLine(
"訂單數量不合法"
);
只是:
顯示訊息
↓
程式仍然可以繼續
但:
throw new ArgumentOutOfRangeException(
nameof(quantity)
);
則是:
拋出 Exception
↓
目前正常流程中斷
↓
交給 Exception Handling
所以:
throw不是單純顯示錯誤,而是明確表示目前操作無法正常完成。
return 和 throw兩者都可能結束目前 Method,但語意不同。
| 語法 | 用途 |
|---|---|
return |
正常結束 Method,必要時回傳結果 |
throw |
表示操作無法正常完成,進入 Exception Handling |
可以這樣記:
return
→ 正常完成
throw
→ 無法正常完成
這是今天最重要的觀念之一。
假設程式有三個層次:
呼叫端
↓
ProcessOrder()
↓
CreateOrder()
程式:
void ProcessOrder()
{
Console.WriteLine(
"開始處理訂單"
);
CreateOrder(0);
Console.WriteLine(
"訂單處理完成"
);
}
void CreateOrder(int quantity)
{
if (quantity <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(quantity),
"訂單數量必須大於 0。"
);
}
Console.WriteLine(
"訂單建立成功"
);
}
呼叫端:
try
{
ProcessOrder();
}
catch (ArgumentOutOfRangeException)
{
Console.WriteLine(
"訂單數量不合法。"
);
}
Console.WriteLine(
"返回主流程"
);
執行結果:
開始處理訂單
訂單數量不合法。
返回主流程
為什麼沒有:
訂單處理完成
因為:
ProcessOrder()
↓
CreateOrder(0)
↓
throw
↓
CreateOrder 無法正常完成
↓
Exception 回到 ProcessOrder
↓
ProcessOrder 沒有 catch
↓
繼續往呼叫端傳遞
↓
找到 catch
這稱為:
Exception Propagation
→ 例外傳遞
核心:
Exception 會沿著 Method 呼叫鏈往外傳遞,直到找到可以處理它的
catch。
學會 try-catch 不代表每一層都應該 catch。
前面的例子可以整理成:
| 層次 | 責任 |
|---|---|
CreateOrder() |
知道 quantity 是否符合規則,不合法時 throw |
ProcessOrder() |
負責協調流程,無法處理就讓 Exception 往外傳 |
| 呼叫端 | 有足夠資訊時進行提示、記錄或其他處理 |
所以:
不是每個 Method 都需要
try-catch。
判斷的關鍵不是:
這裡可能出錯嗎?
→ 有
→ 加 try-catch
而是:
目前這一層真的知道發生錯誤後應該怎麼處理嗎?
例如目前這一層知道:
要顯示什麼訊息
要不要重試
要怎麼補救
要怎麼回應使用者
那麼:
catch
才有意義。
如果不知道:
不要為了 catch 而 catch。
學會 Exception Handling 後,還有一個很重要的問題:
這個失敗真的屬於 Exception,還是只是正常流程的一部分?
例如使用者輸入:
abc
這是很常見、可以預期的情況。
如果使用:
int.Parse(input);
確實可能產生 FormatException。
但這種情況通常更適合:
int.TryParse()
例如:
Console.Write(
"請輸入數字:"
);
string? input =
Console.ReadLine();
if (
int.TryParse(
input,
out int number
)
)
{
Console.WriteLine(
$"轉換成功:{number}"
);
}
else
{
Console.WriteLine(
"請輸入有效的整數。"
);
}
流程:
輸入 "123"
→ true
→ number = 123
輸入 "abc"
→ false
→ 顯示輸入提示
所以:
不要使用 Exception 控制正常、可預期的流程。
| 情況 | 常見方式 |
|---|---|
| 正常條件判斷 | if |
| 可預期的轉換失敗 | TryParse() |
| 違反 Method 的操作前提 | throw |
| 目前這層知道如何處理 Exception | catch |
| 目前無法合理處理 | 讓 Exception 往外傳 |
例如:
使用者輸入可能不是數字
→ TryParse()
Quantity <= 0
違反 CreateOrder() 的操作前提
→ throw
呼叫端知道如何提示使用者
→ catch
catch這種寫法應該避免:
try
{
CreateOrder(0);
}
catch (Exception)
{
}
這代表:
Exception 發生
↓
被 catch
↓
什麼都沒做
↓
錯誤被隱藏
這常被稱為:
Swallow Exception
→ 把 Exception 吞掉
問題是:
呼叫端不知道操作失敗
錯誤沒有被記錄
很難除錯
很難追蹤問題
因此:
catch不是為了讓錯誤消失,而是要做有意義的處理。
例如:
提供提示
記錄 Log
執行補救
轉換錯誤
決定是否重新拋出
有時某一層只能先記錄問題,但真正的處理仍然要交給上層。
例如:
try
{
CreateOrder(0);
}
catch (ArgumentOutOfRangeException ex)
{
Console.WriteLine(
$"記錄錯誤:{ex.Message}"
);
throw;
}
這裡:
throw;
代表:
重新拋出目前捕捉到的 Exception,讓它繼續往外傳。
流程:
Exception
↓
catch
↓
記錄資訊
↓
throw;
↓
繼續往上傳
目前先理解它的用途即可。
finally:必要的收尾工作有些程式不論:
正常完成
或
發生 Exception
離開 try 時都需要執行必要的收尾。
這時可以使用:
finally
例如:
try
{
int number =
int.Parse("123");
Console.WriteLine(number);
}
catch (FormatException)
{
Console.WriteLine(
"格式錯誤"
);
}
finally
{
Console.WriteLine(
"處理結束"
);
}
結果:
123
處理結束
如果改成:
int.Parse("abc");
則會得到:
格式錯誤
處理結束
所以在一般的 Exception Handling 流程中:
try 正常完成
↓
finally
或:
try
↓
Exception
↓
catch
↓
finally
可以先理解成:
finally適合放置不論成功或失敗,都需要執行的收尾工作。
例如:
釋放資源
關閉連線
必要的清理工作
不過:
不是每個
try-catch都需要finally。
如果沒有必要的清理工作,就不用為了語法完整而刻意加入。
現代 C# 對許多需要釋放的 Resource,也會搭配:
using
處理。
using 與 IDisposable 之後有需要時再深入。
現在把今天的觀念整合起來。
需求:
建立訂單
Quantity 必須 > 0
CreateOrder()
→ 負責檢查並建立訂單
ProcessOrder()
→ 負責協調流程
呼叫端
→ 負責處理已知錯誤
完整程式:
try
{
ProcessOrder(
"A001",
5
);
ProcessOrder(
"A002",
0
);
}
catch (ArgumentOutOfRangeException ex)
{
Console.WriteLine(
$"建立失敗:{ex.Message}"
);
}
Console.WriteLine(
"返回主流程"
);
void ProcessOrder(
string orderId,
int quantity
)
{
Console.WriteLine(
$"開始處理訂單 {orderId}"
);
CreateOrder(
orderId,
quantity
);
Console.WriteLine(
$"訂單 {orderId} 處理完成"
);
}
void CreateOrder(
string orderId,
int quantity
)
{
if (quantity <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(quantity),
"訂單數量必須大於 0。"
);
}
Console.WriteLine(
$"訂單 {orderId} 建立成功,數量:{quantity}"
);
}
執行結果:
開始處理訂單 A001
訂單 A001 建立成功,數量:5
訂單 A001 處理完成
開始處理訂單 A002
建立失敗:訂單數量必須大於 0。
返回主流程
ProcessOrder("A002", 0)
↓
CreateOrder("A002", 0)
↓
quantity <= 0
↓
throw ArgumentOutOfRangeException
↓
CreateOrder 中斷
↓
Exception 回到 ProcessOrder
↓
ProcessOrder 沒有 catch
↓
繼續往呼叫端傳遞
↓
呼叫端找到 catch
↓
顯示錯誤訊息
因此:
Console.WriteLine(
$"訂單 {orderId} 處理完成"
);
在第二筆訂單中不會執行。
這個範例最重要的觀念是:
發現問題的位置,不一定等於處理問題的位置。
CreateOrder() 最清楚:
quantity 合不合法
所以由它決定是否:
throw
而呼叫端最清楚:
發生錯誤後應該如何回應
所以由它:
catch
今天可以整理成:
Method 執行
↓
可以正常完成?
↙ ↘
是 否
↓ ↓
正常完成 發生異常
↓
throw
↓
Exception 往外傳
↓
目前這層能處理嗎?
↙ ↘
能 不能
↓ ↓
catch 繼續往上傳
另外:
finally
→ 必要時負責收尾與清理
實務上可以依序問三個問題:
1. 這是正常、可預期的分支嗎?
→ if / TryParse / 一般回傳
2. 是否違反 Method 的操作前提,
或發生目前無法正常處理的異常?
→ throw
3. 目前這一層真的知道怎麼處理嗎?
→ 知道:catch
→ 不知道:讓 Exception 往外傳
今天最重要的概念:
| 語法 / 概念 | 用途 |
|---|---|
try |
執行可能拋出 Exception 的程式 |
catch |
在有能力處理時捕捉指定 Exception |
throw |
表示目前操作無法正常完成 |
| Exception Propagation | Exception 沿著 Method 呼叫鏈往外傳遞 |
finally |
執行必要的收尾或清理工作 |
實務上可以記成:
正常、可預期的流程
→ if / TryParse / 一般回傳
操作無法正常完成
→ throw
目前這一層知道如何處理
→ catch
目前無法處理
→ 讓 Exception 往上傳
需要必要收尾
→ finally
今天最重要的一句話:
Exception Handling 的目的不是把所有錯誤攔下來,而是讓無法正常完成的操作被清楚表示,並在真正有能力處理的位置進行處理。