錯誤#
程式碼運行的環境本質上是不完美的:使用者會提供無效輸入、外部系統會故障、我們自身與周圍的程式碼也難免存在 bug。因此,錯誤是不可避免的。撰寫穩健且可靠的程式碼,必須深思熟慮地面對錯誤情境。本章探討如何區分可復原與不可復原的錯誤,以及確保錯誤不被忽略且被妥善處理的技巧。
4.1 可復原性(Recoverability)#
思考一套軟體時,首先要判斷某個錯誤情境是否有合理的方式可以復原。
4.1.1 可復原的錯誤#
許多錯誤並非致命的,可以優雅地處理並復原。例如使用者輸入了無效的電話號碼,如果整個應用程式因此崩潰(甚至遺失未儲存的工作),這將是極差的使用者體驗。更好的做法是顯示友善的錯誤訊息,請使用者重新輸入。
常見的可復原錯誤包括:
- 網路錯誤:依賴的服務無法連線時,可以等待幾秒後重試,或請使用者檢查網路連線
- 非關鍵任務錯誤:例如記錄使用統計時發生錯誤,通常可以繼續執行
一般而言,大多數由外部因素引起的錯誤,系統整體應該嘗試優雅地復原,因為這些事情是可以預期會發生的:外部系統和網路會中斷、檔案會損毀、使用者(或駭客)會提供無效輸入。
4.1.2 不可復原的錯誤#
有時錯誤發生後,系統沒有合理的方式可以復原。這些通常是程式錯誤(programming error),也就是某位工程師「搞砸了」。例如:
- 應該與程式碼打包在一起的資源遺失
- 某段程式碼誤用了另一段程式碼:
- 以無效的輸入參數呼叫
- 未預先初始化所需的狀態
當錯誤確實無法復原時,唯一合理的做法是盡量限制損害並最大化工程師注意到並修復問題的可能性。
4.1.3 通常只有呼叫者才知道錯誤是否可復原#
大部分錯誤發生在一段程式碼呼叫另一段程式碼時。因此處理錯誤情境時,必須仔細思考誰會呼叫我們的程式碼,特別是:
- 呼叫者是否可能想要從錯誤中復原?
- 如果是,呼叫者如何得知需要處理這個錯誤?
以解析電話號碼的函式為例:如果傳入的是硬編碼的錯誤值,那是程式錯誤,無法復原;如果傳入的是使用者輸入的值,那就是可以且應該復原的情境,應該在 UI 中顯示友善的錯誤訊息。
判斷原則:如果以下任一條件成立,函式因輸入造成的錯誤都應視為呼叫者可能想要復原的情境:
- 我們不完全了解函式會從哪些地方被呼叫,以及輸入值的來源
- 程式碼未來即使只有最微小的可能被重複使用
唯一的例外是:程式碼的合約(contract)明確指出某個輸入是無效的,且呼叫者有簡單明顯的方式可以在呼叫前驗證。
flowchart TD
A["發生錯誤"] --> B{"錯誤可復原嗎?"}
B -- "是" --> C["可復原"]
B -- "否" --> D["不可復原"]
C --> E["優雅處理"]
D --> F["快速失敗"]
E --> G["重試操作"]
E --> H["使用備援方案"]
E --> I["通知使用者"]
F --> J["程式錯誤"]
F --> K["狀態已損壞"]
F --> L["必要資源遺失"]
4.1.4 讓呼叫者知道他們可能需要復原的錯誤#
當其他程式碼呼叫我們的程式碼時,通常無法事先知道呼叫是否會產生錯誤。例如什麼是有效的電話號碼,其規則相當複雜。
PhoneNumber 類別提供了一層抽象來處理電話號碼的細節;呼叫者被隔離在實作細節之外。因此不能期望呼叫者只傳入有效的輸入,因為這個類別的存在目的正是讓呼叫者不必擔心這些規則。
函式的作者應確保呼叫者能意識到錯誤可能發生。 若未做到這點,當錯誤真正發生時可能沒有人撰寫處理程式碼,導致使用者可見的 bug 或業務邏輯的失敗。
4.2 穩健性 vs. 失敗(Robustness vs. Failure)#
當錯誤發生時,通常需要在兩者之間做出選擇:
- 失敗(Failure):讓上層程式碼處理錯誤,或讓整個程式崩潰
- 繼續執行(Carry on):嘗試處理錯誤並繼續
繼續執行有時能使程式碼更穩健,但也可能讓錯誤被忽略,導致怪異行為發生。
4.2.1 快速失敗(Fail Fast)#
作者以松露獵犬比喻:第一隻狗發現松露會立刻停下吠叫;第二隻狗發現後會無聲地亂走一陣才吠叫。顯然我們應該選第一隻狗。追蹤程式碼中的 bug 就像用狗找松露——如果「吠叫」不在問題的實際位置附近,就毫無用處。
快速失敗意味著在盡可能接近問題真正位置的地方發出錯誤訊號:
- 對於可復原的錯誤:給呼叫者最大的機會優雅且安全地復原
- 對於不可復原的錯誤:讓工程師能最快速地定位並修復問題
- 在兩種情況下,都能防止軟體進入非預期且潛在危險的狀態
一個常見的例子:當函式以無效參數被呼叫時,應該在該函式中立即拋出錯誤,而非帶著無效輸入繼續執行,等到其他地方在某個時候才出問題。

