多型才是 OO 的精華#
上一章我們看到,OO 風格與「資料型別、資料內聚」高度相關——但那只是 OO 的一部分。更核心的特徵其實是多型(polymorphism)。
在《Clean Architecture》中作者主張:OO 有三項屬性——封裝(encapsulation)、繼承(inheritance)、多型——其中多型才是真正帶來好處的那一項,其他兩項是次要的。
前面三章的範例都缺少多型場景。本章用《Agile Software Development: Principles, Patterns, and Practices》第三部那個經典的薪資(Payroll)問題補上這一塊。
薪資需求#
- 系統內有員工資料庫
- 薪資程式每日執行,算出今天該發薪水的員工並送出給付
- 員工分三類:
- 月薪(salaried):每月最後一個工作日發;月薪寫在員工資料中
- 抽成(commissioned):每兩週的星期五發;底薪 + 抽成;抽成 = 抽成率 × 該段時間的銷售總額
- 時薪(hourly):每週五發;薪資 = 時薪 × 該週工時;超過 40 小時的部分以 1.5 倍計算
- 給付方式有三種:
- 郵寄到家
- 由薪資管理員代收
- 直接存入銀行
- 地址、薪資管理員、銀行資訊皆寫在員工資料中
OO 解法:Java#
UML 物件圖大致如下:Payroll 為頂層;員工有三種薪資分類(Hourly、Commissioned、Salaried)、三種給付方式(Mail、Hold、Deposit)、三種薪資排程(Weekly、Bi-weekly、Monthly)。

Figure 9.1: Payroll 問題的物件模型
Payroll.run#
Payroll 的 run 是純粹的真理:
void run() {
for (Employee e : db.getEmployees()) {
if (e.isPayDay()) {
Pay pay = e.calcPay();
e.sendPay(pay);
}
}
}對每位員工,若今天是發薪日就算薪、送薪——僅此而已。
圖中有三處明顯的 Strategy 模式:
isPayDay、calcPay、sendPay各自由不同子類實作。
getEmployees 必須把資料庫中的扁平資料重組成這個物件結構(資料庫中通常不會直接長這樣)。
整個 UML 中還有一條重要的**架構邊界(虛線)**橫切所有繼承關係,分隔了高階抽象與低階細節——這是 OO 設計常見的形貌。
函式式版本#
函式式版本最自然的呈現是資料流圖(Data Flow Diagram, DFD)。

