上一章遺留的不安#

讀完上一章,你可能感到不安:那些被叫做「物件」的東西其實只是 hash map,完全沒有型別。任何人都能往裡面塞任何東西,沒有任何約束:

  • :pay-class 中的薪資可以塞進字串而非數字
  • :schedule 可以塞進整數而非預期的關鍵字

簡言之,這些物件不是靜態型別的(statically typed)——編譯器不檢查它們,地獄之門可能因此打開。

許多函式式語言(如 Haskell)與 OO 語言(如 Java、C#)都採用靜態型別來防止這個地獄;而 Clojure、Python、Ruby 等則仰賴其他機制

TDD 加上 spec:Clojure 的解法#

實踐 TDD 的開發者通常不太擔心型別地獄——測試會確保物件被正確構造。但在大型系統中,「全部物件之集合」可能極為複雜,這時需要比動態型別語言更正式、完整的型別保證

在 Clojure,作者使用 clojure.spec 達成型別完整性。它讓你自由決定:要在哪裡檢查、檢查多嚴。

Payroll 的 spec 範例#

(s/def ::id string?)
(s/def ::schedule #{:monthly :weekly :biweekly})

(s/def ::salaried-pay-class
  (s/tuple #(= % :salaried) pos?))

(s/def ::hourly-pay-class
  (s/tuple #(= % :hourly) pos?))

(s/def ::commissioned-pay-class
  (s/tuple #(= % :commissioned) pos? pos?))

(s/def ::pay-class
  (s/or :salaried     ::salaried-pay-class
        :Hourly       ::hourly-pay-class
        :Commissioned ::commissioned-pay-class))

(s/def ::mail-disposition
  (s/tuple #(= % :mail) string? string?))

(s/def ::deposit-disposition
  (s/tuple #(= % :deposit) string? string?))

(s/def ::paymaster-disposition
  (s/tuple #(= % :paymaster) string?))

(s/def ::disposition
  (s/or :mail      ::mail-disposition
        :deposit   ::deposit-disposition
        :paymaster ::paymaster-disposition))

(s/def ::employee
  (s/keys :req-un [::id ::schedule ::pay-class ::disposition]))

(s/def ::employees (s/coll-of ::employee))

(s/def ::date string?)
(s/def ::time-card (s/tuple ::date pos?))
(s/def ::time-cards (s/map-of ::id (s/coll-of ::time-card)))

(s/def ::sales-receipt (s/tuple ::date pos?))
(s/def ::sales-receipts (s/map-of ::id (s/coll-of ::sales-receipt)))

(s/def ::db (s/keys :req-un [::employees]
                    :opt-un [::time-cards ::sales-receipts]))

(pos? x)x 為大於 0 的數字時回傳 true

怎麼讀這份 spec#

如果一眼看去覺得嚇人,正常——靜態型別語言要表達同樣多的約束時也是這個體量。

::db 開始往下讀:

  • ::db 是含 :employees(必要)與 :time-cards:sales-receipts(可選)的 map
  • ::employees::employee 的集合
  • ::employee 是 map,必須有 :id:schedule:pay-class:disposition
  • :id 必須是字串
  • :schedule 必須是 :monthly / :weekly / :biweekly 之一
  • :salaried-pay-class 是一個 tuple:第一位是 :salaried,第二位是正數

雙冒號 ::xxx 是 namespace 用的書寫慣例,初次接觸不必糾結。

s/or 的兩兩配對中,第一個元素只是「分支的名字」,方便後續 dispatch,不要被它分神。

用 spec 驗證:在測試與函式邊界#

最常見的兩種使用方式。

用在測試#

(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 (s/valid? ::db db))
    (let [paycheck-directives (payroll today db)]
      (should (s/valid? ::paycheck-directives paycheck-directives))
      (should= [{:type :mail :id "emp1"
                 :name "name" :address "home"
                 :amount 5000}]
               paycheck-directives))))

關鍵函式 s/valid?:當資料符合 spec 時回傳 true。在 payroll 的「入口」與「出口」分別檢查 ::db::paycheck-directives——只要測試覆蓋率夠,型別違反就極少發生。

用在 :pre / :post#

Clojure 函式可在開頭使用 :pre:post 條件,前後檢查資料:

(defn update-world [ms world]
  ;{:pre  [(valid-world? world)]
  ; :post [(valid-world? %)]}
  (->> world
       (game-won ms)
       (game-over ms)
       (ship/update-ship ms)
       (shots/update-shots ms)
       (explosions/update-explosions ms)
       (clouds/update-clouds ms)
       (klingons/update-klingons ms)
       (bases/update-bases ms)
       (romulans/update-romulans ms)
       (view-frame/update-messages ms)
       (add-messages)))

上面 :pre / :post 被註解掉了,但隨時可以「重啟」這道防線——當懷疑出現嚴重型別污染時非常有用。

作者承認:他不喜歡保留註解掉的程式碼,等專案成熟後會清掉這幾行。

結論:靜態 vs. 動態的偽爭論#

關於靜態型別與動態型別的爭論常常情緒化,雙方在自己的擂台上吼,誰也聽不見誰。

兩邊的論點其實都有道理:

  • 動態型別讓程式碼更易寫
  • 靜態型別讓程式碼更安全、更易理解、更內部一致

而像 clojure.spec 這樣的工具找到了極佳的平衡點:

  • 想要多少型別檢查就有多少
  • 自由指定何時、何處檢查
  • 還能描述靜態系統檢查不到的動態約束

對作者而言,這類函式庫提供的是**「兩個世界中各自最好的部分」加總**。