讓程式碼模組化#
需求在軟體的生命週期中幾乎必然會改變,試圖精確預測改變方向通常是白費力氣。模組化(Modularity)的核心目標是:讓程式碼能被輕鬆調整與重新配置,而不需要事先知道未來到底會怎麼變。關鍵原則是——不同的功能或需求應對應到程式碼庫中各自獨立的部分。如果做到了這點,當某個需求改變時,我們只需修改與該需求直接相關的那一處程式碼。
本章大量建立在「乾淨的抽象層」概念之上(第二章)。讓程式碼模組化,往往就是確保各子問題的解法彼此獨立、互不緊密耦合。這不僅讓程式碼更易適應變化,也讓系統更容易推理,且如第 9、10、11 章所述,更可重用、更可測試。
8.1 考慮使用依賴注入#
類別之間互相依賴是常態。第二章展示了程式碼如何將高層問題拆解為子問題,而在結構良好的程式碼中,每個子問題通常由專屬的類別負責解決。但子問題往往不只一種解法,因此將程式碼設計成可以重新配置子問題的解法,會很有用。依賴注入(Dependency Injection)正是達成這一目標的手段。
8.1.1 硬編碼的依賴會造成問題#
以路線規劃器(RoutePlanner)為例。RoutePlanner 依賴 RoadMap 介面,但在建構子中直接建立了 NorthAmericaRoadMap:
RoutePlanner被硬編碼為只能規劃北美路線,完全無法用於其他地區- 當
NorthAmericaRoadMap新增建構參數(例如useOnlineVersion、includeSeasonalRoads)時,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 小心類別繼承#
物件導向語言的標誌性功能之一是允許類別繼承。經典例子是用類別建立車輛階層:Car 和 Truck 都繼承自 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 風險
- 諷刺的是,
CsvFileHandler和SemicolonFileHandler都實作了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): 不支援多重繼承的語言中,單一繼承限制了分類彈性——例如有
Car和Aircraft兩個父類,當出現「飛天汽車」時無法同時繼承兩者

Figure 8.2: Many languages support only single inheritance. This can lead to problems when a class logically belongs in more than one hierarchy.
面對這些問題,工程師常採用的替代方案:
- 用介面定義階層:
Car和Aircraft改為介面,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 過度關心其他類別的問題#
以 Book 和 Chapter 為例:
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() 為例,該函式需要四個文字樣式參數(font、fontSize、lineHeight、textColor)。這些參數需要從 UiSettings 一路傳遞到 renderText():
- 中間層函式(如
displayMessage())根本不關心文字樣式的細節,卻被迫逐一傳遞每個參數 - 如同一個快遞員被迫知道包裹裡是焦糖松露還是果仁糖——理想狀況下,快遞員只需知道「有個包裹」就好
- 若新增文字樣式選項(如斜體),所有中間層函式都得修改,即使它們與文字樣式毫無直接關係
8.5.2 解法:將相關資料組合成物件或類別#
建立 TextOptions 類別,將 font、fontSize、lineHeight、textColor 封裝在一起:
displayMessage()只需傳遞一個TextOptions實例,完全不需了解文字樣式的具體細節- 新增樣式選項時,只需修改
TextOptions、UiSettings和TextBox,中間層完全不受影響 displayMessage()變成了稱職的快遞員:盡責交付包裹,不過問內容
何時封裝: 當不同的資料片段之間不可分割地相互關聯,且實際上不存在「只需要部分資料而不需要全部」的情境時,就適合將它們封裝在一起。但也要避免過度打包——第二章已警告過將太多概念塞進同一類別的問題。
8.6 小心回傳型別洩漏實作細節#
為了擁有乾淨的抽象層,必須確保各層不會洩漏實作細節。最常見的洩漏方式之一,就是回傳一個與實作細節緊密耦合的型別。
8.6.1 回傳型別洩漏實作細節的問題#
以 ProfilePictureService 為例,它使用 HttpFetcher 從伺服器取得頭像。getProfilePicture() 回傳的 ProfilePictureResult 使用了 HttpResponse.Status 和 HttpResponse.Payload——間接洩漏了使用 HTTP 連線的實作細節:
- 使用者必須理解 HTTP 狀態碼(STATUS_200、STATUS_404 以及其他 50 多種),才能判斷請求是否成功
- 若未來需要改用 WebSocket 取得頭像,所有依賴
HttpResponse型別的上層程式碼都需要大幅修改
8.6.2 解法:回傳符合抽象層的型別#
回傳型別應反映該類別所提供的抽象層,而非暴露底層實作:
- 定義專屬的
Statusenum(只包含SUCCESS、USER_DOES_NOT_EXIST、OTHER_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)低層例外為適合當前層的例外類型:
- 定義
TextSummarizerException和TextImportanceScorerException ModelBasedScorer捕捉PredictionModelException後,包裝為TextImportanceScorerException再拋出TextSummarizer捕捉TextImportanceScorerException後,包裝為TextSummarizerException再拋出- 使用者只需處理
TextSummarizerException,無論底層實作如何更換,錯誤處理都能正常運作 - 原始錯誤資訊不會遺失——它被包裝在新例外的
cause中
關於顯式錯誤信號: 檢查例外(Checked Exception)只是一種顯式錯誤信號技術,且幾乎是 Java 獨有的。第四章介紹了替代方案,如 Result Type 和 Outcome。不論使用哪種方式,核心原則不變——確保錯誤類型符合當前的抽象層。
8.8 總結#
- 模組化的程式碼更容易適應變化的需求
- 模組化的核心目標:需求變更應只影響與該需求直接相關的程式碼
- 模組化與乾淨的抽象層密切相關
- 實現模組化的技巧:
- 使用依賴注入
- 依賴介面而非具體類別
- 使用介面與組合取代類別繼承
- 讓類別只關心自己
- 將相關資料封裝在一起
- 確保回傳型別和例外不會洩漏實作細節