防禦性程式設計(Defensive Programming)的概念源自防禦性駕駛:你永遠無法確定其他駕駛人會做什麼,因此必須主動保護自己。 同理,當一個常式收到錯誤資料時,即使錯誤是其他常式造成的,它也不應因此崩潰。 更廣義地說,這是承認程式必然會有問題與變動,聰明的程式設計師會據此開發程式。

8.1 保護程式免遭非法輸入資料的破壞#

Figure 8-1: Part of the Interstate-90 floating bridge in Seattle sank during a storm because the flotation tanks were left uncovered, they filled with water, and the bridge became too heavy to float. During construction, protecting yourself against the small stuff matters more than you might think.

在學校我們可能聽過「Garbage in, garbage out」,但對產品級軟體來說這遠遠不夠。 好的程式應該做到「垃圾進,什麼都不出」、「垃圾進,錯誤訊息出」或「根本不允許垃圾進入」。

處理非法輸入的三種一般策略:

  • 檢查所有外部來源的資料:確認數值在合理範圍內、字串長度可處理,並特別警覺緩衝區溢位、SQL 注入、HTML/XML 注入、整數溢位等攻擊。
  • 檢查所有常式輸入參數的值:與檢查外部資料相同,只是資料來自其他常式。
  • 決定如何處理錯誤的輸入:根據情境選擇適當的錯誤處理策略(詳見 8.3 節)。

防禦性程式設計是品質改善技術的輔助手段。預防勝於治療——迭代設計、先寫虛擬碼、先寫測試案例、進行低階設計審查,這些措施的優先級都應高於防禦性程式設計。

8.2 斷言(Assertions)#

斷言是開發期間使用的程式碼(通常是常式或巨集),讓程式在執行時能自我檢查。斷言為真表示一切正常;為假表示程式碼中有未預期的錯誤。

斷言通常接受兩個引數:一個描述假設的布林運算式,以及假設不成立時顯示的訊息。

assert denominator != 0 : "denominator is unexpectedly equal to 0.";

斷言的常見用途#

  • 輸入/輸出參數值在預期範圍內
  • 檔案或串流在常式開始/結束時是開啟(或關閉)的
  • 指標非 null
  • 陣列或容器至少能容納 X 個元素
  • 表格已初始化為真實值
  • 高度最佳化的複雜常式與較慢但清晰的常式結果一致

使用斷言的指導原則#

  • 錯誤處理用於預期可能發生的狀況;斷言用於不應發生的狀況。斷言是可執行的文件——比註解更主動地記錄假設。
  • 避免在斷言中放入執行碼。若編譯器在產品版本中移除斷言,裡面的執行碼也會一併消失。應先將結果存入狀態變數,再對變數進行斷言。
  • 用斷言記錄前置條件(Preconditions)與後置條件(Postconditions)。這是**契約式設計(Design by Contract)**的一部分:前置條件是呼叫端的義務,後置條件是被呼叫端的保證。
  • 對高健壯性程式碼,先斷言再做錯誤處理。在大型、長期維護的系統(如 Microsoft Word)中,同一錯誤條件同時使用斷言和錯誤處理——斷言在開發期間沖出錯誤,錯誤處理在產品中優雅應對。

8.3 錯誤處理技術#

斷言用於不應發生的錯誤;那麼預期可能發生的錯誤該怎麼辦?以下是幾種常見策略:

