為何在函式式語言中重提 SOLID#

二十多年前,作者在 OO 設計脈絡下提出 SOLID 原則,因此許多人把 SOLID 與 OO 綁在一起,甚至認為它們與函式式編程相剋。

這是個誤解。SOLID 是通用的軟體設計原則,與特定典範無關。本章逐條說明它們在函式式編程中如何體現。

延伸閱讀:

單一職責原則(SRP)#

SRP:模組應對少數的「變更來源」負責——而那些來源最終都是人

人可以歸入不同的角色(role)參與者(actor)。同一個 actor 對系統的需求一致;不同 actor 的需求可能彼此衝突。

一個模組若同時對多個 actor 負責,他們的需求變動會在你模組裡互相干擾,導致脆弱性(fragility)——簡單變更導致系統意外損壞。

對主管和客戶來說,「修一個小功能就讓系統其他地方掛掉」是最可怕的事——重複幾次後,他們會認定團隊已經失控。

範例:訂單系統的 SRP 違反#

(describe "Order Entry System"
  (context "Parsing Customers"
    (it "parses a valid customer"
      (should= {:id "1234567"
                :name "customer name"
                :address "customer address"
                :credit-limit 50000}
               (parse-customer
                 ["Customer-id: 1234567"
                  "Name: customer name"
                  "Address: customer address"
                  "Credit Limit: 50000"])))

    (it "makes sure credit limit is <= 50000"
      (should= :invalid
               (parse-customer
                 ["Customer-id: 1234567"
                  "Name: customer name"
                  "Address: customer address"
                  "Credit Limit: 50001"])))))

最後一個測試在做什麼?檢查商業規則——「信用額度不得超過 50,000」。

把商業規則塞進「解析」是典型 SRP 違反:解析應該只負責語法檢查語意檢查屬於另一個 actor 的領域。

規定輸入格式的人,不必然是規定信用額度上限的人。即使是同一個人,那個人也在扮演兩個不同的角色。

對應的實作糟糕之處:

(defn validate-customer
  [{:keys [id name address credit-limit] :as customer}]
  (if (or (nil? id) (nil? name) (nil? address) (nil? credit-limit))
    :invalid
    (let [credit-limit (Integer/parseInt credit-limit)]
      (if (> credit-limit 50000)
        :invalid
        (assoc customer :credit-limit credit-limit)))))

validate-customer 把語法檢查與「不得超過 50,000」的語意規則揉在一起——當業務改規則或解析格式改變,就會在同一個函式裡互相打架。

解法:把 credit-limit 上限那道規則移到負責信用額度處理的模組,而不是解析模組。

SRP 的核心:「同因同變、異因異變」的事物分開放

開放封閉原則(OCP)#

OCP(Bertrand Meyer,1988):模組應當對擴展開放、對修改封閉——擴充或改變行為不需要修改既有程式碼。

聽起來矛盾,但這是我們每天在做的事。例如 C 的 copy 程式:

void copy() {
  int c;
  while ((c = getchar()) != EOF)
    putchar(c);
}

它從 stdin 抄到 stdout。即使我們之後給作業系統新增一個 OCR 與一個語音合成器作為新裝置,這支程式不需要修改、甚至不需要重新編譯——它就能繼續工作。

OCP 的關鍵是:高階政策必須透過抽象層存取低階細節

Clojure 中的三種實踐方式#

OO 語言用「多型介面」做抽象層;函式式語言至少有三種等價作法。

1. 高階函式#

把要呼叫的函式當參數傳進來:

(defn copy [read write]
  (let [c (read)]
    (if (= c :eof)
      nil
      (recur read (write c)))))

將函式作為參數傳入或從函式回傳,稱為高階函式(higher-order function)

測試時用 atom 模擬 I/O 副作用:

(def str-in (atom nil))
(def str-out (atom nil))

(defn str-read []
  (let [c (first @str-in)]
    (if (nil? c) :eof
        (do (swap! str-in rest) c))))

(defn str-write [c]
  (swap! str-out str c)
  str-write)

2. 帶 vtable 的「物件」#

把多個函式打包成一個 map(C++ 的 vtable):

(defn copy [device]
  (let [c ((:getchar device))]
    (if (= c :eof)
      nil
      (do
        ((:putchar device) c)
        (recur device)))))
