什麼是設計模式#
設計模式:在特定脈絡下,對某個常見問題的「有名字的解法」。
模式的價值在於名字與形式都是約定俗成的——對熟悉同一套術語的人來說,只要說「我們用 Abstract Server 吧」,對方就立刻聽懂「在 client 與 server 之間插入一個介面」。
Switch / Light 的故事#
很久以前作者參與 comp.object 新聞群組討論「給定一個開關(switch)和一個燈(light),讓開關打開燈」這個問題。
最簡單的解法:
Switch直接呼叫Light.TurnOn()

Figure 16.1: Switch / Light 問題的最簡解法
但有人反對:開關也可能用來開風扇、電視 ⋯⋯ 所以兩者之間應該插入抽象:
Switch依賴Switchable介面Light實作Switchable

Figure 16.2: Abstract Server 模式
這個解法叫做 Abstract Server——同時是 DIP、OCP、LSP 的最簡實現。
設計模式 = 名字 + 解法 + 脈絡。
若 team 不能修改
Light(它是第三方函式庫),就要改用 Adapter 模式:建一個LightAdapter來實作Switchable、把呼叫轉發給Light。
Adapter 的兩種形式#
- Object form:
LightAdapter是獨立物件,內含對Light的引用 - Class form:
LightAdapter繼承Light,並另外實作Switchable

Figure 16.3: Adapter 模式的物件形式

Figure 16.4: Adapter 模式的類別形式
同一個模式名稱未必只對應一種解法——多個變體共用同一名稱很常見。
函式式語言裡的設計模式#
一個常見的迷思:「設計模式是 OO 語言缺陷的繞路;函式式語言不需要它們」。
這個說法部分正確——某些模式的確含有「為了繞開 OO 語言限制」的部分;但整體不正確。即使是這類模式,也都有更通用的形式,能在函式式語言中使用。
Abstract Server#
函式形式#
(defn turn-on-light [] ...)
(defn engage-switch [turn-on-function]
(turn-on-function))vtable 形式#
(defn make-switchable-light []
{:on turn-on-light
:off turn-off-light})
(defn engage-switch [switchable]
((:on switchable))
((:off switchable)))multi-method 形式#
(defmulti turn-on :type)
(defmulti turn-off :type)
(defmethod turn-on :light [_] (turn-on-light))
(defmethod turn-off :light [_] (turn-off-light))
(defn engage-switch [switchable]
(turn-on switchable)
(turn-off switchable))Clojure 動態型別讓 vtable 形式特別自然。protocol/record 形式則接近 Java interface,留給讀者作為練習。
Adapter#
範例:第三方 :variable-light 的 turn-on-light 接受一個 intensity 參數(0 = 關,100 = 全亮)——介面與 engage-switch 不相容。
(defn turn-on-light [intensity] ...)
(defmulti turn-on :type)
(defmulti turn-off :type)
(defmethod turn-on :variable-light [_] (turn-on-light 100))
(defmethod turn-off :variable-light [_] (turn-on-light 0))
Figure 16.5: Adapter 模式的物件形式
把它拆成多個 namespace 後,UML 結構就跟 OO 版的 Adapter 完全對應:
turn-on-light.switchable:defmulti(介面)turn-on-light.engage-switch:高階政策turn-on-light.variable-light:第三方turn-on-light.variable-light-adapter:Adapter,依賴switchable與variable-light
這個版本同時是 Adapter 的 multi-method 形式 與 object form。
Clojure 不能做 class form 的 Adapter——它沒有實作繼承(inheritance of implementation)。同樣地,Java 也做不出 multi-method 形式。模式是通用的,但形式可能受語言限制。
讓 Adapter 持有狀態時,物件感更明顯:
(defn make-adapter [min-intensity max-intensity]
{:type :variable-light
:min-intensity min-intensity
:max-intensity max-intensity})
(defmethod turn-on :variable-light [adapter]
(turn-on-light (:max-intensity adapter)))
(defmethod turn-off :variable-light [adapter]
(turn-on-light (:min-intensity adapter)))進入到這裡,應該已經能說服你:許多 GOF 模式都能用 Clojure 表達。namespace/source file 的結構,本身就是函式式程式設計與架構的一部分。
Command#
GOF 中作者最喜歡的模式是 Command——不是因為複雜,而是因為極其簡單:
class Command {
public:
virtual void execute() = 0;
};一個方法的 interface。簡單得驚人,但能玩出無數變化。
函式式做法:函式即物件#
(defn execute [] ...)
(defn some-app [command]
(command))呼叫者直接傳入函式即可——函式式語言中的函式本身就是物件。
帶參數的 Command#
(some-app (partial execute :the-argument))partial 把 execute 與 :the-argument 綁在一起,回傳一個新函式——等同於 OO 中「把參數塞進 Command 物件」。
Undo 變體#
class UndoableCommand : public Command {
public:
virtual void undo() = 0;
};作者多年前寫過一支類似 AutoCAD 的繪圖工具,每個 palette 動作都是一個 UndoableCommand:
- 執行時把所做的事記在自己身上
- 完成後推上 undo stack
- 使用者按 undo 時,從 stack 取出並呼叫
undo
OO 版本的 AddRoomCommand 是可變的(記著 theAddedRoom)。Clojure 版本可以這樣做:
(defmulti execute :type)
(defmulti undo :type)
(defn make-add-room-command []
{:type :add-room-command})
(defmethod execute :add-room-command [command]
(assoc (make-add-room-command) :the-added-room (add-room)))
(defmethod undo :add-room-command [command]
(delete-room (:the-added-room command)))execute 不是修改自己,而是回傳一個記錄著執行結果的全新 command 物件——加進 undo-list 即可。
gui-app#
(defn gui-app [actions]
(loop [actions actions
undo-list (list)]
(if (empty? actions)
:DONE
(condp = (first actions)
:add-room-action
(let [executed-command (execute (make-add-room-command))]
(recur (rest actions) (conj undo-list executed-command)))
:undo-action
(let [command-to-undo (first undo-list)]
(undo command-to-undo)
(recur (rest actions) (rest undo-list)))
:TILT))))從這個例子可以看出:
- 簡單的 Command 可以單純用「函式」表達(function 本身就是 object)
- 一旦需求變複雜(多型、undo),就會自然回到 GOF 的標準形式
Composite#
Composite 是另一個「semantic richness、syntactic triviality」的好例子。它源自 handle/body 寫法(最早見於 Jim Coplien 的 Advanced C++ Programming Styles and Idioms)。
結構:
Switchable介面Light、VariableLight都實作SwitchableCompositeSwitchable也實作Switchable,內含一串SwitchableCompositeSwitchable.turnOn把呼叫廣播給所有成員