點擊展開:錯誤處理策略清單
  • 傳回中性值(Neutral Value):數值回傳 0、字串回傳空字串。但在醫療 X 光顯示等場景中不適用。
  • 替換為下一筆合法資料:處理資料流時跳過損壞的記錄,取用下一筆。
  • 傳回前一次的值:感測器每秒讀取 100 次,某次失敗可用上次值代替(但不適用於 ATM 帳戶驗證)。
  • 替換為最接近的合法值:溫度計校準在 0-100°C 之間,低於 0 就用 0 代替。
  • 將警告訊息記錄到檔案:可與其他策略合併使用;注意日誌是否需要加密保護。
  • 傳回錯誤碼:由呼叫階層中較高層的程式碼負責處理。可透過狀態變數、函式回傳值或例外機制回報。
  • 呼叫集中式錯誤處理常式/物件:便於除錯,但會增加耦合性。注意緩衝區溢位後處理器位址可能已被攻擊者竄改。
  • 就地顯示錯誤訊息:簡單但會將 UI 散佈在整個程式中,且可能洩露系統資訊給攻擊者。
  • 關閉程式:適用於安全攸關的應用,例如放射治療設備寧可重開機也不能給錯劑量。

健壯性 vs. 正確性#

**正確性(Correctness)**意味著絕不回傳不準確的結果——不回傳結果好過回傳錯誤結果。 **健壯性(Robustness)**意味著盡一切努力讓軟體持續運作,即使結果偶爾不完全準確。

  • 安全攸關的系統傾向正確性(放射治療機)。
  • 消費性應用傾向健壯性(文書處理器畫面偶爾有小瑕疵,比直接當機好)。

錯誤處理方式的選擇是架構層級的設計決策,應在高階設計時統一制定,並在整個程式中一致遵循。 即使你不認為函式會產生錯誤,仍應檢查其回傳值——這正是防禦性程式設計的精神。

8.4 例外(Exceptions)#

例外讓程式碼能將錯誤或異常事件傳遞給呼叫端。當某段程式碼遇到無法處理的狀況時,就拋出例外,讓呼叫階層中更了解上下文的程式碼來處理。

使用例外的指導原則#

  • 用例外通知不可忽略的錯誤:例外最大的好處是讓錯誤條件無法被默默忽略。
  • 只為真正異常的狀況拋出例外:例外會弱化封裝(呼叫端須知道可能拋出哪些例外),增加複雜度。
  • 不要用例外來推卸責任:能在本地處理的錯誤,就在本地處理。
  • 避免在建構函式和解構函式中拋出例外:相關規則極其複雜,容易導致資源洩漏。
  • 在正確的抽象層級拋出例外Employee.GetTaxId() 應拋出 EmployeeDataNotAvailable,而非底層的 EOFException
  • 在例外訊息中包含所有導致例外的資訊:例如陣列索引錯誤就包含上下界與實際索引值。
  • 避免空的 catch 區塊:至少記錄一筆日誌。空的 catch 代表 try 或 catch 其中之一寫錯了。
  • 了解你使用的函式庫會拋出哪些例外
  • 考慮建立集中式例外回報器(Centralized Exception Reporter)
  • 標準化專案的例外處理方式:定義何時可在本地使用 throw-catch、何時可拋出不在本地處理的例外、是否允許在建構/解構函式中使用例外等。
  • 考慮例外以外的替代方案:本地處理、錯誤碼、記錄除錯資訊、關閉系統等。

8.5 隔離程式,使之包容由錯誤造成的損害#

**隔離(Barricade)**是一種損害控制策略,如同船體的隔水艙或建築的防火牆。 做法是指定某些介面為「安全區域」的邊界,在邊界上驗證資料的合法性。

  • 類別的 public 方法假設資料不安全,負責檢查與消毒資料。
  • 類別的 private 方法可以假設資料是安全的。

可以用「手術室」的比喻來理解:資料進入手術室前必須消毒,手術室內的一切則被假定為安全的。

儘早將輸入資料轉換為正確的型別。輸入通常以字串形式到來,若長時間以錯誤型別攜帶資料,會增加複雜度與被攻擊的風險。

隔離與斷言的關係#

隔離使斷言與錯誤處理的界線更清晰:隔離外的常式應使用錯誤處理(不能假設資料安全),隔離內的常式應使用斷言(資料已經過消毒,若仍有問題代表程式有 bug)。

