上一章遺留的不安#
讀完上一章,你可能感到不安:那些被叫做「物件」的東西其實只是 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這樣的工具找到了極佳的平衡點:
- 想要多少型別檢查就有多少
- 自由指定何時、何處檢查
- 還能描述靜態系統檢查不到的動態約束
對作者而言,這類函式庫提供的是**「兩個世界中各自最好的部分」加總**。