程式語言 · 例外處理

錯誤要丟出去,還是回傳回來:例外 vs 錯誤值(C# throw; 與 TryParse,附 Go 對照)

Chance Lu · 學習文章 · 2026-11-04 23:32
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; 換一張新單 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 物件。

常見的坑

名詞白話

名詞白話
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 預設就把錯誤當值。

參考來源