讓程式碼模組化#

需求在軟體的生命週期中幾乎必然會改變,試圖精確預測改變方向通常是白費力氣。模組化(Modularity)的核心目標是:讓程式碼能被輕鬆調整與重新配置,而不需要事先知道未來到底會怎麼變。關鍵原則是——不同的功能或需求應對應到程式碼庫中各自獨立的部分。如果做到了這點,當某個需求改變時,我們只需修改與該需求直接相關的那一處程式碼。

本章大量建立在「乾淨的抽象層」概念之上(第二章)。讓程式碼模組化,往往就是確保各子問題的解法彼此獨立、互不緊密耦合。這不僅讓程式碼更易適應變化,也讓系統更容易推理,且如第 9、10、11 章所述,更可重用、更可測試。

8.1 考慮使用依賴注入#

類別之間互相依賴是常態。第二章展示了程式碼如何將高層問題拆解為子問題,而在結構良好的程式碼中,每個子問題通常由專屬的類別負責解決。但子問題往往不只一種解法,因此將程式碼設計成可以重新配置子問題的解法,會很有用。依賴注入(Dependency Injection)正是達成這一目標的手段。

8.1.1 硬編碼的依賴會造成問題#

以路線規劃器(RoutePlanner)為例。RoutePlanner 依賴 RoadMap 介面,但在建構子中直接建立了 NorthAmericaRoadMap

  • RoutePlanner 被硬編碼為只能規劃北美路線,完全無法用於其他地區
  • NorthAmericaRoadMap 新增建構參數(例如 useOnlineVersionincludeSeasonalRoads)時,RoutePlanner 被迫處理與自身抽象層無關的細節
  • 為了傳遞這些參數,RoutePlanner 又硬編碼了更多武斷的預設值(如總是使用線上版地圖、總是排除季節性道路),進一步限制了它的適用場景

硬編碼依賴的連鎖效應: 雖然建構起來很簡單(無參數),但 RoutePlanner 喪失了模組性與彈性——只能使用北美地圖、必須有網路連線、永遠排除季節性道路。

8.1.2 解法:使用依賴注入#

RoadMap 透過建構子參數注入,而不是在內部自行建立:

  • RoutePlanner 不再綁定特定地圖實作,可以搭配任何 RoadMap 使用
  • 建構時雖然多了一步(呼叫端需先建立 RoadMap),但可以透過 工廠函式(Factory Function)簡化常見用法
  • 例如 createDefaultNorthAmericaRoutePlanner() 提供合理的預設值,讓預設情境幾乎和原先一樣簡單,同時保留了對其他情境的適應能力

依賴注入框架#

  • 依賴注入可能導致大量的工廠函式樣板程式碼(boilerplate),依賴注入框架(DI Framework)能自動化大部分工作
  • 各語言都有多種框架可選,值得研究適合你的選項
  • 注意:即使擁護依賴注入的工程師,也不一定喜歡 DI 框架——若使用不當,會讓程式碼難以推理(難以追蹤哪段配置對應哪段程式碼)

8.1.3 設計程式碼時考慮依賴注入的可能性#

有些寫法會讓依賴注入變得不可能,應該在設計時就避免:

  • 靜態函式(Static Functions)的問題: 如果 NorthAmericaRoadMap 的函式是 static 的,RoutePlanner 就直接依賴這些靜態函式,無法透過依賴注入替換為其他地圖
  • 這種問題被稱為 Static Cling,過度依賴靜態函式(或變數)是已知的反模式,在單元測試中尤其棘手(無法使用 test doubles,詳見第 10 章)

設計建議: 當子問題可能有多種解法時,定義一個介面,並讓實作類別實現該介面(函式為非靜態)。如此一來,使用者在需要時就能透過依賴注入讓程式碼具備適應性。

8.2 優先依賴介面#

前一節展示了依賴注入如何讓 RoutePlanner 更容易重新配置。但這之所以可能,是因為所有地圖類別都實作了同一個 RoadMap 介面。這引出更通用的技巧:如果我們依賴的類別實作了一個介面,而該介面涵蓋了我們需要的功能,那麼通常應該依賴介面而非直接依賴具體類別。

8.2.1 依賴具體實作會限制適應性#

即使使用了依賴注入,如果 RoutePlanner 的建構子接受的是 NorthAmericaRoadMap(具體類別)而非 RoadMap(介面),我們仍然無法將它與其他 RoadMap 實作搭配使用。依賴注入帶來的「不需知道如何建構依賴」的好處仍在,但最重要的「可替換實作」優勢就喪失了。

