程式語言 · 例外處理

# 錯誤要丟出去，還是回傳回來：例外 vs 錯誤值（C# throw; 與 TryParse，附 Go 對照）

Chance Lu

凌晨兩點，訂單服務開始報錯。你打開 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**（呼叫堆疊追蹤）。

Main最外層
Orders.KeepTrace接住後再丟
Orders.Load中間轉手
Db.Query在這裡出事

藍線：一層一層呼叫進去　　橘線：例外從 Db.Query 一層一層傳回來

stack trace ＝ Db.Query → Orders.Load → Orders.KeepTrace → Main（第一行就是源頭）

圖 A：例外往回傳的路，就是 stack trace 記下的路（點圖可放大）

> 一句話：stack trace 是例外的**經手紀錄**，第一行就是**出事的地方**。

## 第二步：`throw;` 原單轉交，`throw ex;` 換一張新單

很常見的寫法是：`catch` 起來記一筆 log，再往上丟，讓上層決定怎麼辦。**再丟有兩種寫法，長得只差一個字。**這一步看它們差在哪。

```
catch (Exception ex)
{
    logger.Log(ex.Message);
    throw;      // 寫法一：不帶變數
    // throw ex;  寫法二：帶變數
}
```

- **`throw;`**：把**原本那張通報單**往上交。發生地點、沿路的章都還在。
- **`throw ex;`**：.NET 把它當成**在這一行重新丟一次**。紀錄從這裡重新開始，**源頭到這裡之間的站全部丟掉**。等於撕掉舊單，抄一句訊息到新單上，發生地點寫自己。

throw;　原單轉交
throw ex;　換一張新單
at Db.Query()　line 35
at Orders.Load()　line 29
at Orders.KeepTrace()　line 11
at Main　line 1
at Db.Query()　line 35被丟掉
at Orders.Load()　line 29被丟掉
at Orders.CutTrace()　line 25
at Main　line 4

① Db.Query 丟出例外：紀錄從第 35 行開始兩邊一模一樣
② 例外往上傳到 Orders.Load：紀錄多蓋一個章還沒被接住，兩邊仍一樣
③ catch 接住後再丟：左邊原單照轉；右邊從第 25 行重新開始右邊前兩站被丟掉了
④ 傳到 Main 印出來：左邊找得到源頭，右邊只剩第 25 行右邊就是開頭那份 log

圖 B：同一個例外，兩種再丟法，最後印出的紀錄（動畫會循環，點圖可放大）

你不用自己盯。`throw ex;` 會觸發 .NET 內建的程式碼檢查規則 **CA2200**，**.NET 10 預設就是警告**，`dotnet build` 會直接提醒你。

> 想**補一句說明**再丟？丟一個新例外，把原本的例外當參數傳進去：`throw new OrderException("載入訂單失敗", ex);`。原本那張單會被夾在新單裡（`InnerException` 屬性），紀錄不會丟。

> 一句話：`throw;` 保留整張經手紀錄；`throw ex;` 把紀錄**重設到這一行**，源頭就找不到了。

## 第三步：先問「這是意外，還是預期？」

會丟之後，下一個問題是**該不該丟**。緊急通報單是給「大事」用的。客人填錯郵遞區號也拉警報，大家會被吵死，而且每一層都得準備接。這一步學怎麼判斷。

另一條路是**錯誤值**：把「失敗了、為什麼」當成**一般的回傳值**交回去，像櫃台當場退件給客人一張「請重填」的單子，不驚動任何主管。

Microsoft 的建議很直接：**不常發生、真的意外**的事用例外；**平常就會發生、算正常流程的一部分**，就先檢查、別讓它變成例外。用兩個情境對照：

| 情境 | 常發生嗎？ | 這一層處理得了嗎？ | 做法 |
| --- | --- | --- | --- |
| 使用者把年齡打成 `abc` | 天天發生 | 可以：請他重填 | **回傳錯誤值** |
| 資料庫斷線 | 很少，而且是意外 | 不行：要讓上層和監控知道 | **丟例外** |
| 程式 bug（例如 null） | 不該發生 | 不行：要修程式 | **丟例外**，別接起來假裝沒事 |