Figure 4.1: If code doesn't fail fast when an error occurs, then the error may only manifest much later in some code far away from the actual location of the error.
如果程式碼不快速失敗,錯誤可能在遠離實際位置的地方才顯現,需要大量工程努力才能回溯找到並修復錯誤。

Figure 4.2: If code fails fast when an error occurs, then the exact location of the error will usually be immediately obvious.
相反地,快速失敗時,錯誤會在接近實際位置的地方顯現,stack trace 通常能提供準確的行號。
不快速失敗的危害:除了難以除錯之外,程式碼可能「帶傷前行」並造成損害。例如將損壞的資料存入資料庫——這個 bug 可能數月後才被發現,屆時大量重要資料可能已被永久破壞。
flowchart LR subgraph 快速失敗 A1["錯誤發生"] --> B1["立即偵測"] B1 --> C1["清楚的錯誤訊息"] C1 --> D1["容易修復"] end subgraph 延遲失敗 A2["錯誤發生"] --> B2["無聲傳播"] B2 --> C2["在遠處顯現"] C2 --> D2["難以追蹤"] D2 --> E2["修復成本高"] end
4.2.2 大聲失敗(Fail Loudly)#
如果發生了程式無法復原的錯誤,那很可能是工程師造成的 bug。我們顯然想修復它,但前提是要先知道它的存在。
大聲失敗就是確保錯誤不會被忽略。最直接的方式是拋出 exception 讓程式崩潰;另一種選擇是記錄錯誤訊息(但這取決於工程師是否勤於檢查日誌以及日誌中的雜訊程度)。如果程式碼在使用者裝置上執行,可以考慮將錯誤訊息回傳伺服器記錄(前提是已取得使用者同意)。
快速失敗 + 大聲失敗:如果程式碼同時做到這兩點,bug 極有可能在開發或測試階段就被發現(程式碼尚未發佈前)。即使未在測試中被發現,發佈後也能很快看到錯誤報告,並從報告中精確得知 bug 在程式碼中的位置。
4.2.3 可復原性的範圍(Scope of Recoverability)#
可復原性的範圍是可以變動的。例如在處理客戶端請求的伺服器中,某個請求可能觸發了有 bug 的程式碼路徑。在該請求的範圍內可能無法復原,但這不代表需要讓整個伺服器崩潰。
讓軟體保持穩健是好事——因為一個壞請求就讓整個伺服器崩潰顯然不好。但同時也要確保錯誤不被忽略,即程式碼需要大聲失敗。這兩個目標之間往往存在二律背反(dichotomy)。
解決方案是:捕捉程式錯誤後,以確保工程師會注意到的方式進行記錄與監控:
- 記錄詳細的錯誤資訊以便工程師除錯
- 監控錯誤率,當錯誤率過高時警示工程師

