《Design Patterns 設計模式(GoF)》Podcast 準備稿:創建型模式 II — Builder、Prototype 與「該被退休」的 Singleton
書名: 設計模式 Design Patterns(Elements of Reusable Object-Oriented Software) 作者: Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides(Gang of Four) 系列: 啃一本大書(恩普拉氏) 公開標題建議: 啃一本大書|Design Patterns.Builder、Prototype,與被作者後悔收錄的 Singleton (4/11) 涵蓋範圍: Builder、Prototype、Singleton(深講 Builder+Singleton 爭議;Prototype 速覽)
背景速覽
接續上一集的工廠雙雄,這集收掉創建型模式剩下的三個:Builder(建造者)、Prototype(原型)、Singleton(單例)。前兩個是實用但低調的好工具,最後一個——Singleton——則是整本 GoF 裡爭議最大、連作者自己都後悔收錄的模式。
這集的戲劇張力都在 Singleton。它被教科書當「入門模式」教了二十年,卻在實務界被公認為「包裝過的全域變數」「測試的惡夢」。我們會誠實把它的 Intent、它為什麼看起來合理、以及它為什麼被現代開發視為反模式,一次講清楚——這正是 EP1 講的「經典要讀,但要帶腦讀」的最佳示範。
一句話重點
Builder 把「複雜物件的建構流程」和「最終長相」拆開、讓同一套流程產出不同表示;Prototype 用「複製一個原型」取代「實例化一個類別」;而 Singleton——確保只有一個實例——雖然 Intent 合理,但在現代已普遍被視為反模式,請理解它、但別輕易用它。
值得討論的重點
1. Builder:把「怎麼建」和「建成什麼」拆開
Intent:把複雜物件的建構過程與它的表示分離,使同一套建構流程能產生不同表示。
GoF 的動機例子很經典:一個 RTF 文件閱讀器,要把 RTF 轉成純文字、可編輯 widget、或 TeX。解法是給 RTFReader(稱為 Director)配一個 TextConverter(稱為 Builder)。Reader 一邊解析 RTF、一邊每遇到一個 token 就呼叫 Builder 的對應操作。換不同的 Builder(ASCIIConverter、TeXConverter)就產出不同格式——而解析演算法完全不用改。
精髓句:「怎麼建構」(Director 的演算法)與「建構出什麼」(Builder 的產品)解耦。 跟 Abstract Factory 的差別在於:Abstract Factory 一口氣回傳產品,Builder 是一步步建、最後一步才取回成品——對最終結構有更精細的控制。
要提醒聽眾:GoF 原版 Builder 是「Director + Builder」這種重型結構。但今天大家口中的「Builder 模式」,多半是另一回事——是那種 new Pizza.Builder().cheese().pepperoni().build() 的流暢介面(fluent builder),用來解決「建構子參數太多/太多 optional 欄位」的問題。兩者精神相通(都是逐步建構),但解的痛點不太一樣。這個差異很重要,否則聽眾會對不上號。
2. Prototype:用「複製」取代「實例化」(速覽)
Intent:用一個原型實例指定要建的物件種類,透過 clone(複製) 原型來建新物件。
動機:樂譜編輯器的工具列,每種音符如果都要一個 GraphicTool 子類別,會爆出一堆只差在「new 哪個」的子類別。解法是讓工具持有一個原型(某個 Graphic 實例),要新物件時 Clone() 它就好。更妙的是,全音符和二分音符甚至不用各自的 class——可以是同一個 MusicalNote 用不同 bitmap/duration 初始化的實例。用「改值」取代「定義新類別」。
關鍵權衡(一句話帶過即可):深複製 vs 淺複製的陷阱——物件內部若有複雜結構或循環參照,clone 要小心,通常得 deep copy 才能讓副本和原型互不影響。
Prototype 在當代的影子:JavaScript 的整個物件模型就是 prototypal;很多語言的 clone()/copy()/序列化反序列化建副本,骨子裡都是這招。它在動態語言裡其實活得很好。
3. Singleton:Intent 合理,但請把它當「反模式」來認識
Intent:確保一個類別只有一個實例,並提供一個全域存取點。
GoF 的理由聽起來很合理:系統裡只該有一個 printer spooler、一個 window manager。與其用全域變數(無法防止建第二個),不如讓類別自己管控唯一實例——Instance() 方法、建構子設 private、lazy initialization。GoF 還列了它比全域變數好的地方:受控存取、可子類別化、之後能輕鬆改成允許多個實例。
這些聽起來都對。問題出在實務後果,這段要花最多時間:
- 它是「偽裝的全域狀態」。任何地方都能
Singleton.Instance()拿到它、改它的狀態。這等於到處藏著看不見的依賴和耦合——你看一個函式的簽名,根本不知道它偷偷動了哪個 singleton。 - 它是測試的惡夢。全域單例讓單元測試難以隔離:測試之間共享狀態、難以替換成 mock、測試順序開始影響結果。
- 它違反單一職責:一個類別同時管「自己的業務邏輯」和「自己只能有一個」這件事。
- 多執行緒下還有 lazy init 的競態,GoF 當年甚至專門警告「別在 C++ 用全域/static 物件實作 Singleton」(初始化順序不可控)。
現代共識:你想要「整個系統只有一個」這件事,沒問題;但達成它的方式應該是「由 DI 容器管理生命週期,把這唯一實例『注入』給需要的人」,而不是讓大家自己去 Instance() 全域抓取。前者的依賴是明確的、可測試的、可替換的;後者是隱藏的、僵硬的。
注意事項
⚠️ Singleton 這集最重要的一句話:別教條地套用 GoF。 GoF 是 1994 年的書,當時 DI 容器、現代測試文化都還沒成形,Singleton 在那個時空有它的道理。但今天它已被廣泛視為反模式(anti-pattern)——這不是我個人意見,是業界長期累積的共識,連 GoF 作者在後來的訪談都說「如果重寫,會把 Singleton 拿掉」。所以這集不是要你「不准用」,而是要你知道它的代價,並知道現代有更好的替代(DI 管理的單一實例)。
⚠️ 別把「Singleton 模式」和「一個物件的單一實例」混為一談。 「系統裡只有一個 X」是個合理的需求;「用 GoF Singleton 模式(全域 Instance() + 私有建構子)來實現它」是個有問題的手段。需求沒錯,錯在手段。把這兩件事分開,聽眾才不會誤以為「所以以後不能只有一個實例」。
專家補充
💡 Builder 的現代主流用法(fluent builder)其實是在補語言的不足。 它解決的核心痛點是「建構子參數爆炸 + 太多 optional 欄位 + 想要不可變物件」。在有具名參數 + 預設值的語言(Kotlin、Python、Swift),這個痛點直接被語言消化掉,fluent builder 的需求就大幅下降——你直接 Pizza(cheese = true, pepperoni = true) 就好。又一個「模式是補語言不足的補丁」的活例子。
💡 Singleton 是『過度套用模式(patternitis)』的頭號案例。 因為它最好實作、最早被教,初學者學會後容易「看到什麼都想做成 Singleton」——設定檔、logger、資料庫連線、快取,全變單例。結果是一個處處耦合、無法測試的系統。這集很適合把它當「反面教材」,呼應 EP1 埋的伏筆:模式用錯地方,比不用更糟。
💡 三個模式其實能互相搭配。 GoF 自己提到:Abstract Factory、Builder、Prototype 都可以用 Singleton 實作(工廠通常只需一個);Abstract Factory 也可以內部存一組 Prototype 來 clone 產品,避免為每個家族寫工廠子類別。創建型這五個模式不是互斥清單,而是一個可以互相組合的工具箱——這個觀念收尾很好。
討論問題
🎙️ 開場鉤子:「今天要講三個建立物件的模式。前兩個很實用,最後一個——Singleton——是 GoF 裡唯一一個,連作者自己都說『如果重寫會拿掉它』的模式。我們來看看,一個被寫進經典、教了二十年的模式,怎麼會變成人人喊打的反模式。」
🎙️ 自問自答:「那 Singleton 到底還能不能用?」——我的立場:需求(只有一個實例)合理,手段(全域 Instance)過時。今天要「只有一個」,就交給 DI 容器管理、把它注入進來;別讓全世界自己去全域抓。
🎙️ 帶走的一題:「搜一下你專案裡所有的 Singleton(或叫 getInstance 的東西)。挑一個,問自己:如果我想在測試裡把它換成假的,做得到嗎?如果做不到——你就摸到 Singleton 為什麼是反模式了。」
更大範圍關聯
- Singleton 與測試文化的衝突:全域單例難以注入 mock,正是《Clean Code》《Clean Coder》(Uncle Bob) 強調的 TDD 與可測試性所反對的——這條線把 GoF 接到測試紀律的脈絡(見 [軟體工程領域切分藍圖])。
- DI 容器作為「正確版 Singleton」:Spring/Guice 用容器管理單一實例的生命週期,是對 GoF Singleton 的現代修正——保留「只有一個」,去掉「全域抓取」。
- Prototype 與動態語言:JavaScript 的原型鏈、各語言的
clone/序列化建副本,都是 Prototype 思想的延續;它在動態/函數式世界活得比 Singleton 好。 - Builder 與不可變性:fluent builder 在 FP 與不可變物件設計(《Functional Design》)中常被用來「分階段建構一個不可變物件」,與「偏好不可變」的現代潮流契合。
錄製建議
- 建議時長:約 22-25 分鐘。配比:Builder(原版 + fluent 版差異)約 8 分、Prototype 速覽約 4 分、Singleton 約 10-12 分(本集主菜,爭議要講透)。
- 討論策略:前半段保持輕快(Builder/Prototype 是實用工具,給直覺即可),把火力集中在 Singleton。Singleton 段建議用「先講它哪裡看起來合理 → 再拆它實務上為什麼出事 → 最後給現代替代方案」的三段式,避免變成單純的批鬥。務必引用「GoF 作者後悔收錄」這個料,這是本集最有記憶點的鉤子。
- 收尾把創建型五模式串成「一個可互相組合的工具箱」,並預告下集進入結構型模式。