這個失敗，平常就會發生，呼叫者也知道怎麼處理嗎？
是
否
回傳錯誤值TryParse、Result 物件例：使用者把年齡打成 abc
丟例外往上傳，讓上層接手處理例：資料庫斷線、程式 bug

圖 C：一個問題就能分出該丟還是該回（點圖可放大）

> 一句話：**意外**才拉警報（丟例外）；**預期**的失敗當場退件（回傳錯誤值）。

## 第四步：C# 怎麼「回傳」一個錯誤

C# 預設是用例外報錯。那預期會失敗的事，要怎麼把錯誤「回」回去？這一步看兩種常見寫法。

### 寫法一：Try 模式

方法名字加 `Try`，**回傳 bool 表示成不成功**，結果從 `out` 參數（方法多交回一個值用的參數）拿：

```
if (int.TryParse(input, out int age))
    Save(age);
else
    ShowError("年齡要填數字");
```

官方設計準則還有兩條：Try 版只吞「定義好的那種失敗」，**別種失敗照樣丟例外**；而且**要同時提供會丟例外的版本**，所以才有 `int.Parse` 和 `int.TryParse` 一對。

### 寫法二：Result 物件

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 實際跑出來的，路徑已省略。

### 練習 1：同一個例外，兩種再丟法

建檔把 `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）。

### 練習 2：TryParse 和 Parse，遇到 abc 各做什麼

建檔把 `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` 用例外回報。使用者輸入，選前者。

### 練習 3：用 Result 物件告訴使用者錯在哪

建檔把 `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 對照：錯誤本來就是值

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 題，這篇就過了。

1. `throw;` 和 `throw ex;` 差在哪？為什麼後者讓除錯變難？
**`throw;` 保留原本的 stack trace；`throw ex;` 把 stack trace 重設到這一行。**
    - 重設後，源頭到這一行之間的方法全被丟掉，log 只指到 `catch` 那一行，找不到真正出事的地方。
    - `dotnet build` 會用 CA2200 警告你。
2. 使用者在年齡欄打了 `abc`，該丟例外還是回傳錯誤？為什麼？
**回傳錯誤值，例如 `int.TryParse` 或 Result 物件。**
    - 打錯字天天發生，是預期的；這一層知道怎麼處理：請他重填。
    - 要告訴他錯在哪，就用 Result 物件帶原因。
3. 查訂單時資料庫斷線，該丟例外還是回傳錯誤？為什麼？
**丟例外。**
    - 這是意外，這一層處理不了，要讓上層和監控知道。
    - 中途要記 log，記完用 `throw;` 再丟，別吞掉也別寫 `throw ex;`。
4. Go 沒有例外，它怎麼區分「預期的失敗」和「意外」？
**預期的失敗回傳 `error` 值（`val, err :=`）；程式沒辦法繼續的 bug 才用 `panic`。**
    - 跟 C# 分界一樣，只是 Go 預設就把錯誤當值。

## 參考來源

- [Microsoft Learn — Best practices for exceptions](https://learn.microsoft.com/en-us/dotnet/standard/exceptions/best-practices-for-exceptions)（stack trace 定義、`throw;` vs `throw e`、常見狀況別用例外、Try 方法）
- [Microsoft Learn — CA2200: Rethrow to preserve stack details](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca2200)（.NET 10 預設為警告）
- [.NET Design Guidelines — Exception Throwing](https://learn.microsoft.com/en-us/dotnet/standard/design-guidelines/exception-throwing)（別用例外做一般流程控制）
- [.NET Design Guidelines — Exceptions and Performance](https://learn.microsoft.com/en-us/dotnet/standard/design-guidelines/exceptions-and-performance)（Try-Parse 模式的規則）
- [.NET Design Guidelines — Using Standard Exception Types](https://learn.microsoft.com/en-us/dotnet/standard/design-guidelines/using-standard-exception-types)（避免接 System.Exception）
- [The Go Blog — Errors are values（Rob Pike）](https://go.dev/blog/errors-are-values)
- [Effective Go — Errors / Panic](https://go.dev/doc/effective_go#errors)（多回傳值、函式庫應避免 panic）
