多型才是 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#

Payrollrun 是純粹的真理:

void run() {
  for (Employee e : db.getEmployees()) {
    if (e.isPayDay()) {
      Pay pay = e.calcPay();
      e.sendPay(pay);
    }
  }
}

對每位員工,若今天是發薪日算薪、送薪——僅此而已。

圖中有三處明顯的 Strategy 模式:isPayDaycalcPaysendPay 各自由不同子類實作。

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):從 map m 取出鍵 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 三類了——從架構觀點來看,幾乎一致

我們甚至可以把實作再切成 PaySchedulePayClassificationPayDisposition 三個 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 解法中,employeepaycheck-directivepay-classdisposition 仍然是物件——只是它們與「操作它們的函式」的綁定比 OO 弱

弱綁定到底是優點還是缺點?接下來幾章會持續探討。

但隨著我們越深入設計與架構議題,會發現:函式式程式與「不可變物件導向」的差異,會越來越不重要