(copy {:getchar str-read :putchar str-write})

3. multi-method(依 dispatch 函式分派)#

(defmulti getchar (fn [device] (:device-type device)))
(defmulti putchar (fn [device c] (:device-type device)))

(defn copy [device]
  (let [c (getchar device)]
    (if (= c :eof)
      nil
      (do (putchar device c)
          (recur device)))))

(defmethod getchar :test-device [device] ...)
(defmethod putchar :test-device [device c] ...)

三種方式都讓你在不修改 copy 的情況下增加新裝置——這就是 OCP。

獨立部署:protocol + record#

OCP 的另一個好處是「高階政策」與「低階細節」可以獨立部署。前述三種寫法不能輕易做到 jar 等級的獨立部署,但 Clojure 的 defprotocol + defrecord 可以:

(defprotocol device
  (getchar [_])
  (putchar [_ c]))

(defrecord str-device [in-atom out-atom]
  device
  (getchar [_]
    (let [c (first @in-atom)]
      (if (nil? c) :eof
          (do (swap! in-atom rest) c))))
  (putchar [_ c]
    (swap! out-atom str c)))

protocol 編譯後會變成 Java interface,可獨立打包成 jar 動態載入。

如果這套機制讓你覺得「很 OO」,那是因為它就是 OO——JVM 是 OO 基礎,Clojure 在它上面合作良好。

Liskov 替代原則(LSP)#

任何支援 OCP 的語言都必然要支援 LSP——每一個 LSP 違反都是潛在的 OCP 違反

LSP 由 Barbara Liskov 於 1988 年提出:子型別必須能在使用基底型別的程式中替代基底型別

Clojure 中的 LSP 範例#

(defn pay [employee pay-date]
  (let [is-payday? (:is-payday employee)
        calc-pay (:calc-pay employee)
        send-paycheck (:send-paycheck employee)]
    (when (is-payday? pay-date)
      (let [paycheck (calc-pay)]
        (send-paycheck paycheck)))))

pay 透過 vtable 看到 employee 上的方法。任何 employee 物件只要遵守這份「介面」就能被 pay 處理——這正是 OCP。

但若有人創建這樣的 employee:

(defn make-later-employee [name address pay]
  (let [employee (make-test-employee name address pay)
        is-payday? (partial (fn [_ _] :tomorrow)
                            (:employee-data employee))]
    (assoc employee :is-payday is-payday?)))

is-payday? 回傳 :tomorrow 而非 false——這會讓 pay 在「不該發薪」的日子也發出薪水。這就是 LSP 違反

你可能想用「補丁」修正:

(when (= true (is-payday? pay-date)) ...)

這同時是 OCP 違反——因為低階細節的錯誤行為竟然推著你修改高階政策。很多人會忍不住把那個 = true 刪掉,於是 bug 重新出現

ISA 規則的陷阱#

OO 文獻常用「ISA」(is a)描述子型別:square ISA rectangle、later-employee ISA employee。但這個用語常常誤導我們。

正方形與長方形#

(defn make-rect [h w] {:h h :w w})

(defn set-h [rect h] (assoc rect :h h))
(defn set-w [rect w] (assoc rect :w w))
(defn area [rect] (* (:h rect) (:w rect)))

(defn perimeter [rect]
  (let [{:keys [h w]} rect]
    (* 2 (+ h w))))

(defn minimally-increase-area [rect]
  (let [{:keys [h w]} rect]
    (cond
      (>= h w) (make-rect (inc h) w)
      (> w h)  (make-rect h (inc w))
      :else    :tilt)))

「正方形是長方形」,所以我們可能寫:

(defn make-square [side]
  (make-rect side side))

大部分測試會通過——但客戶說:「我把 5×5 的正方形 minimally-increase-area,竟然得到面積 30 的東西,我要的是面積 36 的正方形!」

這是 LSP 違反:minimally-increase-area 預期高與寬可以獨立修改,但對正方形來說不成立。

試著加 :type 與分支判斷修補——你會發現 set-hset-wminimally-increase-area 都得改,OCP 也跟著被破壞

Representative Rule(代表規則)#

物件代表的事物之間的關係,不一定是物件之間的關係。

幾何上「正方形是長方形」沒錯,但程式中的 square 物件程式中的 rectangle 物件並不必然繼承這個關係——因為它們行為不同。

