什麼是設計模式#

設計模式:在特定脈絡下,對某個常見問題的「有名字的解法」。

模式的價值在於名字與形式都是約定俗成的——對熟悉同一套術語的人來說,只要說「我們用 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 formLightAdapter 是獨立物件,內含對 Light 的引用
  • Class formLightAdapter 繼承 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-lightturn-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.switchabledefmulti(介面)
  • turn-on-light.engage-switch:高階政策
  • turn-on-light.variable-light:第三方
  • turn-on-light.variable-light-adapter:Adapter,依賴 switchablevariable-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))

partialexecute: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 介面
  • LightVariableLight 都實作 Switchable
  • CompositeSwitchable 也實作 Switchable,內含一串 Switchable
  • CompositeSwitchable.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-switchablelight / variable-light 一樣,都只是 Switchable——「複數」被隱藏了。

純函式版的 Composite:shape#

shape 介面有 translatescale

(defmulti translate (fn [shape dx dy] (::type shape)))
(defmulti scale     (fn [shape factor] (::type shape)))

circlesquarecomposite-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)))

translatescale 都回傳新的 composite——完全函式式

Composite 不只能用於有副作用的物件(燈、開關),也完全適用於純函式式資料結構。

Decorator#

Decorator 也是 handle/body 模式之一。它讓我們在不修改既有型別模型的情況下,為它加上新功能(OCP)。

範例:journaled-shape——記錄自身被執行過的所有操作。

  • 不希望每個 shape 都被 journal(記憶體與計算成本高)
  • 不希望在 circlesquare 內加 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) 方法
  • 每個子型別 SquareCircle 都把 this 傳給 v.visit(this)
  • ShapeVisitor 介面為每個子型別都有一個 visit(Square s) / visit(Circle c) 方法
  • 不同 visitor 子類別(JsonVisitorXmlVisitor)實作不同方法

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 型別模型——不需要修改 shapecirclesquare 任何一行

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 提供良好解法:所有跨越邊界的相依都指向高階端,CircleSquare 仍然是 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 只相依 shapeshape-factory 兩個介面;具體實作由 main 在啟動時透過 atom 注入。

設定全域 atom 是處理 factory 的常見策略——靜態語言中該 atom 會被宣告為介面型別,動態語言中無此需要。

90 度旋轉的問題再現#

shape-factory 為每個子型別都有一個方法(make-circlemake-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 等函式式語言中多半依然適用、依然有用