為何在函式式語言中重提 SOLID#
二十多年前,作者在 OO 設計脈絡下提出 SOLID 原則,因此許多人把 SOLID 與 OO 綁在一起,甚至認為它們與函式式編程相剋。
這是個誤解。SOLID 是通用的軟體設計原則,與特定典範無關。本章逐條說明它們在函式式編程中如何體現。
延伸閱讀:
- 《Agile Software Development: Principles, Patterns, and Practices》
- 《Clean Architecture》
- cleancoder.com ↗
- cleancoders.com ↗(影片)
單一職責原則(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-h、set-w、minimally-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 是組織原則
- LSP 與 ISP 是避坑警告
- DIP 是這一切之所以可能的底層機制——每一個原則違反,幾乎都能透過反轉某個關鍵依賴來修復
古老的平行結構#
過去軟體的相依性結構常常是:
- 執行期相依(runtime dependency):高階模組呼叫中階,再呼叫低階
- 原始碼相依(source code dependency):每個模組
#include、import、require它呼叫的模組
兩種相依互相平行——高階政策因此被低階細節綁住。

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-amount用 tuple[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 的專利——在函式式世界依然完整適用,只是表達方式換了。