編譯器是你最可靠的隊友#

許多人在學習程式設計時,覺得編譯器是個挑剔的麻煩製造者——斤斤計較每一個小錯誤。但一旦深入理解它的能力,你會發現編譯器是團隊中最可靠的成員。它不僅將高階語言轉換為低階語言,更能驗證程式的多種特性,保證某些錯誤不會在執行期發生。

本章的核心思路是一段漸進式的旅程:認識編譯器 → 善用編譯器 → 信任編譯器 → 完全仰賴編譯器

flowchart LR
    A["認識\n編譯器"] --> B["善用\n編譯器"]
    B --> C["信任\n編譯器"]
    C --> D["完全仰賴\n編譯器"]

認識編譯器的長處與短處#

短處:停機問題(Halting Problem)限制了編譯期知識#

停機問題指出:不實際執行程式,就無法確切知道程式的行為。這是所有程式語言的本質限制,不是特定編譯器的問題。

編譯器面對不確定的程式碼時,必須做出選擇:

  • 有些情況會選擇放行(可能在執行期出錯)
  • 有些情況會採取保守分析(conservative analysis),拒絕任何無法保證安全的程式

我們只能依賴保守分析所提供的保證。

長處:可達性分析(Reachability)#

編譯器能檢查方法是否在每條路徑上都有 return。結合 assertExhausted 這類技巧,可以做到窮舉檢查(exhaustiveness check)——確保 switchif 涵蓋了所有可能的值。

長處:確定賦值(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 過渡到 forforEach 等高階建構來降低風險。
  • 多執行緒: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),應按以下優先順序處理:

  1. 消除它
  2. 無法消除,就教導編譯器(透過型別系統)
  3. 無法教導編譯器,就寫自動化測試
  4. 無法自動化,就寫文件
  5. 無法寫文件,就手動測試
  6. 以上都做不到,就祈禱吧
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)**的概念——當警報成為常態,人們就會對真正重要的警報失去敏感度。程式碼中的警告也一樣。

健康的警告數量只有一個:零。

如果現有程式碼警告已經泛濫,就設定上限並逐月遞減。一旦達到零,就啟用語言配置禁止所有警告再度出現。

完全仰賴編譯器#

旅程的最終階段:當你已經深入了解編譯器的長處與短處,並在程式碼中教導了它領域結構與不變量,成功編譯應該比你自己閱讀程式碼帶來更大的信心

編譯器不能告訴你程式是否解決了預期的問題,但它能告訴你程式是否會崩潰——而崩潰永遠不是我們預期的行為。

這需要大量練習與紀律,也需要選用適當的程式語言。就像那句話說的:「如果你是房間裡最聰明的人,你就進錯房間了。」對編譯器也是如此——讓它比你更聰明,是一件好事。