Figure 16.6: Composite 模式
不該偷懶用 map / doseq#
直觀做法:直接用 doseq 在外面遍歷。但這樣把「複數」洩漏到了外部——Composite 模式的目的就是把這份複數隱藏起來。
真正的 composite-switchable#
(defn make-composite-switchable []
{:type :composite-switchable
:switchables []})
(defn add [composite-switchable switchable]
(update composite-switchable :switchables conj switchable))
(defmethod turn-on :composite-switchable [c-switchable]
(doseq [s-able (:switchables c-switchable)]
(turn-on s-able)))
(defmethod turn-off :composite-switchable [c-switchable]
(doseq [s-able (:switchables c-switchable)]
(turn-off s-able)))
add是純函式——回傳一個新的 composite,原 composite 不被修改。從外部看,
composite-switchable與light/variable-light一樣,都只是Switchable——「複數」被隱藏了。
純函式版的 Composite:shape#
shape 介面有 translate 與 scale:
(defmulti translate (fn [shape dx dy] (::type shape)))
(defmulti scale (fn [shape factor] (::type shape)))circle、square、composite-shape 都實作之:
(defmethod translate ::composite-shape [cs dx dy]
(let [translated-shapes (map #(translate % dx dy) (::shapes cs))]
(assoc cs ::shapes translated-shapes)))
(defmethod scale ::composite-shape [cs factor]
(let [scaled-shapes (map #(scale % factor) (::shapes cs))]
(assoc cs ::shapes scaled-shapes)))translate 與 scale 都回傳新的 composite——完全函式式。
Composite 不只能用於有副作用的物件(燈、開關),也完全適用於純函式式資料結構。
Decorator#
Decorator 也是 handle/body 模式之一。它讓我們在不修改既有型別模型的情況下,為它加上新功能(OCP)。
範例:journaled-shape——記錄自身被執行過的所有操作。
- 不希望每個 shape 都被 journal(記憶體與計算成本高)
- 不希望在
circle與square內加if (:journaled?)弄髒它們
UML 結構:
journaled-shape也實作Shape- 持有對另一個
Shape的引用 translate/scale被呼叫時:先把這次操作寫進 journal,再委派給內部的Shape

Figure 16.7: Decorator 模式
(defn make [shape]
{::shape/type ::journaled-shape
::journal []
::shape shape})
(defmethod shape/translate ::journaled-shape [js dx dy]
(-> js
(update ::journal conj [:translate dx dy])
(assoc ::shape (shape/translate (::shape js) dx dy))))
(defmethod shape/scale ::journaled-shape [js factor]
(-> js
(update ::journal conj [:scale factor])
(assoc ::shape (shape/scale (::shape js) factor))))
journaled-shape對任意shape(包括composite-shape)都生效——沒有改任何既有元素就為整個型別模型加上新功能,這就是 OCP 的力量。
Visitor#
Decorator 適合「新功能與子型別無關」的情境。當新功能依賴於子型別時,就需要 Visitor。
範例:把每個 shape 變成 JSON 字串。新增 XML / YAML / 其他格式時,不希望持續汙染 Shape 介面。
GOF 的 Visitor(Java 版)#
Shape介面新增一個accept(ShapeVisitor v)方法- 每個子型別
Square、Circle都把this傳給v.visit(this) ShapeVisitor介面為每個子型別都有一個visit(Square s)/visit(Circle c)方法- 不同 visitor 子類別(
JsonVisitor、XmlVisitor)實作不同方法

Figure 16.8: Visitor 模式
90 度旋轉 與 雙重分派#
注意
ShapeVisitor把「子型別」轉換為方法:每個子型別都對應一個方法名稱。作者稱這種「子型別 → 方法」的轉換為90 度旋轉。流程:
shape.accept(v)→ 多型分派到Square.accept→ 呼叫v.visit(this)→ 多型分派到JsonVisitor.visit(Square)。這叫 double-dispatch(雙重分派)。第一次分派決定 shape 的子型別;第二次分派決定 visitor 的子型別。
為何 GOF 的 Visitor 這麼複雜#
答案:closed classes。
在 Java、C++ 中,要為類別新增方法,必須打開該類別的源檔修改它——無法從外部新檔案加入新方法。
Clojure 沒有這個限制(C# 有部份解法)——因為 Clojure 沒有「類別」這個語法構造,而是用慣例做出來的。
Clojure 版 Visitor#
(ns visitor-example.json-shape-visitor
(:require [visitor-example
[shape :as shape]
[circle :as circle]
[square :as square]]))
(defmulti to-json ::shape/type)
(defmethod to-json ::square/square [square]
(let [{:keys [::square/top-left ::square/side]} square
[x y] top-left]
(format "{\"top-left\": [%s,%s], \"side\": %s}" x y side)))
(defmethod to-json ::circle/circle [circle]
(let [{:keys [::circle/center ::circle/radius]} circle
[x y] center]
(format "{\"center\": [%s,%s], \"radius\": %s}" x y radius)))
defmulti to-json直接把to-json加進shape型別模型——不需要修改shape、circle、square任何一行。GOF 的
accept方法 + 雙重分派全部消失——它們本來就只是繞開 closed classes 的腳手架。這告訴我們:GOF 把這個模式描述得有點過度——雙重分派只是 closed classes 語言的副產品。
90 度旋轉的副作用#
json-shape-visitor 對所有子型別(square、circle)都有 defmethod——若新增 triangle,這個 visitor 必須修改。這是 OCP 違反,更糟的是它違反了 Clean Architecture 的依賴規則:高階模組相依低階模組。

Figure 16.9: 違反依賴規則
解法:把介面與實作分開#
(ns visitor-example.json-shape-visitor
(:require [visitor-example.shape :as shape]))
(defmulti to-json ::shape/type)(ns visitor-example.json-shape-visitor-implementation
(:require [visitor-example.json-shape-visitor :as v]
[visitor-example.circle :as circle]
[visitor-example.square :as square]))
(defmethod v/to-json ::square/square [square] ...)
(defmethod v/to-json ::circle/circle [circle] ...)main 在啟動時 require 這個 implementation,讓 defmethod 註冊:
(ns visitor-example.main
(:require [visitor-example.json-shape-visitor-implementation]))這樣所有越過架構邊界的相依都指向高階抽象——OCP、依賴規則同時滿足。

Figure 16.10: 函式式且具備架構能力的 Visitor
Abstract Factory#
DIP 告訴我們:避免相依「會變動且具體」的東西。但創建物件時往往得直接相依該具體類別,從而帶來架構難題。
範例:App 透過 Shape 介面操作物件,但要建立 Circle / Square 實例時,就不得不相依它們的具體模組。

Figure 16.11: 因建立物件導致的 DIP 違反
加上架構邊界後,這個 <creates> 相依跨越邊界、指向錯誤的方向,依賴規則被破壞:

Figure 16.12: 越過架構邊界違反依賴規則
Abstract Factory 模式#
Abstract Factory 提供良好解法:所有跨越邊界的相依都指向高階端,Circle 與 Square 仍然是 App 的插件,但透過 ShapeFactory 介面建立——相依方向被反轉了。

Figure 16.13: Abstract Factory 模式解決依賴規則衝突
(ns abstract-factory-example.shape-factory)
(defmulti make-circle (fn [factory center radius] (::type factory)))
(defmulti make-square (fn [factory top-left side] (::type factory)))(ns abstract-factory-example.shape-factory-implementation
(:require [abstract-factory-example.shape-factory :as factory]
[abstract-factory-example.square :as square]
[abstract-factory-example.circle :as circle]))
(defn make [] {::factory/type ::implementation})
(defmethod factory/make-square ::implementation
[factory top-left side]
(square/make top-left side))
(defmethod factory/make-circle ::implementation
[factory center radius]
(circle/make center radius))(ns abstract-factory-example.main
(:require [abstract-factory-example.shape-factory-implementation :as imp]))
(def shape-factory (atom nil))
(defn init []
(reset! shape-factory (imp/make)))App 只相依 shape 與 shape-factory 兩個介面;具體實作由 main 在啟動時透過 atom 注入。
設定全域 atom 是處理 factory 的常見策略——靜態語言中該 atom 會被宣告為介面型別,動態語言中無此需要。
90 度旋轉的問題再現#
shape-factory 為每個子型別都有一個方法(make-circle、make-square)——新增 triangle 時,介面也得改,又是 OCP 違反,且發生在架構邊界的高階端。
用「不透明 token」消除 90 度旋轉#
(defmulti make (fn [factory type & args] (::type factory)))
(defmethod factory/make ::implementation
[factory type & args]
(condp = type
:square (apply square/make args)
:circle (apply circle/make args)))呼叫端:
(factory/make @main/shape-factory :square [100 100] 10)
(factory/make @main/shape-factory :circle [100 100] 10)
:square/:circle是不透明的 token:沒有 namespace、沒有宣告、其他模組對它們一無所知。換成
1/2或字串"square"/"circle"都行——重點是它們在邊界線上方完全不被認識。這種不透明性是這個解法的關鍵:新增
triangle時,邊界線上方完全不必修改。
型別安全?#
在靜態型別語言(Java)中,這種技巧犧牲了型別安全——不透明值無法被靜態型別系統檢查。
Clojure 的
clojure.spec也救不了——任何錯誤都還是 runtime exception。這是「軟體物理定律」:任何語言要在架構邊界上維持 OCP,都得在邊界處放棄部分型別安全、依靠 runtime 例外。
結論:Singleton 和其他模式#
其他 GOF 模式留作練習。讀到這裡,應該已經明白:只要有類似 Clojure 的多型機制,函式式語言就跟 OO 語言一樣 OO——大多數 GOF 模式只要把不可變性納入考慮,就能直接套用。
至於 Singleton?直接創一個就好。
後記:OO 是毒藥嗎#
函式式編程與 OO 完全相容,且互利。
仔細看本書中 Clojure 的寫法:
defmulti/defmethod表達多型- map 表達封裝資料結構(即物件)
- 常常為這些「物件」寫構造函式
- namespace / 源檔結構與 Java、C++、C#、Ruby、Python 高度相似
Clojure 的 OO 屬性:
- 沒有繼承,但有至少三種有效的多型機制;其中至少兩種支援 open class
- 沒有
public/private/protected,但有 namespaced keyword、動態型別 spec、defn-私有函式 - 支援(不強制)source-file/namespace 結構,可表達與「企業語言」相同的架構切分
Clojure 是個 OO/函式式語言(OOFL?FOOL?還是別發明縮寫了)。Scala、Elixir、F# 也或多或少屬於這類。
函式式編程帶給我們的「額外限制」是:消除或嚴格隔離副作用。我們的 class 與模組會強烈偏好不可變物件——但它們仍然是物件,仍可組織為「實作介面的類別」。
因此,OO 中那些有用的設計原則與設計模式,在 Clojure 等函式式語言中多半依然適用、依然有用。