當你在現實世界看到兩個事物有 ISA 關係時,不要急著在程式中建立子型別關係——先問:行為一致嗎?否則就等著踩 LSP 的雷。

最簡單的解法:把兩個型別完全分開,永遠不要把 square 傳進處理 rectangle 的函式。

介面隔離原則(ISP)#

ISP 起源於靜態型別 OO 語言。在 Java 中,這樣的介面違反 ISP:

interface AtmInteractor {
  void requestAccount();
  void requestAmount();
  void requestPin();
}

任何只用到其中一個方法的使用者,仍然依賴所有三個方法——其中一個簽名變動,整個使用者都得重編譯。它依賴了它不需要的東西

解法是把介面拆開:

interface AccountInteractor { void requestAccount(); }
interface AmountInteractor  { void requestAmount(); }
interface PinInteractor     { void requestPin(); }

public class AtmInteractor implements AccountInteractor,
                                      AmountInteractor,
                                      PinInteractor { ... }

Figure 12.1: 已隔離的介面

Clojure 的 ISP#

Clojure(與 Ruby、Python、JavaScript)的「介面」是 duck type,本身已經被分散,不會像 Java 介面那樣綁死多個方法:

(defmulti request-account :interactor)
(defmulti request-amount  :interactor)
(defmulti request-pin     :interactor)

這三個 defmulti 不必住在同一個檔案,更新其一不影響其餘使用者。

「不要依賴你不需要的東西」原則仍然成立,只是動態語言中違反成本較低。

在 Clojure 等動態語言中,模組可以包含任何東西、放在任何檔案——反而更容易讓使用者依賴它們不需要的東西。要靠設計者自律。

ISP 的真正意義:

  • 使用方式相近的東西聚在一起
  • 使用方式不同的東西分開
  • 不要依賴你不需要的東西

依賴反轉原則(DIP)#

SOLID 原則之中:

  • OCP 是道德核心
  • SRP 是組織原則
  • LSPISP 是避坑警告
  • DIP 是這一切之所以可能的底層機制——每一個原則違反,幾乎都能透過反轉某個關鍵依賴來修復

古老的平行結構#

過去軟體的相依性結構常常是:

  • 執行期相依(runtime dependency):高階模組呼叫中階,再呼叫低階
  • 原始碼相依(source code dependency):每個模組 #includeimportrequire 它呼叫的模組

兩種相依互相平行——高階政策因此被低階細節綁住。

Figure 12.2: 古老的平行相依結構

1960 年代後期 Dahl 與 Nygaard 把 ALGOL 編譯器的呼叫堆疊框搬到 heap,發明了 OO(Simula 67)——讓「反轉相依」成為輕鬆又安全的家常便飯。

反轉相依的標準作法#

在高階模組與低階模組之間放一個抽象介面 I

  • 兩者都依賴 I
  • I 的方向是「低階指向高階」(在原始碼層次)

Figure 12.3: 透過介面反轉相依

盡可能把所有原始碼相依指向抽象。

這樣可以做出外掛式架構:商業規則在中央,UI 與資料庫在外層作為「插件」,可隨時更換。

Figure 12.4: 外掛式結構

實際上,UI 與資料庫實作了商業規則內定義的介面:商業規則操作這些介面,控制流向外指向 UI 與資料庫,原始碼相依方向卻全部反轉指向商業規則

Figure 12.5: 商業規則內的介面允許插件接入

範例:影片出租店(Video Store)#

引用 Martin Fowler《Refactoring》第一版的經典範例。

第一版:所有事情擠在一起#

(defn make-statement [rental-order]
  (let [{:keys [name]}    (:customer rental-order)
        {:keys [rentals]} rental-order
        header (format "Rental Record for %s\n" name)
        details (string/join "\n" (make-details rentals))
        footer (make-footer rentals)]
    (str header details footer)))

測試把商業規則統計報表的格式綁在一起。把 “Rental Record for” 改成 “Rental Statement for”,所有測試都會壞

拆成三模組:calculator + formatter + integration#

statement-calculator 把報表「算出資料」:

(defn make-statement-data [rental-order]
  (let [{:keys [name]} (:customer rental-order)
        {:keys [rentals]} rental-order]
    {:customer-name name
     :movies (for [rental rentals]
               {:title (:title (:movie rental))
                :price (determine-amount rental)})
     :owed   (reduce + (map determine-amount rentals))
     :points (reduce + (map determine-points rentals))}))