Figure 9.2: Payroll 問題的資料流圖
有趣的是,OO 用 UML 表達最自然,函式式用 DFD 表達最自然。DFD 擅長描繪資料與處理之間的關係,但在描繪架構決策時不如 UML class diagram 直觀。
函式版的「純粹真理」#
(defn payroll [today db]
(let [employees (get-employees db)
employees-to-pay (get-employees-to-be-paid-today
today employees)
amounts (get-paycheck-amounts employees-to-pay)
ids (get-ids employees-to-pay)
dispositions (get-dispositions employees-to-pay)]
(send-paychecks ids amounts dispositions)))這版不是迭代——員工清單流經整支程式,每一站都依照 DFD 略加修整。
函式式程式更像「水管系統」而非「逐步流程」:它調節並改造資料的流動,而非一步一步地走過資料。
測試片段#
月薪:
(it "pays one salaried employee at end of month by mail"
(let [employees [{:id "emp1"
:schedule :monthly
:pay-class [:salaried 5000]
:disposition [:mail "name" "home"]}]
db {:employees employees}
today (parse-date "Nov 30 2021")]
(should= [{:type :mail :id "emp1"
:name "name" :address "home"
:amount 5000}]
(payroll today db))))時薪:
(it "pays one hourly employee on Friday by Direct Deposit"
(let [employees [{:id "empid"
:schedule :weekly
:pay-class [:hourly 15]
:disposition [:deposit "routing" "account"]}]
time-cards {"empid" [["Nov 12 2022" 80/10]]}
db {:employees employees :time-cards time-cards}
friday (parse-date "Nov 18 2022")]
(should= [{:type :deposit :id "empid"
:routing "routing" :account "account"
:amount 120}]
(payroll friday db))))
80/10是「有理數 80/10」而非「80 除以 10」——確保後續運算不被當成整數處理。
抽成:
(it "pays one commissioned employee on an even Friday"
(let [employees [{:id "empid"
:schedule :biweekly
:pay-class [:commissioned 1000 0.05]
:disposition [:paymaster "paymaster"]}]
sales-receipts {"empid" [["Nov 12 2022" 1000]]}
db {:employees employees :sales-receipts sales-receipts}
friday (parse-date "Nov 18 2022")]
(should= [{:type :paymaster :id "empid"
:paymaster "paymaster"
:amount 850}]
(payroll friday db))))可以看到員工與支票指令物件依 :schedule / :pay-class / :disposition 三個欄位呈現不同形狀。是怎麼做到的?
defmulti:依「函式結果」分派的多型#
(defn get-pay-class [employee]
(first (:pay-class employee)))
(defn get-disposition [paycheck-directive]
(first (:disposition paycheck-directive)))
(defmulti is-today-payday :schedule)
(defmulti calc-pay get-pay-class)
(defmulti dispose get-disposition)defmulti 像 Java interface,但分派方式不同:
- Java/C# 多型:依物件的內建型別分派
- Clojure
defmulti:依指定函式的回傳值分派
例如 (defmulti calc-pay get-pay-class) 表示:呼叫 calc-pay 時,先呼叫 get-pay-class 得到一個 dispatch 值(:salaried / :hourly / :commissioned),再分派到對應的 defmethod 實作。
defmethod 各自實作#
(defn- get-salary [employee]
(second (:pay-class employee)))
(defmethod calc-pay :salaried [employee]
(get-salary employee))
(defmethod calc-pay :hourly [employee]
(let [db (:db employee)
time-cards (:time-cards db)
my-time-cards (get time-cards (:id employee))
[_ hourly-rate] (:pay-class employee)
hours (map second my-time-cards)
total-hours (reduce + hours)]
(* total-hours hourly-rate)))
(defmethod calc-pay :commissioned [employee]
(let [db (:db employee)
sales-receipts (:sales-receipts db)
my-sales-receipts (get sales-receipts (:id employee))
[_ base-pay commission-rate] (:pay-class employee)
sales (map second my-sales-receipts)
total-sales (reduce + sales)]
(+ (* total-sales commission-rate) base-pay)))關鍵 Clojure 細節:
defn-:宣告為私有函式(僅本檔可用)(get m k):從 mapm取出鍵k的值[_ hourly-rate] (:pay-class employee):解構(destructure)pay-class,忽略第一個元素
來源依賴反轉#
注意:這些 defmethod 寫在 payroll 函式之下——但 Clojure 不允許往下呼叫尚未宣告的函式。
這意味著
payroll並不靜態依賴defmethod的實作——而是實作在執行期向defmulti註冊自己。我們可以把實作搬到另一個檔案
payroll-implementation.clj,由它(:require)上層payroll——形成來源依賴反轉。

Figure 9.3: 來源依賴反轉
三層架構#
把實作獨立成檔案後,可以畫出明顯的架構邊界——高階政策與低階細節之間隔開來:

Figure 9.4: 架構邊界
進一步把 defmulti(連同支援函式)獨立到 payroll-interface namespace:
payroll:高階政策payroll-interface:多型介面(defmulti 們)payroll-implementation:低階實作(defmethod 們)

Figure 9.5: 加入介面的架構
這已經很像 Java 的 Payroll、PayrollInterface、PayrollImplementation 三類了——從架構觀點來看,幾乎一致。
我們甚至可以把實作再切成 PaySchedule、PayClassification、PayDisposition 三個 namespace,讓不同方法住在不同檔案——這在 Java/C# 不可能做到,因為它們不允許「介面的不同方法在不同模組實作」。

Figure 9.6: 拆分後的架構
注意:這些是 namespace 而非 class。不能
new它們,它們也不代表 OO 意義下的物件。
namespace 與檔案#
Clojure 的 namespace 與 source file 緊密綁定:
- 每個 namespace 必須住在自己的檔案中
- 檔名必須與 namespace 對應
因此把 namespace 想成「class 的近親」並不是壞事。
Clojure 的危險誘惑是:沒有 OO 那種強制把函式分進 class 的紀律,函式就容易被胡亂塞進 namespace,最後讓檔案結構變得脆弱。
寫函式式程式時,繼續沿用 OO 的切分紀律,不會吃虧。
結論#
- 形態不同:函式式程式像水管系統(資料流變換);OO 程式像逐步迭代
- 架構卻相通:函式式程式的函式可以切分成跟 OO 程式一樣具備架構意義的元素
- 物件依然存在:在 Clojure 解法中,
employee、paycheck-directive、pay-class、disposition仍然是物件——只是它們與「操作它們的函式」的綁定比 OO 弱
弱綁定到底是優點還是缺點?接下來幾章會持續探討。
但隨著我們越深入設計與架構議題,會發現:函式式程式與「不可變物件導向」的差異,會越來越不重要。