題目來源#
Wa-Tor 是 A. K. Dewdney 在 1984 年 12 月《Scientific American》介紹的一個簡單細胞自動機,題材是一個典型的「捕食者/獵物」模擬:
- 魚與鯊魚生活在沒有陸地、純水的世界
- 魚:隨機移動、偶爾繁殖
- 鯊魚:隨機移動;遇到相鄰的魚就吃;吃夠了會繁殖;長期不吃會餓死
- 世界的左右、上下相連——拓樸上是一個環面(torus),所以名字是 WAter TORus
完整參考:Wikipedia: Wa-Tor ↗。本章故意把它當成超大型企業應用來練習,不為了寫得快,而是為了把架構與設計的肌肉操起來。
從 SRP 與架構邊界開始#
兩個 actor:
- UX 設計師:會反覆改畫面
- 建模者(modelers):會調整魚/鯊魚行為,未來可能加新的動物
第一版分區(partitioning):
WatorUI與WatorModel。WatorUI是低階(離 I/O 較近),原始碼相依必須從 UI 指向 Model——UI 是 Model 的插件。

Figure 17.1: Wa-Tor 顯而易見的傳統分區
物件模型(沒錯,物件模型)#
Clojure 是函式式語言,但本書讓我們學到一件事:函式式設計與 OO 設計是同一枚硬幣的兩面。
第一版物件圖:
world含有許多cellcell為抽象,每個cell在每個 tick 處理一次時間cell的子型別:water、animalanimal的子型別:fish、sharkanimal可move與reproduce;shark還可以eat