flowchart LR
    外部["外部不安全資料"]
    subgraph 隔離邊界["隔離邊界(public 方法)"]
        驗證["驗證與消毒資料"]
    end
    subgraph 安全區域["安全區域(private 方法)"]
        斷言["使用斷言確保正確性"]
        邏輯["執行核心邏輯"]
    end
    外部 --> 驗證
    驗證 --> 斷言
    斷言 --> 邏輯

8.6 輔助除錯的程式碼#

不要自動將產品限制套用到開發版本#

產品版本要快、要省資源、不能暴露危險操作。但開發版本可以慢、可以消耗資源、可以提供額外操作。 例如 Microsoft Word 的除錯版本會在閒置迴圈中每隔幾秒檢查 Document 物件的完整性。

進攻式程式設計(Offensive Programming)#

在開發期間讓異常狀況盡量明顯,在產品版本中則優雅恢復:

  • 確保斷言會中止程式,不讓程式設計師養成按 Enter 跳過的習慣
  • 完全填滿分配的記憶體和檔案,以偵測分配錯誤和格式錯誤
  • 確保 switch/casedefault 在開發期間會中止程式
  • 在物件刪除前填入垃圾資料

規劃移除除錯輔助工具的方式#

  • 使用版本控制與建置工具(ant、make)區分開發與產品版本
  • 使用前置處理器(如 C++ 的 #if defined(DEBUG))以編譯器開關控制除錯碼
  • 使用除錯樁(Debugging Stubs):開發版本執行完整檢查,產品版本替換為僅回傳的空常式

8.7 決定在產品程式碼中該保留多少防禦性程式碼#

  • 保留檢查重要錯誤的程式碼(例如試算表的計算引擎不能有未偵測的錯誤)
  • 移除檢查瑣碎錯誤的程式碼(例如畫面更新的小瑕疵)
  • 移除會導致硬性當機的程式碼(使用者不接受任何造成資料遺失的當機)
  • 保留讓程式能優雅當機的程式碼(火星探路者號保留了除錯碼,工程師因此能遠端診斷並上傳修正)
  • 為技術支援人員記錄錯誤:將斷言改為寫入日誌檔
  • 確保錯誤訊息對使用者友善:不要讓使用者看到類似「You’ve got a bad pointer allocation, Dog Breath!」的訊息

8.8 對防禦性程式設計採取防禦的姿態#

過多的防禦性程式設計本身也是問題。在每個可能的地方以每種可能的方式檢查參數,程式會變得臃腫緩慢。 額外的防禦性程式碼也會增加複雜度,而且防禦性程式碼本身同樣可能有缺陷。 思考哪裡真正需要防禦,並據此設定優先順序。

更多資源#

  • Howard & LeBlanc,《Writing Secure Code》(2nd ed.)——涵蓋信任輸入的安全性議題,展示程式被攻破的各種方式。
  • Maguire,《Writing Solid Code》——第 2 章深入討論斷言的使用與實例。
  • Meyer,《Object-Oriented Software Construction》(2nd ed.)——前置條件與後置條件的權威討論,以及例外處理的詳細探討。
  • Stroustrup,《The C++ Programming Language》(3rd ed.)——C++ 中斷言的多種實作方式與 21 條例外處理建議。

要點#

  • 產品程式碼的錯誤處理方式應比「Garbage in, garbage out」更精緻。
  • 防禦性程式設計技術讓錯誤更容易被發現、更容易被修復、對產品程式碼造成更少的損害。
  • 斷言有助於及早偵測錯誤,特別適用於大型系統、高可靠性系統與快速變動的程式碼庫。
  • 如何處理錯誤的輸入,是關鍵的錯誤處理決策,也是關鍵的高階設計決策。
  • 例外提供了在正常程式流程之外的另一個維度來處理錯誤,審慎使用是程式設計師工具箱的寶貴補充,但應與其他錯誤處理技術一併權衡。
  • 適用於產品系統的限制不一定適用於開發版本,善用這一點,在開發版本中加入能快速沖出錯誤的程式碼。