凌晨兩點,訂單服務開始報錯。你打開 log,只看到這幾行:
System.TimeoutException: 資料庫連線逾時
at Orders.CutTrace() in Program.cs:line 25
at Program.<Main>$(String[] args) in Program.cs:line 4
(<Main>$ 就是 Program.cs 最上面那幾行頂層程式碼。)你跳到第 25 行。那裡沒有查詢,只有一句 throw ex;。到底是哪個查詢逾時?log 沒說。線索就在這一行被剪斷了。
這篇講兩件事:錯誤要怎麼「再丟」才不會剪斷線索;以及一個錯誤,到底該當例外丟出去,還是當成一個值回傳回來。
一句話:再丟例外寫throw;,別寫throw ex;。意外的失敗(資料庫斷線、bug)丟例外;預期會發生的失敗(使用者打錯字)回傳錯誤值。
方法會一層呼叫一層。最底層出事時,最上層要知道是哪裡、哪一行出的事。這一步認識幫你找到源頭的那份紀錄。
把例外想成公司裡的一張緊急通報單。哪個部門出事,就在單子最上面寫「發生地點」;單子往上傳,每經過一個主管就蓋一個章。
.NET 的例外也一樣。它被丟出時,會記下從哪個方法、哪一行出發,又經過哪些方法往上傳,直到被 catch 接住。這份清單叫 stack trace(呼叫堆疊追蹤)。
圖 A:例外往回傳的路,就是 stack trace 記下的路(點圖可放大)
一句話:stack trace 是例外的經手紀錄,第一行就是出事的地方。
throw; 原單轉交,throw ex; 換一張新單很常見的寫法是:catch 起來記一筆 log,再往上丟,讓上層決定怎麼辦。再丟有兩種寫法,長得只差一個字。這一步看它們差在哪。
catch (Exception ex)
{
logger.Log(ex.Message);
throw; // 寫法一:不帶變數
// throw ex; 寫法二:帶變數
}
throw;:把原本那張通報單往上交。發生地點、沿路的章都還在。throw ex;:.NET 把它當成在這一行重新丟一次。紀錄從這裡重新開始,源頭到這裡之間的站全部丟掉。等於撕掉舊單,抄一句訊息到新單上,發生地點寫自己。圖 B:同一個例外,兩種再丟法,最後印出的紀錄(動畫會循環,點圖可放大)
你不用自己盯。throw ex; 會觸發 .NET 內建的程式碼檢查規則 CA2200,.NET 10 預設就是警告,dotnet build 會直接提醒你。
想補一句說明再丟?丟一個新例外,把原本的例外當參數傳進去:throw new OrderException("載入訂單失敗", ex);。原本那張單會被夾在新單裡(InnerException屬性),紀錄不會丟。
一句話:throw;保留整張經手紀錄;throw ex;把紀錄重設到這一行,源頭就找不到了。
會丟之後,下一個問題是該不該丟。緊急通報單是給「大事」用的。客人填錯郵遞區號也拉警報,大家會被吵死,而且每一層都得準備接。這一步學怎麼判斷。
另一條路是錯誤值:把「失敗了、為什麼」當成一般的回傳值交回去,像櫃台當場退件給客人一張「請重填」的單子,不驚動任何主管。
Microsoft 的建議很直接:不常發生、真的意外的事用例外;平常就會發生、算正常流程的一部分,就先檢查、別讓它變成例外。用兩個情境對照:
| 情境 | 常發生嗎? | 這一層處理得了嗎? | 做法 |
|---|---|---|---|
使用者把年齡打成 abc | 天天發生 | 可以:請他重填 | 回傳錯誤值 |
| 資料庫斷線 | 很少,而且是意外 | 不行:要讓上層和監控知道 | 丟例外 |
| 程式 bug(例如 null) | 不該發生 | 不行:要修程式 | 丟例外,別接起來假裝沒事 |
圖 C:一個問題就能分出該丟還是該回(點圖可放大)
一句話:意外才拉警報(丟例外);預期的失敗當場退件(回傳錯誤值)。
C# 預設是用例外報錯。那預期會失敗的事,要怎麼把錯誤「回」回去?這一步看兩種常見寫法。
方法名字加 Try,回傳 bool 表示成不成功,結果從 out 參數(方法多交回一個值用的參數)拿:
if (int.TryParse(input, out int age))
Save(age);
else
ShowError("年齡要填數字");
官方設計準則還有兩條:Try 版只吞「定義好的那種失敗」,別種失敗照樣丟例外;而且要同時提供會丟例外的版本,所以才有 int.Parse 和 int.TryParse 一對。
bool 只能說「成功/失敗」。要告訴使用者錯在哪,就回傳一個物件,裡面裝著「成不成功、值、原因」。.NET 標準庫沒有內建通用的 Result 型別,通常自己寫一個小 record(「動手做」練習 3 有完整版):
record AgeResult(bool Ok, int Value, string? Error);
一句話:只要知道成不成功,用 Try 模式;還要帶原因,用 Result 物件。
catch (Exception) 然後什麼都不做。資料庫斷了,程式卻照樣回「成功」,錯誤被吞掉。官方準則:除了程式最外層那個「接住所有錯誤、記 log」的地方,避免接 Exception;處理不了的例外就別接,讓上層處理。int.Parse 加 catch 來判斷輸入對不對。實測 10 萬筆 "abc":TryParse 約 6 ms,Parse 加 catch 約 230 ms,慢了約 40 倍(.NET 10 Release、arm64,數字依機器而異)。| 名詞 | 白話 |
|---|---|
| stack trace | 例外的經手紀錄:從哪一行丟出、經過哪些方法傳上來 |
| rethrow(再丟) | 在 catch 裡把接到的例外再往上丟;寫 throw; 才保留紀錄 |
| 錯誤值 | 把「失敗了、為什麼」當一般回傳值交回去,例如 TryParse 的 bool、Result 物件 |
| panic(Go) | Go 的「程式沒辦法繼續了」,留給 bug 這類不該發生的事 |
先開一個練習資料夾:
mkdir lab && cd lab && dotnet new console
每個練習都是把 Program.cs 整個換掉再 dotnet run(預設是 Debug 建置,所以看得到行號)。先猜、再跑、再對答案。輸出是在 .NET 10 實際跑出來的,路徑已省略。
建檔把 Program.cs 換成:
try { Orders.KeepTrace(); }
catch (Exception ex) { Console.WriteLine("== throw; ==\n" + ex.StackTrace); }
try { Orders.CutTrace(); }
catch (Exception ex) { Console.WriteLine("== throw ex; ==\n" + ex.StackTrace); }
static class Orders
{
public static void KeepTrace()
{
try { Load(); }
catch (Exception ex)
{
Console.WriteLine("log: " + ex.Message);
throw;
}
}
public static void CutTrace()
{
try { Load(); }
catch (Exception ex)
{
Console.WriteLine("log: " + ex.Message);
throw ex;
}
}
static void Load() => Db.Query();
}
static class Db
{
public static void Query() =>
throw new TimeoutException("資料庫連線逾時");
}
先猜兩段 stack trace,哪一段看得到 Db.Query?
執行dotnet run。建置時先跳出警告:
warning CA2200: Re-throwing caught exception changes stack information
接著印出:
log: 資料庫連線逾時
== throw; ==
at Db.Query() in Program.cs:line 35
at Orders.Load() in Program.cs:line 29
at Orders.KeepTrace() in Program.cs:line 11
at Program.<Main>$(String[] args) in Program.cs:line 1
log: 資料庫連線逾時
== throw ex; ==
at Orders.CutTrace() in Program.cs:line 25
at Program.<Main>$(String[] args) in Program.cs:line 4
你會看到throw; 那段第一行就是源頭 Db.Query 第 35 行;throw ex; 那段只剩第 25 行,也就是開頭那份 log(圖 B)。
建檔把 Program.cs 換成:
string[] inputs = ["42", "abc", ""];
foreach (var s in inputs)
{
if (int.TryParse(s, out int n))
Console.WriteLine($"TryParse(\"{s}\") → 成功,n = {n}");
else
Console.WriteLine($"TryParse(\"{s}\") → false,請使用者重填");
}
foreach (var s in inputs)
{
try
{
Console.WriteLine($"Parse(\"{s}\") → {int.Parse(s)}");
}
catch (FormatException ex)
{
Console.WriteLine($"Parse(\"{s}\") → 丟出 {ex.GetType().Name}");
}
}
先猜空字串 "" 給 TryParse,會丟例外嗎?
執行dotnet run:
TryParse("42") → 成功,n = 42
TryParse("abc") → false,請使用者重填
TryParse("") → false,請使用者重填
Parse("42") → 42
Parse("abc") → 丟出 FormatException
Parse("") → 丟出 FormatException
這證明同一件「預期的失敗」,TryParse 用 false 回報,Parse 用例外回報。使用者輸入,選前者。
建檔把 Program.cs 換成:
foreach (var input in new[] { "30", "abc", "200" })
{
var r = ParseAge(input);
Console.WriteLine(r.Ok ? $"\"{input}\" → 年齡 {r.Value}"
: $"\"{input}\" → 退回:{r.Error}");
}
static AgeResult ParseAge(string input)
{
if (!int.TryParse(input, out int n))
return new(false, 0, "年齡要填數字");
if (n is < 0 or > 150)
return new(false, 0, "年齡要在 0 到 150 之間");
return new(true, n, null);
}
record AgeResult(bool Ok, int Value, string? Error);
先猜"200" 是合法數字,會被退回嗎?
執行dotnet run:
"30" → 年齡 30
"abc" → 退回:年齡要填數字
"200" → 退回:年齡要在 0 到 150 之間
這證明Result 物件能帶不同的原因回去,全程沒有任何例外。
Go 沒有例外。函式把錯誤當成第二個回傳值交回來,呼叫者用 if err != nil 檢查。Go 團隊的 Rob Pike 把這個想法寫成一句話:Errors are values。
n, err := strconv.Atoi("abc")
if err != nil {
fmt.Println("錯誤是一個值:", err)
}
錯誤是一個值: strconv.Atoi: parsing "abc": invalid syntax
那「意外」呢?Go 有 panic,用在程式沒辦法繼續、或不可能的事發生了。Effective Go 也說,一般函式庫應該避免 panic。
| C# | Go | |
|---|---|---|
| 預設怎麼報錯 | 丟例外 | 回傳 error 值 |
| 預期的失敗 | Try 模式、Result 物件 | val, err :=,這就是預設 |
| 意外、bug | 例外 | panic |
兩邊分界其實一樣:預期的失敗當值處理,意外才中斷程式流程。差別只在哪一種是預設。
能不看筆記回答下面 4 題,這篇就過了。
throw; 和 throw ex; 差在哪?為什麼後者讓除錯變難?看答案收合throw; 保留原本的 stack trace;throw ex; 把 stack trace 重設到這一行。
catch 那一行,找不到真正出事的地方。dotnet build 會用 CA2200 警告你。abc,該丟例外還是回傳錯誤?為什麼?看答案收合int.TryParse 或 Result 物件。
throw; 再丟,別吞掉也別寫 throw ex;。error 值(val, err :=);程式沒辦法繼續的 bug 才用 panic。
throw; vs throw e、常見狀況別用例外、Try 方法)