你有沒有遇過這種情況:

def process_payment(user_id, amount):
    user = get_user(user_id)
    card = user.get_payment_card()
    result = charge_card(card, amount)
    send_receipt(user.email, result)

這個函式每一行都可能失敗,但讀起來完全看不出來。get_user 找不到怎麼辦?charge_card 失敗怎麼辦?你要讀每個函式的文件(或原始碼)才知道它會不會 throw,throw 什麼。

這是 Exception 作為主要錯誤處理機制的問題——錯誤路徑是隱性的。


Exception(Python、Java、Ruby)

Exception 的設計假設:「大多數錯誤是例外情況,不應該讓正常的程式碼流程充斥錯誤處理。」

try:
    user = get_user(user_id)  # 可能 throw UserNotFoundError
    card = user.get_payment_card()  # 可能 throw NoCardError
    charge_card(card, amount)  # 可能 throw PaymentError
except UserNotFoundError:
    return error_response("User not found")
except PaymentError as e:
    log_payment_failure(e)
    return error_response("Payment failed")

優點:正常路徑的程式碼很乾淨,try block 裡只有正常流程。錯誤可以跨多層傳播,不需要每層都明確傳遞。

問題

  1. 隱性合約:你不知道一個函式會 throw 什麼,除非讀文件或測試。Java 的 checked exception 強迫你宣告(throws IOException),Python 和 Ruby 沒有這個機制。
  2. 控制流不透明throw 讓程式跳到任意一個 catch,這條跳躍路徑在程式碼裡是隱性的。
  3. 容易忘記處理:沒有 try/catch 的話,exception 往上冒泡,最終可能變成 HTTP 500 或 crash,沒有人真的處理它。

Result Type(Rust、Haskell、Swift)

Rust 的 Result<T, E> 讓錯誤成為函式回傳型別的一部分——呼叫者不能忽略它:

fn get_user(user_id: u64) -> Result<User, DbError> {
    // ...
}
 
fn process_payment(user_id: u64, amount: f64) -> Result<Receipt, PaymentError> {
    let user = get_user(user_id)?;  // ? 運算子:出錯就立刻 return Err
    let card = user.get_payment_card()?;
    let receipt = charge_card(&card, amount)?;
    Ok(receipt)
}

? 運算子(或 Haskell 的 monadic bind)讓錯誤沿函式鏈傳播,不需要顯式的 if err != nil,但傳播路徑在型別簽名裡是明確的。

Result 的核心優勢是:型別系統強迫你承認函式可能失敗,編譯器保證你處理了 Err 的情況(否則無法 pattern match 完整),錯誤路徑和正常路徑都在可讀的程式碼裡。

代價是需要習慣 Result 型別,函式組合需要學習 mapand_then? 等模式。


Error Value(Go)

Go 選擇了最傳統的方式:函式可以回傳多個值,錯誤作為最後一個值回傳:

func getUser(userID int) (User, error) {
    // ...
}
 
func processPayment(userID int, amount float64) error {
    user, err := getUser(userID)
    if err != nil {
        return fmt.Errorf("get user: %w", err)
    }
    card, err := user.getPaymentCard()
    if err != nil {
        return fmt.Errorf("get card: %w", err)
    }
    _, err = chargeCard(card, amount)
    return err
}

Go error value 的優勢是極度明確——每個可能的錯誤點都在程式碼裡可見,不需要學習新的抽象,if err != nil 是最基礎的 Go 語法,錯誤是普通的值,可以被 log、修改、包裝,和其他值一樣。

批評是 if err != nil 佔了大量的行數,正常路徑和錯誤路徑混在一起,視覺上很吵。

Go 的設計者的回應是:冗長是刻意的。它讓你在 code review 時能立刻看到所有的錯誤處理,不讓任何錯誤「隱藏」在語法糖後面。


三種哲學的核心差異

ExceptionResultError value
錯誤能否被忽略?能(容易漏寫 catch)不能(編譯器強迫)能(但 if err != nil 會提醒你)
錯誤傳播方式隱性冒泡明確的型別鏈顯式 return
正常路徑可讀性高(try block 裡很乾淨)中(? 讓它還可以)低(錯誤處理分散在各行)
主要風險漏掉錯誤、控制流不透明學習曲線冗長、容易不小心 return nil
代表語言Python、Java、RubyRust、Swift、HaskellGo

現實中的混合使用

純粹的哲學在現實中都有妥協:

  • Java 的 Checked exception 是 exception + 編譯器強制的混合:某些 exception 你必須宣告或捕獲。結果是開發者大量用空的 catch(Exception e) {} 繞過強制(「反模式中的反模式」)。

  • Go 的 panic 是 exception 機制——Go 有 panic/recover,用於真正不可恢復的錯誤(index out of bounds、nil pointer dereference)。它不是主要的錯誤處理機制,但它存在。

  • Python 的 Optional/Union + type checking:Python 3.10+ 可以標 def get_user(id: int) -> User | None,配合 mypy,讓 Python 的錯誤處理更接近 Result type 的可見性。


實際後端開發的判斷

在設計一個服務的錯誤處理策略時,有幾個問題值得問:

這個錯誤是「預期的失敗」還是「程式 bug」?

預期的失敗(user not found、payment declined、rate limit exceeded)應該明確處理,用 Result / error value 更合適。程式 bug(nil pointer、array out of bounds)應該讓它 crash,暴露問題,用 panic / uncaught exception。

錯誤要跨多少層傳播?

如果你的函式鏈有 5 層,每層都要顯式傳遞 err,Go 的風格很冗長但清晰;Rust 的 ? 讓這件事優雅得多。

團隊熟悉哪個模式?

在 Python/Java 的 team 引入 Result type 的思維,需要教育成本。選擇和語言主流用法一致的模式,通常是更好的工程決策。

下一篇:錯誤包裝(wrapping)