其他工程師與程式碼契約#
本章涵蓋:
- 其他工程師如何與我們的程式碼互動
- 程式碼契約(code contracts)與契約中的「小字條款」(small print)
- 如何透過減少小字條款來防止誤用與意外
- 當無法避免小字條款時,如何使用檢查(checks)與斷言(assertions)來強制執行
3.1 你的程式碼與其他工程師的程式碼#
撰寫與維護軟體通常是團隊合作。其他工程師最終會與你寫的程式碼互動,反之亦然。你的程式碼不會孤立存在——它依賴其他工程師撰寫的底層程式碼,而其他工程師也會在你的程式碼之上建構新功能。
如果你在解決問題的過程中把子問題拆分成乾淨的抽象層,其他工程師可能會將這些抽象層重用於你根本沒有預想到的場景。

Figure 3.1: The code you write rarely lives in isolation. It will depend on code written by other engineers, and other engineers will in turn write code that depends on your code.
關鍵認知: 需求會不斷變化與演進,程式碼也會隨之改變。一群工程師持續修改同一個 codebase,使其成為一個繁忙的地方。脆弱的東西和繁忙的地方無法共存。
撰寫程式碼時,應考慮以下三件事:
3.1.1 對你顯而易見的事,對其他人並不顯而易見#
當你寫程式碼時,你可能已經花了數小時甚至數天思考問題。你對自己的邏輯非常熟悉,一切似乎理所當然。但其他工程師不會擁有你的背景知識——對你來說完全顯而易見的東西,對他們很可能完全不明顯。
- 確保程式碼能自我解釋其用法、功能與原因
- 這不代表要寫大量註解,而是有更好的方式讓程式碼易於理解
3.1.2 其他工程師會無意間試圖破壞你的程式碼#
你的程式碼建立在不斷變動的基礎之上,上面又有更多不斷移動的部分。其他工程師很可能在某個時間點添加或修改程式碼,無意間破壞或誤用你的程式碼。
- 當某些東西被破壞時,唯二可靠的防線是:程式碼無法編譯或測試開始失敗
- 高品質程式碼的許多考量,最終都是為了確保在出問題時這兩件事之一會發生
3.1.3 隨著時間你會忘記自己的程式碼#
你現在對程式碼的細節記憶猶新,但一年後你可能不再記得其中的來龍去脈。看一年前自己寫的程式碼,和看別人寫的程式碼沒什麼差別。
建議: 確保你的程式碼即使對沒有任何背景的人也易於理解,並且難以被破壞。你不只是在幫別人的忙,也是在幫未來的自己。
3.2 其他人如何弄清楚怎麼使用你的程式碼?#
當其他工程師需要使用你的程式碼時,他們需要理解:
- 在什麼場景下應該呼叫你提供的各種函式
- 你建立的類別代表什麼,何時該使用
- 應該用什麼值來呼叫
- 你的程式碼會執行什麼操作
- 你的程式碼可能回傳什麼值
其他工程師可能會透過以下方式來理解你的程式碼:
3.2.1 看名稱#
看名稱是工程師弄清楚如何使用新程式碼的主要方式之一。套件、類別和函式的名稱就像一本書的目錄——是快速找到能解決子問題的程式碼的便利方式。
- 如果一個函式叫
removeEntry(),很難和addEntry()搞混 - 良好的命名是向其他工程師傳達程式碼用法的最佳方式之一
3.2.2 看資料型別#
在編譯式靜態型別語言中,工程師必須正確使用資料型別,否則程式碼無法編譯。因此,透過型別系統來強制程式碼的使用方式,是確保其他工程師不會誤用或錯誤設定你的程式碼的最佳方法之一。
3.2.3 閱讀文件#
文件形式包括:
- 非正式的函式與類別層級註解
- 正式的程式碼內文件(如 JavaDoc)
- 外部文件(如 README.md、網頁或說明書)
注意: 文件只是「有限度可靠」的方式:
- 不保證其他工程師會閱讀,實際上他們經常不讀或不完整地讀
- 即使讀了也可能誤解
- 文件可能已過時——工程師修改程式碼時經常忘記更新文件
3.2.4 當面詢問你#
這有時有效,但無法依賴:
- 你寫的程式碼越多,花在回答問題的時間越多,最終一天的時間不夠用
- 你可能在休假
- 時間久了你自己也會忘記
- 你可能離開公司
3.2.5 看你的程式碼實作#
直接看實作細節可能得到最準確的答案,但這個方法無法擴展。如果每位工程師都需要閱讀所有依賴的實作細節才能使用,就必須讀成千上萬行程式碼。而這些依賴本身也有依賴,很快就需要讀數十萬行程式碼才能實作一個中等規模的功能。
核心原則: 建立抽象層的目的就是讓工程師一次只需處理少量概念,無需知道子問題的具體解法。要求工程師閱讀實作細節才能使用程式碼,會抵消抽象層帶來的大部分好處。
3.3 程式碼契約(Code Contracts)#
「程式設計契約」(programming by contract / design by contract)是一個將上述概念正式化的原則。工程師將不同程式碼之間的互動視為契約:呼叫方有義務滿足特定條件,作為回報,被呼叫的程式碼會回傳期望的值或修改某些狀態。
契約的條款通常分為三類:
| 類別 | 說明 |
|---|---|
| 前置條件(Preconditions) | 呼叫程式碼之前應為真的事項,如系統應處於什麼狀態、應提供什麼輸入 |
| 後置條件(Postconditions) | 程式碼執行後應為真的事項,如系統被置於新狀態或回傳特定值 |
| 不變量(Invariants) | 比較程式碼執行前後,應保持不變的事項 |
即使你從未刻意實踐契約式程式設計,你寫的程式碼幾乎肯定具有某種契約——只要函式有輸入參數、回傳值或修改狀態,你就已經建立了契約。
3.3.1 契約中的小字條款#
就像現實生活中的契約一樣,程式碼契約也有顯而易見的部分和小字條款。
以電動滑板車租賃 App 為例:

Figure 3.2: Renting an electric scooter using an app is a real-world example of entering a contract.
顯而易見的部分:
- 你正在租一台電動滑板車
- 租金為每小時 $10
小字條款(需點開條款與條件才看到):
- 撞壞滑板車需要賠償
- 超出市區範圍罰款 $100
- 超過 30 mph 罰款 $300(因為會損壞馬達),但滑板車沒有限速器,很容易超速,使用者需自行監控速度

Figure 3.3: Contracts usually contain small print, such as things in the terms and conditions.
前兩項小字條款不算意外,但第三項——不能超過 30 mph——是個潛在的陷阱。
對應到程式碼契約:
顯而易見的部分(unmistakable):
- 函式和類別名稱——不知道這些就無法使用程式碼
- 參數型別——型別錯誤就無法編譯
- 回傳型別——同理
- Checked exceptions(如果語言支援)——不處理就無法編譯
小字條款(small print):
- 註解與文件——人們真的應該讀,但實際上經常不讀
- Unchecked exceptions——可能連列在文件中都沒有
flowchart TB
Root[程式碼契約條款]
Root --> Obvious[顯而易見的部分]
Root --> SmallPrint[小字條款]
Obvious --> O1[函式與類別名稱]
Obvious --> O2[參數型別]
Obvious --> O3[回傳型別]
Obvious --> O4[Checked Exceptions]
O1 & O2 & O3 & O4 -.->|不知道就無法使用| Reliable[高度可靠]
SmallPrint --> S1[註解與文件]
SmallPrint --> S2[Unchecked Exceptions]
S1 & S2 -.->|經常被忽略或過時| Unreliable[不可靠]
原則: 讓契約條款顯而易見,遠比依賴小字條款可靠。人們經常不讀小字條款,即使讀了也可能只是略讀而產生錯誤理解。而且文件有過時的傾向,所以小字條款甚至不一定是正確的。
3.3.2 不要過度依賴小字條款#
小字條款形式的註解與文件經常被忽略,因此不是傳達程式碼契約的可靠方式。過度依賴小字條款可能產生脆弱的、容易被誤用的、會帶來意外的程式碼。
以下是一個過度依賴小字條款的範例——UserSettings 類別:
class UserSettings {
UserSettings() { ... }
// 在使用此函式成功載入設定前,不要呼叫任何其他函式。
// 成功載入回傳 true。
Boolean loadSettings(File location) { ... }
// 必須在呼叫其他函式之前呼叫 init(),
// 但僅限於 loadSettings() 已載入設定之後。
void init() { ... }
// 回傳使用者選擇的 UI 顏色,若未選擇或設定未載入/初始化則回傳 null。
Color? getUiColor() { ... }
}這個契約的問題:
- 顯而易見的部分: 類別名為
UserSettings,顯然包含使用者設定;getUiColor()回傳 UI 顏色或 null - 小字條款: 必須按特定順序呼叫
loadSettings()->init()才能使用;null的回傳值被賦予了多重含義