statement-formatter 把資料「格式化」:

(defn format-rental-statement [statement-data] ...)

integration-specs 把兩者串起來測試。

行銷改格式,只壞 formatter / integration 測試;財務改計算,只壞 calculator / integration 測試。兩種變動互不干擾

但有 DIP 違反#

(ns video-store.integration-specs
  (:require [video-store.statement-formatter :refer :all]
            [video-store.statement-calculator :refer :all]))

整合測試直接相依具體的 formatter 與 calculator——這是 DIP 違反。整合測試其實是高階政策的代理,它的 require 會把高階政策推向具體實作。

是否每個測試都要恪守 DIP?不必。但若想讓測試健壯、避免「改一個小地方壞 100 個測試」,就值得留心。

加新功能後:11 個模組的乾淨架構#

Figure 12.6: 把 Video Store 應用切成多個模組

新增需求:

  • 既要支援文字終端,也要支援 HTML 瀏覽器
  • 部份店面提供「買二送一」優惠

這時把系統切成 11 個模組(含 3 個測試):

  • order-processing:最高階政策
  • statement-formatter / statement-policy:兩個抽象
  • text-formatter / html-formatter:兩個 formatter 實作
  • normal-statement-policy / buy-two-get-one-free-policy:兩個 policy 實作
  • constructors:純資料建構
  • 三個 *-spec 測試

order-processing#

(ns video-store.order-processing
  (:require [video-store.statement-formatter :refer :all]
            [video-store.statement-policy :refer :all]))

(defn process-order [policy formatter order]
  (->> order
       (make-statement-data policy)
       (format-rental-statement formatter)))

這個高階模組只相依兩個抽象,不知道任何具體實作。

statement-formatter(介面)#

(ns video-store.statement-formatter)

(defmulti format-rental-statement
  (fn [formatter statement-data] (:type formatter)))

一個 defmulti 約等於一個 abstract method;只含一個 abstract method 的模組,約等於 OO 的介面。

statement-policy(抽象類別 + Template Method)#

(ns video-store.statement-policy)

(defn- policy-movie-dispatch [policy rental]
  [(:type policy) (-> rental :movie :type)])

(defmulti determine-amount policy-movie-dispatch)
(defmulti determine-points policy-movie-dispatch)
(defmulti total-amount (fn [policy _rentals] (:type policy)))
(defmulti total-points (fn [policy _rentals] (:type policy)))

(defn make-statement-data [policy rental-order] ...)

注意 determine-amounttuple [policy-type movie-type] 作為 dispatch——同時依兩個自由度分派,這在多數 OO 語言難以做到。

normal-statement-policy#

(defmethod determine-amount [::normal :regular] [_policy rental] ...)
(defmethod determine-amount [::normal :childrens] [_policy rental] ...)
(defmethod determine-amount [::normal :new-release] [_policy rental] ...)

用 derive 表達 ISA#

(ns video-store.buy-two-get-one-free-policy
  (:require [video-store.statement-policy :refer :all]
            [video-store.normal-statement-policy :as normal]))

(derive ::buy-two-get-one-free ::normal/normal)

(defn make-buy-two-get-one-free-policy []
  {:type ::buy-two-get-one-free})

(defmethod total-amount
  ::buy-two-get-one-free
  [policy rentals]
  (let [amounts (map #(determine-amount policy %) rentals)]
    (if (> (count amounts) 2)
      (reduce + (drop 1 (sort amounts)))
      (reduce + amounts))))

derive 告訴 Clojure:::buy-two-get-one-free::normal 的子型別。multi-method dispatcher 找不到專屬實作時,會 fall back 到父型別的實作——這是函式式版的「方法繼承」。

因此,新政策只覆寫 total-amount 一個函式,其他全部沿用 normal。

注意:使用 derive 時要小心避免 LSP 違反

結論#

我們把整個系統切成 11 個模組,每個模組封裝得宜,最重要的原始碼相依被反轉——高階政策不依賴低階細節。

整體結構看起來很像 OO 程式,但它完全是函式式

SOLID 是軟體設計的原則,不是 OO 的專利——在函式式世界依然完整適用,只是表達方式換了。