你有沒有遇過這種情況:
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 裡只有正常流程。錯誤可以跨多層傳播,不需要每層都明確傳遞。
問題:
- 隱性合約:你不知道一個函式會 throw 什麼,除非讀文件或測試。Java 的 checked exception 強迫你宣告(
throws IOException),Python 和 Ruby 沒有這個機制。 - 控制流不透明:
throw讓程式跳到任意一個catch,這條跳躍路徑在程式碼裡是隱性的。 - 容易忘記處理:沒有
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 型別,函式組合需要學習 map、and_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 時能立刻看到所有的錯誤處理,不讓任何錯誤「隱藏」在語法糖後面。
三種哲學的核心差異
| Exception | Result | Error value | |
|---|---|---|---|
| 錯誤能否被忽略? | 能(容易漏寫 catch) | 不能(編譯器強迫) | 能(但 if err != nil 會提醒你) |
| 錯誤傳播方式 | 隱性冒泡 | 明確的型別鏈 | 顯式 return |
| 正常路徑可讀性 | 高(try block 裡很乾淨) | 中(? 讓它還可以) | 低(錯誤處理分散在各行) |
| 主要風險 | 漏掉錯誤、控制流不透明 | 學習曲線 | 冗長、容易不小心 return nil |
| 代表語言 | Python、Java、Ruby | Rust、Swift、Haskell | Go |
現實中的混合使用
純粹的哲學在現實中都有妥協:
-
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 的思維,需要教育成本。選擇和語言主流用法一致的模式,通常是更好的工程決策。