Figure 17.2: Wa-Tor 初始物件模型
Dewdney 把 tick 稱為 chronon(時間量子)。
(ns wator.cell)
(defmulti tick ::type)
(ns wator.water (:require [wator.cell :as cell]))
(defn make [] {::cell/type ::water})
(defmethod cell/tick ::water [water] ...)
(ns wator.animal)
(defmulti move ::type)
(defmulti reproduce ::type)
(defn tick [animal] ...)
(ns wator.fish (:require [wator.cell :as cell]
[wator.animal :as animal]))
(defmethod cell/tick ::fish [fish] (animal/tick fish))第一個行為:water 演化成 fish#
建模者說:water cell 給夠時間就會隨機演化成 fish。
(defmethod cell/tick ::water [water]
(if (> (rand) config/water-evolution-rate)
(fish/make)
water))
(def water-evolution-rate 0.99999)
tick的回傳值是新的 cell——這正是函式式風格。
(rand)讓這個程式並非「純函式式」,但作者選擇接受這份不純。
把魚放進世界並讓它移動#
world 用 map 儲存(key 是 [x y]、value 是 cell):
(defn make [w h]
(let [locs (for [x (range w) y (range h)] [x y])
loc-water (interleave locs (repeat (water/make)))
cells (apply hash-map loc-water)]
{::cells cells
::bounds [w h]}))讓魚移動的測試:
(it "moves"
(let [fish (fish/make)
world (-> (world/make 3 3)
(world/set-cell [1 1] fish))
[loc cell] (animal/move fish [1 1] world)]
(should= cell fish)
(should (#{[0 0] [0 1] [0 2]
[1 0] [1 2]
[2 0] [2 1] [2 2]} loc))))寫測試時做出的設計決策#
TDD 的最後一個 D 常常是 Design——光是寫這個測試就逼出三個重要決策。
animal不知道世界:要嘛每隻動物持有world引用、要嘛把world設成全域 atom、要嘛把world當參數傳入。作者選最後一種,避免落回 STManimal不知道自己的位置:所以位置loc也得當參數傳入move不能修改world:考慮 update 順序——若魚從[0 0]移到[0 1],下一輪輪到[0 1],魚會被再 tick 一次。所以move只回傳[新位置 動物],由 world 自己重組整個新世界
do-move 的實作#
(defn do-move [animal loc world]
(let [neighbors (world/neighbors world loc)
destinations (filter
#(water/is? (world/get-cell world %))
neighbors)
new-location (rand-nth destinations)]
[new-location animal]))torus 數學則包進 world 內,避免外洩到 animal 上:
(defn wrap [world [x y]]
(let [[w h] (::bounds world)]
[(mod x w) (mod y h)]))
(defn neighbors [world loc]
(let [[x y] loc
neighbors (for [dx (range -1 2) dy (range -1 2)]
(wrap world [(+ x dx) (+ y dy)]))]
(remove #(= loc %) neighbors)))第一個依賴循環#
上面的程式不能編譯:
water依賴fish(演化)fish依賴animal(do-move)animal依賴water(檢查可去的格子)形成依賴循環——Clojure 痛恨依賴循環。

Figure 17.3: 一個依賴循環
在真實的小應用中作者會直接寫進一個檔案、不分割。但這裡我們在演練「假裝是企業級應用」的紀律——必須把循環拆開。
解法:宣告與實作分開(DIP)#
把 water 拆成 water 與 water-imp、fish 拆成 fish 與 fish-imp:
water:宣告類型與is?water-imp:實作cell/tick,依賴fish與cellfish:宣告makefish-imp:實作cell/tick與animal/move

Figure 17.4: 打破依賴循環
關鍵:實作模組依賴宣告模組,而不是反過來——這就是 DIP,把循環的方向反轉掉。
(ns wator.water (:require [wator.cell :as cell]))
(defn make [] {::cell/type ::water})
(defn is? [cell] (= ::water (::cell/type cell)))(ns wator.water-imp
(:require [wator.cell :as cell]
[wator.water :as water]
[wator.fish :as fish]
[wator.config :as config]))
(defmethod cell/tick ::water/water [water]
(if (> (rand) config/water-evolution-rate)
(fish/make)
water))切割原則:任何引用「直系型別之外」的函式都搬到
*-imp。namespaced keyword(::water/water)讓defmethod仍然在原來的型別下分派。
為 world 加上 spec#
(s/def ::location (s/tuple int? int?))
(s/def ::cell #(contains? % ::cell/type))
(s/def ::cells (s/map-of ::location ::cell))
(s/def ::bounds ::location)
(s/def ::world (s/keys :req [::cells ::bounds]))
(defn make [w h]
{:post [(s/valid? ::world %)]}
...)繁殖#
建模者說:fish 達到一定年齡且鄰居有 water 時就會繁殖;兩隻女兒的年齡歸零;否則年齡逐 tick 增加。
(defn do-reproduce [animal loc world]
(if (>= (age animal) config/fish-reproduction-age)
(let [neighbors (world/neighbors world loc)
birth-places (filter #(water/is? (world/get-cell world %))
neighbors)]
(if (empty? birth-places)
nil
[loc (set-age animal 0)
(rand-nth birth-places) (make-child animal)]))
nil))失敗時回傳
nil,因為高階政策很可能寫成:(if-let [result (animal/reproduce ...)] result (animal/move ...))
抓住自己的「癢處」:先寫 world/tick#
作者突然意識到:許多
move/reproduce的回傳格式決定都是基於對world/tick的猜想。直覺已經在報警——應該先寫world/tick,免得作出錯誤決定。
第一版測試:
(it "moves a fish around each tick"
(let [fish (fish/make)
small-world (-> (world/make 1 2)
(world/set-cell [0 0] fish)
(world/tick))
vacated-cell (world/get-cell small-world [0 0])
occupied-cell (world/get-cell small-world [0 1])]
(should (water/is? vacated-cell))
(should (fish/is? occupied-cell))
(should= 1 (animal/age occupied-cell))))第一個假實作:
(defn tick [world]
(-> (make 2 1)
(set-cell [0 0] (water/make))
(set-cell [0 1] (animal/set-age (fish/make) 1))))又是循環依賴!
world依賴fish,fish依賴animal,animal依賴world。解法一樣:拆出
world-imp。world只放介面(含defmulti tick);world-imp放實作(含defmethod)。

Figure 17.5: 打破又一次依賴循環
Showers Solve Problems:用 Factory Method#
但 world-imp 還是依賴 water——「在 world 裡建 water」這件事讓作者覺得「icky」。畢竟前面才剛因為「在 world 建 fish」拆過一次。
洗澡時想到的解法:Factory Method。讓
world自己當 factory,接受不透明 token(:default-cell、:fish、:water、:shark),回傳對應的 cell:
(defmulti tick ::type)
(defmulti make-cell (fn [factory-type cell-type] factory-type))
(defn make [w h]
(let [locs (for [x (range w) y (range h)] [x y])
default-cell (make-cell ::world :default-cell)
...]))(defmethod world/make-cell ::world/world [world cell-type]
(condp = cell-type
:default-cell (water/make)
:water (water/make)
:fish (fish/make)
:shark (shark/make)))此時自然冒出一條架構邊界:所有相依方向都指向高階端,符合 Dependency Rule。

Figure 17.6: 套用 Factory Method 模式後的 Wa-Tor
tick 的雙細胞回傳格式#
把單一格子的 fish 移動測試擴展到四種 1×2 場景後,做了又一次設計變更:
cell/tick、animal/move、animal/reproduce統一回傳[from to],每一個都是{loc cell}格式的單一鍵值 map。
world/tick 用 loop 遍歷所有格子、累積新世界,並維護 moved-into 集合避免「動物被 tick 兩次」:
(defmethod world/tick ::world/world [world]
(let [cells (::world/cells world)]
(loop [locs (keys cells)
new-cells {}
moved-into #{}]
(cond
(empty? locs)
(assoc world ::world/cells new-cells)
(contains? moved-into (first locs))
(recur (rest locs) new-cells moved-into)
:else
(let [loc (first locs)
cell (get cells loc)
[from to] (cell/tick cell loc world)
new-cells (-> new-cells (merge from) (merge to))
to-loc (first (keys to))]
(recur (rest locs) new-cells (conj moved-into to-loc)))))))處理「兩隻魚搶同一格」#
(it "move two fish who compete for the same spot"
...)兩隻魚分別在 [0 0] 與 [2 0],只有一個格子在中間。當前的 animal/move 不知道別隻已先移動到目標格——測試失敗。
解法:把
moved-into集合塞進world裡傳下去(作者稱之為「搭便車的 tramp data」)。do-move從world讀出後,先把已被佔用的鄰居過濾掉。
(defn do-move [animal loc world]
(let [neighbors (world/neighbors world loc)
moved-into (get world :moved-into #{})
available-neighbors (remove moved-into neighbors)
destinations (filter #(water/is? (world/get-cell world %))
available-neighbors)
...]))
:moved-into沒有用 namespaced keyword,因為它不算world的正式欄位——這是現實工程中的取捨。
讓世界塞滿魚#
(it "fills the world with reproducing fish"
(loop [world (-> (world/make 10 10)
(world/set-cell [5 5] (fish/make)))
n 100]
(if (zero? n)
(let [cells (-> world ::world/cells vals)
fishies (filter fish/is? cells)
fish-count (count fishies)]
(should (< 50 fish-count)))
(recur (world/tick world) (dec n)))))只要在 animal/tick 裡加上 reproduce 路徑:
(defn tick [animal loc world]
(let [aged-animal (increment-age animal)
reproduction (reproduce aged-animal loc world)]
(if reproduction
reproduction
(move aged-animal loc world))))10×10 的世界、100 個 tick 後超過 50 隻魚——通過。
鯊魚#
魚與鯊魚的 move、reproduce 行為幾乎相同,差別在「繁殖年齡」。用 Template Method 處理:
(defmulti get-reproduction-age ::cell/type)
(defmethod animal/get-reproduction-age ::fish [_]
config/fish-reproduction-age)
(defmethod animal/get-reproduction-age ::shark [_]
config/shark-reproduction-age)shark 的特殊行為:health#
建模者說:
- 鯊魚有
:health,初始為 starting-health - 每 tick 減一
- 吃魚增加
shark-eating-health - 健康值到 0 → 餓死,留下 water
- 健康值高於門檻才能繁殖;繁殖時兩隻女兒平分父母 health
(defmethod cell/tick ::shark [shark loc world]
(if (= 1 (health shark))
[nil {loc (water/make)}]
(let [aged-shark (-> shark
(animal/increment-age)
(decrement-health))]
(if-let [reproduction (animal/reproduce aged-shark loc world)]
reproduction
(if-let [eaten (eat aged-shark loc world)]
eaten
(animal/move aged-shark loc world))))))鯊魚的處理順序:先試繁殖、不行就試吃、最後才移動。原本想用
animal/tick的延遲呼叫無法表達這個順序,所以鯊魚乾脆完全自己處理cell/tick。
吃魚的實作:
(defn eat [shark loc world]
(let [neighbors (world/neighbors world loc)
fishy-neighbors (filter #(fish/is? (world/get-cell world %))
neighbors)]
(if (empty? fishy-neighbors)
nil
[{loc (water/make)}
{(rand-nth fishy-neighbors) (feed shark)}])))套上 GUI#
(ns wator-gui.main
(:require [quil.core :as q]
[quil.middleware :as m]
[wator.world :as world]
[wator.water :as water]
[wator.fish :as fish]
[wator.shark :as shark]
[wator.world-imp]
[wator.water-imp]
[wator.fish-imp]))
(defn setup []
(q/frame-rate 60)
(q/color-mode :rgb)
(-> (world/make 80 80)
(world/set-cell [40 40] (fish/make))))
(defn update-state [world]
(world/tick world))
(defn draw-state [world]
(q/background 240)
(let [cells (::world/cells world)]
(doseq [loc (keys cells)]
(let [[x y] loc
cell (get cells loc)
color (cond
(water/is? cell) [255 255 255]
(fish/is? cell) [0 0 255]
(shark/is? cell) [255 0 0])]
(q/no-stroke)
(apply q/fill color)
(q/rect (* 12 x) (* 12 y) 11 11)))))注意:GUI 依賴 model,但 model 完全不知 GUI 存在——這達成了一開始的架構目標。

Figure 17.7: Wa-Tor 進行中的截圖(藍色為魚、紅色為鯊魚)
結論#
Wa-Tor 是一個**「函式式」且「物件導向」**的程式:
- 用了多個 GOF 設計模式(Factory Method、Template Method 等)
- OO 切分讓程式自然分群、各種資料型別與相關行為都有合適的所在
- 但本質上是資料流模型——
world流經各種函式而從不被修改這不是 Frankenstein。這就是軟體本來該長的樣子:
- 資料封裝且不可變
- 行為與其操作的資料就近綁在一起
- 但資料流經行為,而不是行為對資料迭代