Figure 4.3: In a server, a programming error may occur when processing a single request. Because requests are independent events, it might be best not to crash the whole server.
伺服器框架注意事項:大多數伺服器框架內建了隔離個別請求錯誤並將特定錯誤類型映射到不同錯誤回應的功能。因此我們通常不需要自己寫 try-catch,但框架內部會做概念上類似的事情。
謹慎使用「捕捉一切錯誤」的技巧:這種捕捉所有類型錯誤然後記錄而非向上層傳遞的技巧,應極度謹慎地使用。通常只有在極少數地方才適合這樣做,例如程式碼的最高層進入點,或邏輯上真正獨立於、或對程式其餘部分正確運作非關鍵的分支。
4.2.4 不要隱藏錯誤(Don’t Hide Errors)#
捕捉非獨立、關鍵或低層級程式碼的錯誤然後繼續執行,往往導致軟體無法正確完成它該做的事。如果錯誤沒有被適當記錄或回報,問題可能永遠不會被工程團隊注意到。
隱藏錯誤對兩種類型的錯誤都有害:
- 隱藏可復原的錯誤:剝奪呼叫者優雅復原的機會。呼叫者完全不知道出了問題,軟體可能無法做到它該做的事
- 隱藏不可復原的錯誤:可能隱藏了程式錯誤。開發團隊需要知道這些錯誤才能修復,隱藏它們意味著 bug 可能長期不被察覺
- 共同問題:錯誤發生通常意味著程式碼無法完成呼叫者預期的任務。隱藏錯誤讓呼叫者誤以為一切正常,程式碼可能帶傷前行,最終輸出錯誤資訊、損壞資料,或最終崩潰
常見的隱藏錯誤方式包括:
- 回傳預設值(Default Value):例如帳戶餘額查詢失敗時回傳零,使客戶以為餘額為零
- Null Object Pattern:例如查詢未付帳單失敗時回傳空清單,使系統誤以為客戶沒有未付帳單
- 什麼都不做(Doing Nothing):函式遇到錯誤時直接 return,呼叫者以為操作已完成
- 壓制 exception(Suppressing Exceptions):
catch (Exception e) { }完全忽略例外
記錄日誌也不夠:即使在 catch 區塊中記錄了錯誤(
logger.logError(e)),仍然是對呼叫者隱藏了錯誤。呼叫者會以為操作成功,但實際上並沒有。此外也要注意日誌中是否包含使用者個人資訊(如 email 地址),可能違反資料處理政策。
4.3 錯誤訊號的方式(Ways of Signaling Errors)#
當錯誤發生時,通常需要向程式的更高層傳遞訊號。錯誤訊號的方式大致分為兩類:
- 顯式(Explicit):直接呼叫者被強制知道錯誤可能發生。無論他們選擇處理、傳遞或忽略,都是主動的選擇。錯誤的可能性位於程式碼合約的明顯部分
- 隱式(Implicit):錯誤會被發出訊號,但呼叫者可以完全不知道。呼叫者需要主動閱讀文件或原始碼才會知道。錯誤(如果有記載)位於合約的附屬細則,有時甚至完全沒有記載
| 顯式技巧 | 隱式技巧 | |
|---|---|---|
| 在合約中的位置 | 明顯部分(unmistakable part) | 附屬細則,或完全未記載 |
| 呼叫者是否知道 | 是 | 不一定 |
| 技巧範例 | Checked exception、Nullable return type(null safety)、Optional、Result type、Outcome return type(搭配回傳值檢查) | Unchecked exception、Magic value、Promise/Future、Assertion、Panic |
mindmap
root(("錯誤訊號方式"))
("顯式 Explicit")
("Checked Exception")
("Nullable 回傳")
("Optional")
("Result 型別")
("Outcome 型別")
("隱式 Implicit")
("Unchecked Exception")
("Magic Value")
("Promise/Future")
("Assertion")
("Panic")
顯式技巧#
Checked Exception#
編譯器強制呼叫者承認 checked exception 的存在——要麼撰寫處理程式碼,要麼在自己的函式簽名中宣告。這使得 checked exception 成為顯式的錯誤訊號技巧。
- 函式必須在簽名中宣告可能拋出的 checked exception(如
throws NegativeNumberException) - 呼叫者必須用 try-catch 處理,或在自己的簽名中繼續宣告
- 如果兩者都不做,程式碼無法編譯
Nullable Return Type(搭配 Null Safety)#
在支援 null safety 的語言中,回傳 null 可以有效地表示某個值無法被計算。呼叫者會被強制檢查值是否為 null 才能使用。
- 缺點:無法傳達錯誤的原因
- 使用 Optional 回傳型別可作為不支援 null safety 語言的替代方案
Result Return Type#
Result type 可以在通知呼叫者操作失敗的同時,提供錯誤原因。Swift、Rust、F# 等語言有內建支援。
- 回傳型別明確標示可能的錯誤類型,如
Result<Double, NegativeNumberError> - 呼叫者必須先檢查
hasError()再取值 - 比 nullable 更豐富,能封裝詳細的錯誤資訊
Outcome Return Type#
某些函式執行動作而非回傳值。可以透過回傳 Boolean 或 enum 來表示操作結果。
- 問題:呼叫者可能忽略回傳值
- 解決方案:使用
@CheckReturnValue(Java)、[[nodiscard]](C++)等標註,讓編譯器在回傳值被忽略時發出警告
隱式技巧#
Unchecked Exception#
呼叫者不被強制知道 unchecked exception 的存在。即使文件中有記載,也只是合約的附屬細則。
- 函式不需要在簽名中宣告
- 呼叫者不處理也能正常編譯
- 這使得 unchecked exception 成為隱式的錯誤訊號技巧
Promise / Future#
在非同步程式設計中,promise 或 future 也可以傳達錯誤狀態。但消費者不被強制處理可能發生的錯誤——他們可能只提供 then() callback 而不提供 catch() callback。
- 若要讓 promise 變成顯式,可以回傳
Promise<Result<V, E>>型別,但程式碼會變得較為繁瑣
Magic Value(魔術值)#
回傳一個特殊值(如 -1)來代表錯誤。呼叫者必須閱讀文件或原始碼才會知道。
- 容易造成意外和 bug,通常不是好的錯誤訊號方式
4.4 不可復原的錯誤應使用隱式技巧#
當錯誤確實無法復原時,最佳策略是快速失敗且大聲失敗。常見做法:
- 拋出 unchecked exception
- 觸發 panic(如果語言支援)
- 使用 check 或 assertion
使用隱式技巧可以避免呼叫鏈上的每一層都需要撰寫程式碼來處理它們無法做任何有意義處理的錯誤情境。
4.5 可復原錯誤的訊號之爭:Unchecked Exception vs. 顯式技巧#
這是軟體工程師之間的重要辯論。在介紹各方論點之前,作者強調:團隊一致的做法比選哪一方更重要。最糟糕的情況是團隊中一半人用一種做法、另一半用另一種。
4.5.1 支持 Unchecked Exception 的論點#
改善程式碼結構:
- 大部分錯誤處理可以集中在少數幾個高層的錯誤處理層
- 錯誤向上冒泡(bubble up),中間層不必被大量錯誤處理邏輯弄亂