Figure 3.4: The more ways there are to misuse a piece of code, the more likely it is to be misused, and the more likely there are to be bugs in the software.
如何消除小字條款:
就像滑板車可以加裝限速器讓超過 30 mph 成為不可能,程式碼也能讓做錯事成為不可能。改進後的 UserSettings:
class UserSettings {
private UserSettings() { ... } // 建構子設為 private
static UserSettings? create(File location) {
UserSettings settings = new UserSettings();
if (!settings.loadSettings(location)) {
return null; // 載入失敗回傳 null,防止取得無效實例
}
settings.init();
return settings;
}
private Boolean loadSettings(File location) { ... } // 設為 private
private void init() { ... } // 設為 private
// 回傳使用者選擇的 UI 顏色,若未選擇則回傳 null。
Color? getUiColor() { ... } // null 現在只有一個含義
}改進重點:
- 靜態工廠方法
create()確保只能取得完全初始化的實例 - 私有建構子迫使外部程式碼使用
create()方法 loadSettings()和init()設為 private,防止外部呼叫getUiColor()的null回傳值不再有多重含義

Figure 3.5: If code is impossible to misuse, then it's a lot less likely that bugs will creep into the software.
關鍵技術: 這裡使用的技巧是消除類別對外暴露的狀態與可變性(state and mutability)。讓錯誤的使用方式在編譯階段就不可能發生,遠比用小字條款來提醒可靠得多。
3.4 檢查與斷言(Checks and Assertions)#
當無法透過編譯器來強制執行契約時,可以使用執行期強制(runtime enforcement)作為替代方案。這不如編譯期強制穩健,因為它依賴測試(或使用者)在執行程式碼時發現問題,而非從邏輯上讓違反契約成為不可能。
3.4.1 檢查(Checks)#
檢查是額外的邏輯,用於驗證程式碼契約是否被遵守。如果未被遵守,檢查會拋出錯誤,導致明顯且無法忽視的失敗。
以電動滑板車比喻:加入檢查就像在滑板車韌體中加入安全機制——一旦騎士達到 30 mph,滑板車會完全關閉。騎士必須靠邊停車、找到重置按鈕並等待重啟。這防止了馬達損壞,但造成突然停機——不如限速器那麼優雅(限速器從一開始就讓壞情況不可能發生)。
檢查的命名常依強制的契約條件分類:
- 前置條件檢查(Precondition checks):檢查輸入參數是否正確、是否已執行初始化、系統是否在有效狀態
- 後置條件檢查(Postcondition checks):檢查回傳值是否正確、系統在執行後是否處於有效狀態
使用檢查的 UserSettings 範例:
class UserSettings {
UserSettings() { ... }
Boolean loadSettings(File location) { ... }
void init() {
if (!haveSettingsBeenLoaded()) {
throw new StateException("Settings not loaded");
}
...
}
Color? getUiColor() {
if (!hasBeenInitialized()) {
throw new StateException("Settings not initialized");
}
...
}
}語言差異: 不同語言對檢查有不同的支援方式。有些語言有內建語法,有些則需要手動實作或使用第三方函式庫。使用前請查閱你所使用語言的最佳實踐。
檢查的局限性:
- 如果被違反的條件只在沒人想到要測試的罕見場景中發生,bug 可能仍然要到上線後才被發現
- 儘管檢查會造成明顯的失敗,仍有可能沒人注意到——例外可能在上層被捕獲並僅記錄日誌
3.4.2 斷言(Assertions)#
斷言在概念上與檢查非常相似,但關鍵差異在於:斷言通常在 release 版本編譯時會被移除,意味著在正式環境中不會觸發失敗。
移除斷言的原因:
- 提升效能:計算條件是否被違反需要 CPU 資源,在頻繁執行的程式碼中可能影響整體效能
- 降低程式碼失敗機率:在可用性比避免潛在 bug 更重要的系統中,這可能是正確的權衡
使用斷言的 UserSettings 範例:
class UserSettings {
...
Color? getUiColor() {
assert(hasBeenInitialized(), "Settings not initialized");
...
}
}注意: 許多開發團隊會在 release 版本中保留啟用斷言。在這種情況下,斷言與檢查的差異僅在於可能拋出的錯誤或例外類型不同。
3.5 總結#
- Codebase 處於持續變動狀態,通常有多位工程師同時進行修改
- 思考其他工程師可能如何破壞或誤用程式碼,並設計出讓這些情況發生機率最小化甚至不可能發生的方式
- 當我們撰寫程式碼時,必然會建立某種程式碼契約——其中包含顯而易見的部分和小字條款
- 小字條款不是確保其他工程師遵守契約的可靠方式。讓事情顯而易見(unmistakably obvious)通常是更好的做法
- 透過編譯器強制執行契約通常是最可靠的方式。當這不可行時,替代方案是使用執行期的檢查或斷言來強制執行契約