8.2.2 解法:盡可能依賴介面#

依賴介面而非具體實作類別,可以讓程式碼在抽象層上更乾淨、模組性更強:

  • 介面提供了解決子問題的抽象層,具體實作則是較低層次的解法
  • 依賴更抽象的介面,通常能實現更乾淨的抽象分層與更好的模組性
  • 如果一個類別實作了某介面,且該介面涵蓋了我們需要的行為,這就是強烈的暗示——其他工程師可能會想用不同的實作來使用我們的程式碼

依賴反轉原則(Dependency Inversion Principle): 「應該依賴抽象而非具體實作」是依賴反轉原則的核心,也是 SOLID 原則中的 D。

8.3 小心類別繼承#

物件導向語言的標誌性功能之一是允許類別繼承。經典例子是用類別建立車輛階層:CarTruck 都繼承自 Vehicle,特定車型再繼承自 Car

Figure 8.1: Classes can inherit from one another, forming a class hierarchy.

繼承確實有其用途,尤其在兩個概念具有真正的 is-a 關係時(例如「汽車是一種載具」)。但繼承是一把雙面刃,濫用造成的問題往往很難修復,因此在讓一個類別繼承另一個之前,通常值得仔細思考。

8.3.1 類別繼承可能造成的問題#

IntFileReader 繼承 CsvFileHandler 為例。動機是想重用 CsvFileHandler 讀取 CSV 字串的功能,只需額外將字串轉為整數。但繼承帶來了以下問題:

繼承破壞乾淨的抽象層:

  • IntFileReader 會繼承 CsvFileHandler 的所有公開方法——包括不適合暴露的 getNextValue()writeValue()
  • 對於一個聲稱「從檔案讀取整數」的類別,能寫入資料和讀取原始字串是非常奇怪的 API
  • 一旦這些方法被外部程式碼使用,CsvFileHandler 就從「實作細節」變成了「公開 API 的一部分」,未來極難更改

繼承讓程式碼難以適應變化:

  • 假設新增需求:除了 CSV 格式,也需要支援分號分隔格式
  • 已有 SemicolonFileHandler 實作了與 CsvFileHandler 相同的介面
  • 但因為使用了繼承,IntFileReader 無法同時使用兩種 handler——唯一選擇是複製一整個 SemicolonIntFileReader 類別
  • 這導致大量重複程式碼,增加維護負擔和 Bug 風險
  • 諷刺的是,CsvFileHandlerSemicolonFileHandler 都實作了 FileValueReader 介面,本可透過這層抽象避免重複——但繼承讓我們無法利用它

8.3.2 解法:使用組合取代繼承#