Figure 4.4: Some engineers argue that using unchecked exceptions can improve code structure because the majority of error handling can be performed in a few distinct layers.
務實考量工程師的實際行為:
- 使用顯式技巧時,工程師可能因厭煩而走捷徑(例如 catch exception 後忽略、將 nullable 強制轉型為 non-null)
- 例如將
InMemoryDataStore改為DiskDataStore後,因IOException是 checked exception,需要修改整條呼叫鏈的函式簽名,工程師可能因此直接在底層壓制錯誤
4.5.2 支持顯式技巧的論點#
優雅的錯誤處理:
- 使用 unchecked exception 時,錯誤可能冒泡到離實際發生處很遠的高層,導致只能顯示籠統的錯誤訊息
- 顯式技巧強制呼叫者意識到錯誤,使錯誤更可能被就地優雅地處理(例如在輸入欄位旁邊顯示精確的錯誤訊息)
錯誤不會被意外忽略:
- 使用 unchecked exception 時,「不處理錯誤」是預設行為,工程師和 code reviewer 可能完全不知道某個錯誤情境存在
- 使用顯式技巧時,即使工程師做了錯誤的事(如 catch 後忽略),這在程式碼中會是明顯可見的違規,更容易在 code review 中被發現

Figure 4.5: When using explicit error-signaling techniques, not handling an error properly will often result in a deliberate and blatant transgression in the code.
務實考量的反面論點:
- Unchecked exception 的文件記載很少被完整維護,工程師往往不確定某段程式碼可能拋出哪些 exception
- 這會導致捕捉 exception 變成「打地鼠遊戲」(whack-a-mole):每次發現新的 undocumented exception 就加一個 catch
- 最終工程師可能厭煩而直接
catch (Exception e),隱藏了所有類型的錯誤——包括嚴重的程式錯誤
4.5.3 作者的觀點#
作者的立場是使用顯式技巧來通知可能想要復原的錯誤。他見過太多因為未記載的 unchecked exception 而導致的 bug 和故障——呼叫者如果知道 exception 的存在,本可以復原。雖然顯式技巧也有缺點,但作者認為 unchecked exception 在這類錯誤情境中的缺點更嚴重。
最重要的是:團隊應該就錯誤訊號的哲學達成一致並堅持執行。
4.6 不要忽略編譯器警告(Don’t Ignore Compiler Warnings)#
編譯器除了產生錯誤之外,還會發出警告。警告通常標記可疑的程式碼,可以作為潛在 bug 的早期預警。
例如一個 UserInfo 類別中,getDisplayName() 錯誤地回傳了 realName 而非 displayName。這段程式碼能編譯,但編譯器會警告 displayName 欄位的值從未被讀取。忽略此警告可能導致嚴重的隱私 bug。
最佳做法:將編譯器設定為把所有警告視為錯誤(阻止編譯)。如果某個警告確實不需要擔心,使用語言提供的機制明確壓制該特定警告(如
@Suppress("unused")),並附上說明原因的註解。理想狀態下,建置時不應有任何警告——每個問題都已被修復或明確壓制。
4.7 總結#
- 錯誤大致分為兩類:系統可復原的與系統不可復原的
- 通常只有呼叫者才知道某個錯誤是否可復原
- 錯誤發生時應快速失敗;若不可復原,還應大聲失敗
- 隱藏錯誤幾乎永遠不是好主意,應該發出錯誤訊號
- 錯誤訊號技巧分為兩類:
- 顯式:位於合約的明顯部分,呼叫者知道錯誤可能發生
- 隱式:位於合約附屬細則或根本未記載,呼叫者不一定知道
- 不可復原的錯誤應使用隱式技巧(unchecked exception、panic、assertion)
- 可復原的錯誤:工程師們對此沒有共識,但作者建議使用顯式技巧
- 編譯器警告往往能標記程式碼問題,應認真對待