編譯器是你最可靠的隊友#
許多人在學習程式設計時,覺得編譯器是個挑剔的麻煩製造者——斤斤計較每一個小錯誤。但一旦深入理解它的能力,你會發現編譯器是團隊中最可靠的成員。它不僅將高階語言轉換為低階語言,更能驗證程式的多種特性,保證某些錯誤不會在執行期發生。
本章的核心思路是一段漸進式的旅程:認識編譯器 → 善用編譯器 → 信任編譯器 → 完全仰賴編譯器。
flowchart LR
A["認識\n編譯器"] --> B["善用\n編譯器"]
B --> C["信任\n編譯器"]
C --> D["完全仰賴\n編譯器"]認識編譯器的長處與短處#
短處:停機問題(Halting Problem)限制了編譯期知識#
停機問題指出:不實際執行程式,就無法確切知道程式的行為。這是所有程式語言的本質限制,不是特定編譯器的問題。
編譯器面對不確定的程式碼時,必須做出選擇:
- 有些情況會選擇放行(可能在執行期出錯)
- 有些情況會採取保守分析(conservative analysis),拒絕任何無法保證安全的程式
我們只能依賴保守分析所提供的保證。
長處:可達性分析(Reachability)#
編譯器能檢查方法是否在每條路徑上都有 return。結合 assertExhausted 這類技巧,可以做到窮舉檢查(exhaustiveness check)——確保 switch 或 if 涵蓋了所有可能的值。
長處:確定賦值(Definite Assignment)#
編譯器會驗證變數在使用前是否已被賦值。搭配 read-only 欄位(必須在建構子結束前初始化),可以用來消除「某個值可能不存在」的不變量。
長處:存取控制(Access Control)#
透過 private 修飾符,編譯器能確保資料不會意外洩漏。這是第六章封裝技術的基礎,能讓不變量的範圍維持在局部。
長處:型別檢查(Type Checking)#
型別檢查器是編譯器最強大的武器。它負責驗證變數和成員是否存在,也支援本書第一部分大量使用的「改名後靠編譯錯誤找出所有引用處」技巧。
型別強度不是二元的,而是一個光譜。從 Rust 的借用型別、OCaml/F# 的多型推導、Haskell 的 type class,到 Coq/Agda 的依值型別(dependent types),越強的型別系統能讓編譯器替你證明越多性質。
短處:null 解引用、算術錯誤、越界存取#
| 短處 | 說明 |
|---|---|
| null | 對 null 呼叫方法會導致程式崩潰。工具難以偵測所有情況,因此「看不到 null 檢查時,就假設它可能是 null」。 |
| 算術錯誤 | 除以零會崩潰,溢位會靜默地產生錯誤結果。編譯器幾乎不檢查這些。 |
| 越界存取 | 直接用索引存取資料結構,若索引無效就會出錯。解法是遍歷整個結構,或用型別系統證明元素一定存在。 |
短處:無限迴圈與多執行緒問題#
- 無限迴圈:編譯器通常無法偵測。現代做法是從
while過渡到for、forEach等高階建構來降低風險。 - 多執行緒:race condition、deadlock、starvation 等問題源於多個執行緒共享可變資料。最好的建議是盡量避免「多執行緒 + 共享 + 可變」這三者同時存在。
善用編譯器#
程式設計不是建造,而是溝通——與電腦溝通、與其他開發者溝通、與編譯器溝通。在這個比喻中,編譯器就是「確保文本品質的編輯」。
把編譯錯誤當作待辦清單#
重構時,將方法或型別改名,讓編譯器告訴你所有需要修改的地方。這是本書中最常使用的技巧——安全、不遺漏、零成本。
強制執行順序(Enforce Sequence)#
利用型別包裝(如 CapitalizedString),讓編譯器保證某個操作一定在之前已經完成。這將不變量轉化為型別系統的性質,未來不可能意外破壞。
強制封裝#
透過存取控制保護敏感方法(如 private depositHelper),確保只有正確的程式碼路徑能呼叫它們。
偵測未使用的程式碼(Try Delete Then Compile)#
大膽刪除方法,讓編譯器掃描整個程式碼庫告訴你哪些方法還在用。這對 interface 方法特別有用——編譯器無法自行判斷 interface 方法是否被使用,但你可以嘗試刪除後觀察結果。
利用確定賦值保證值的存在#
使用 readonly 欄位定義資料結構(如不可為空的串列 NonEmptyList),讓編譯器在建構子層級保證值一定存在。
不要對抗編譯器#
以下是常見的「對抗」行為,都會削弱編譯器的保護能力:
型別相關#
| 對抗行為 | 說明 |
|---|---|
| 型別轉換(Cast) | 等於告訴編譯器「我比你懂」,實質上是禁用了型別檢查。需要 cast 通常代表型別設計有問題。 |
| 動態型別(any / dynamic) | 完全關閉型別檢查器。看似方便,卻打開了無數潛在錯誤的大門。 |
| 執行期型別(run-time types) | 將十個參數塞進一個 Map<string, string> 看似簡化了介面,實際上是把知識從編譯期搬到了執行期——從型別檢查的長處退到了越界錯誤的短處。 |
偷懶相關#
| 對抗行為 | 說明 |
|---|---|
| 預設值(Defaults) | 使用預設值意味著新增選項時可能忘記覆寫,而編譯器不會提醒你。 |
| 類別繼承(Inheritance) | 繼承是一種隱式的預設行為,新增父類方法時子類可能忘記覆寫(例如 Platypus 沒有覆寫 laysEggs)。 |
| 非受檢例外(Unchecked Exceptions) | 如果例外可能發生,就應該強制呼叫端處理。非受檢例外只適用於「不可能發生」的情況。 |
架構相關#
- 破壞封裝:透過 getter 暴露內部狀態(如
getArray()),或將private欄位當作參數傳出去,都會讓編譯器的存取控制形同虛設。正確做法是傳遞this而非內部欄位。
信任編譯器#
教導編譯器認識不變量#
當程式中存在不變量(invariant),應按以下優先順序處理:
- 消除它
- 無法消除,就教導編譯器(透過型別系統)
- 無法教導編譯器,就寫自動化測試
- 無法自動化,就寫文件
- 無法寫文件,就手動測試
- 以上都做不到,就祈禱吧
flowchart TD
A{"能消除\n不變量?"} -->|是| B["消除它"]
A -->|否| C{"能教導\n編譯器?"}
C -->|是| D["透過型別系統表達"]
C -->|否| E{"能寫\n自動化測試?"}
E -->|是| F["寫自動化測試"]
E -->|否| G{"能寫文件?"}
G -->|是| H["寫文件"]
G -->|否| I{"能手動測試?"}
I -->|是| J["手動測試"]
I -->|否| K["祈禱吧"]
style B fill:#c8e6c9
style K fill:#ffcdd2越靠上面的選項,長期維護成本越低。文件需要刻意維護才能保持同步,測試則會在失去同步時主動告訴你。「沒時間寫測試」的藉口在長期來看一定更花時間。
重視警告(Warnings)#
醫療領域有**警報疲勞(alarm fatigue)**的概念——當警報成為常態,人們就會對真正重要的警報失去敏感度。程式碼中的警告也一樣。
健康的警告數量只有一個:零。
如果現有程式碼警告已經泛濫,就設定上限並逐月遞減。一旦達到零,就啟用語言配置禁止所有警告再度出現。
完全仰賴編譯器#
旅程的最終階段:當你已經深入了解編譯器的長處與短處,並在程式碼中教導了它領域結構與不變量,成功編譯應該比你自己閱讀程式碼帶來更大的信心。
編譯器不能告訴你程式是否解決了預期的問題,但它能告訴你程式是否會崩潰——而崩潰永遠不是我們預期的行為。
這需要大量練習與紀律,也需要選用適當的程式語言。就像那句話說的:「如果你是房間裡最聰明的人,你就進錯房間了。」對編譯器也是如此——讓它比你更聰明,是一件好事。