組合(Composition)意味著在類別中持有另一個類別的實例,而非繼承它:

  • IntFileReader 持有一個 FileValueReader 實例(而非繼承 CsvFileHandler
  • 透過依賴注入,FileValueReader 在建構時傳入
  • 不再自動繼承父類所有方法——只有 IntFileReader 明確暴露的方法(如 getNextInt()close())會出現在 API 中
  • close() 方法透過轉發(Forwarding)實作:IntFileReader.close() 內部呼叫 valueReader.close()

組合帶來的好處:

  • 更乾淨的抽象層: API 只暴露 getNextInt()close(),不再有不相關的 getNextValue()writeValue()
  • 更好的適應性: 支援新的檔案格式只需傳入不同的 FileValueReader 實作,完全不需要重複程式碼。例如用工廠函式 createCsvIntReader()createSemicolonIntReader() 輕鬆建立不同配置

委派(Delegation): 當需要轉發大量方法到組合類別時,手動撰寫轉發函式會很繁瑣。部分語言提供內建或外掛支援:Kotlin 內建委派(Delegation)語法,Java 可用 Project Lombok 的 @Delegate 註解。

classDiagram
class CsvFileHandler {
+getNextValue() String
+writeValue(String)
+close()
}
class IntFileReader*繼承版 {
+getNextInt() Int
+getNextValue() String
+writeValue(String)
+close()
}
CsvFileHandler <|-- IntFileReader*繼承版 : extends
note for IntFileReader\_繼承版 "繼承的問題:\n- 洩漏 getNextValue(), writeValue()\n- 綁定 CsvFileHandler 無法替換\n- API 不乾淨"

    class FileValueReader {
        <<interface>>
        +getNextValue() String
        +close()
    }
    class CsvFileHandler_impl {
        +getNextValue() String
        +close()
    }
    class SemicolonFileHandler {
        +getNextValue() String
        +close()
    }
    class IntFileReader_組合版 {
        -FileValueReader valueReader
        +getNextInt() Int
        +close()
    }
    FileValueReader <|.. CsvFileHandler_impl : implements
    FileValueReader <|.. SemicolonFileHandler : implements
    IntFileReader_組合版 o-- FileValueReader : has-a 依賴注入
    note for IntFileReader_組合版 "組合的優點:\n- API 只暴露 getNextInt(), close()\n- 可替換不同 FileValueReader 實作\n- 乾淨的抽象層"

8.3.3 真正的 is-a 關係怎麼辦?#

即使存在真正的 is-a 關係,繼承仍可能造成問題:

  • 脆弱基礎類別問題(Fragile Base Class Problem): 修改父類可能意外破壞子類
  • 菱形繼承問題(Diamond Problem): 支援多重繼承的語言中,多個父類提供相同方法時會產生歧義
  • 問題化的階層(Problematic Hierarchies): 不支援多重繼承的語言中,單一繼承限制了分類彈性——例如有 CarAircraft 兩個父類,當出現「飛天汽車」時無法同時繼承兩者

Figure 8.2: Many languages support only single inheritance. This can lead to problems when a class logically belongs in more than one hierarchy.

面對這些問題,工程師常採用的替代方案:

  • 用介面定義階層: CarAircraft 改為介面,FlyingCar 可以同時實作兩者
  • 用組合達成程式碼重用: 各車類組合一個 DrivingAction 實例,各飛行器組合一個 FlyingAction 實例

Figure 8.3: Hierarchies can be defined using interfaces, while code reuse can be achieved using composition.

Mixins 與 Traits: 部分語言支援 mixin 或 trait,允許在不使用傳統類別繼承的情況下為類別添加共享功能。它們能緩解多重繼承和階層問題,但仍可能導致抽象層不乾淨或適應性不佳,使用時仍需謹慎。例如 Dart 支援 mixin,TypeScript 中也常見 mixin 用法;Rust 支援 trait,Java 和 C# 的 default interface methods 則提供了類似 trait 的能力。

8.4 類別只應關心自己#

模組化的關鍵目標之一是:需求變更應只影響與該需求直接相關的程式碼。如果某個概念完全封裝在單一類別內,這一目標通常能達成。反之,如果一個概念散落在多個類別中,任何相關的需求變更都需要修改多個類別——而且工程師很可能忘記修改其中某個,從而引入 Bug。

8.4.1 過度關心其他類別的問題#

BookChapter 為例:

  • Book 類別包含 getChapterWordCount() 函式,但這個函式只關心 Chapter 的內部結構(假設章節只有 prelude 和 sections)
  • 若需求變更——章節新增 summary 段落——不僅要修改 Chapter 類別,還必須修改 Book.getChapterWordCount()
  • 工程師可能在 Chapter 中加了 summary 支援,卻忘了更新 Book 的計算邏輯,導致字數統計出錯

8.4.2 解法:讓類別只關心自己#

wordCount() 邏輯移到 Chapter 類別內部,Book 只需呼叫 chapter.wordCount() 即可:

  • Book 不再需要知道 Chapter 的內部結構
  • 若章節新增 summary,只需修改 Chapter.wordCount()Book 完全不受影響
  • 類別之間的耦合度大幅降低

迪米特法則(Law of Demeter): 此原則主張一個物件應盡量少假設其他物件的內部結構,只與直接相關的物件互動。原始程式碼中 chapter.getPrelude().wordCount() 就違反了此原則——Book 不應深入 Chapter 的內部物件(如 TextBlock)。

8.5 將相關資料封裝在一起#

類別允許我們將事物組合在一起。第二章警告過將太多概念塞進同一個類別的問題,但我們也不應因此忽略了在合理情況下將事物組合的好處。當不同資料片段之間有不可分割的關聯性,且程式碼需要將它們一起傳遞時,將它們封裝成一個類別通常是明智的。

8.5.1 未封裝的資料難以處理#

TextBox.renderText() 為例,該函式需要四個文字樣式參數(fontfontSizelineHeighttextColor)。這些參數需要從 UiSettings 一路傳遞到 renderText()

  • 中間層函式(如 displayMessage())根本不關心文字樣式的細節,卻被迫逐一傳遞每個參數
  • 如同一個快遞員被迫知道包裹裡是焦糖松露還是果仁糖——理想狀況下,快遞員只需知道「有個包裹」就好
  • 若新增文字樣式選項(如斜體),所有中間層函式都得修改,即使它們與文字樣式毫無直接關係

8.5.2 解法:將相關資料組合成物件或類別#

建立 TextOptions 類別,將 fontfontSizelineHeighttextColor 封裝在一起:

  • displayMessage() 只需傳遞一個 TextOptions 實例,完全不需了解文字樣式的具體細節
  • 新增樣式選項時,只需修改 TextOptionsUiSettingsTextBox,中間層完全不受影響
  • displayMessage() 變成了稱職的快遞員:盡責交付包裹,不過問內容

何時封裝: 當不同的資料片段之間不可分割地相互關聯,且實際上不存在「只需要部分資料而不需要全部」的情境時,就適合將它們封裝在一起。但也要避免過度打包——第二章已警告過將太多概念塞進同一類別的問題。

8.6 小心回傳型別洩漏實作細節#

為了擁有乾淨的抽象層,必須確保各層不會洩漏實作細節。最常見的洩漏方式之一,就是回傳一個與實作細節緊密耦合的型別。

8.6.1 回傳型別洩漏實作細節的問題#

ProfilePictureService 為例,它使用 HttpFetcher 從伺服器取得頭像。getProfilePicture() 回傳的 ProfilePictureResult 使用了 HttpResponse.StatusHttpResponse.Payload——間接洩漏了使用 HTTP 連線的實作細節:

  • 使用者必須理解 HTTP 狀態碼(STATUS_200、STATUS_404 以及其他 50 多種),才能判斷請求是否成功
  • 若未來需要改用 WebSocket 取得頭像,所有依賴 HttpResponse 型別的上層程式碼都需要大幅修改

8.6.2 解法:回傳符合抽象層的型別#

回傳型別應反映該類別所提供的抽象層,而非暴露底層實作:

  • 定義專屬的 Status enum(只包含 SUCCESSUSER_DOES_NOT_EXISTOTHER_ERROR),取代 HttpResponse.Status
  • 回傳 List<Byte> 取代 HttpResponse.Payload
  • 使用者只需面對最少量的概念,不必了解底層使用了 HTTP

原則: 即使重用既有型別通常是好事,但若該型別不適合當前的抽象層,就應該定義自己的型別來暴露最少量的概念,以維持乾淨的分層與模組性。

8.7 小心例外洩漏實作細節#

回傳型別是程式碼契約中顯而易見的部分,相對容易注意到洩漏問題。但例外(Exception)——尤其是未檢查例外(Unchecked Exception)——屬於契約的「小字條款」,洩漏問題更加隱蔽且危險。

8.7.1 例外洩漏實作細節的問題#

TextSummarizer 為例,它依賴 TextImportanceScorer 介面。其中一個實作 ModelBasedScorer 可能拋出 PredictionModelException

  • 使用者為了處理錯誤,必須捕捉 PredictionModelException——但這洩漏了 TextSummarizer 使用模型預測的實作細節
  • 如果 TextSummarizer 換用了不同的 TextImportanceScorer 實作(拋出完全不同的例外),原有的 catch 語句就會失效
  • 未檢查例外的問題在於:實作類別不被強制只拋出介面定義的例外類型,工程師也容易忘記記載

8.7.2 解法:讓例外符合抽象層#

每一層程式碼應只暴露反映該層抽象的錯誤類型。做法是包裝(Wrap)低層例外為適合當前層的例外類型:

  • 定義 TextSummarizerExceptionTextImportanceScorerException
  • ModelBasedScorer 捕捉 PredictionModelException 後,包裝為 TextImportanceScorerException 再拋出
  • TextSummarizer 捕捉 TextImportanceScorerException 後,包裝為 TextSummarizerException 再拋出
  • 使用者只需處理 TextSummarizerException,無論底層實作如何更換,錯誤處理都能正常運作
  • 原始錯誤資訊不會遺失——它被包裝在新例外的 cause

關於顯式錯誤信號: 檢查例外(Checked Exception)只是一種顯式錯誤信號技術,且幾乎是 Java 獨有的。第四章介紹了替代方案,如 Result Type 和 Outcome。不論使用哪種方式,核心原則不變——確保錯誤類型符合當前的抽象層。

8.8 總結#

  • 模組化的程式碼更容易適應變化的需求
  • 模組化的核心目標:需求變更應只影響與該需求直接相關的程式碼
  • 模組化與乾淨的抽象層密切相關
  • 實現模組化的技巧:
    • 使用依賴注入
    • 依賴介面而非具體類別
    • 使用介面與組合取代類別繼承
    • 讓類別只關心自己
    • 將相關資料封裝在一起
    • 確保回傳型別和例外不會洩漏實作細節