題目來源#

Wa-Tor 是 A. K. Dewdney 在 1984 年 12 月《Scientific American》介紹的一個簡單細胞自動機,題材是一個典型的「捕食者/獵物」模擬:

  • 魚與鯊魚生活在沒有陸地、純水的世界
  • :隨機移動、偶爾繁殖
  • 鯊魚:隨機移動;遇到相鄰的魚就吃;吃夠了會繁殖;長期不吃會餓死
  • 世界的左右、上下相連——拓樸上是一個環面(torus),所以名字是 WAter TORus

完整參考:Wikipedia: Wa-Tor 。本章故意把它當成超大型企業應用來練習,不為了寫得快,而是為了把架構與設計的肌肉操起來。

從 SRP 與架構邊界開始#

兩個 actor:

  • UX 設計師:會反覆改畫面
  • 建模者(modelers):會調整魚/鯊魚行為,未來可能加新的動物

第一版分區(partitioning):WatorUIWatorModelWatorUI 是低階(離 I/O 較近),原始碼相依必須從 UI 指向 Model——UI 是 Model 的插件。

Figure 17.1: Wa-Tor 顯而易見的傳統分區

物件模型(沒錯,物件模型)#

Clojure 是函式式語言,但本書讓我們學到一件事:函式式設計與 OO 設計是同一枚硬幣的兩面

第一版物件圖:

  • world 含有許多 cell
  • cell 為抽象,每個 cell 在每個 tick 處理一次時間
  • cell 的子型別:wateranimal
  • animal 的子型別:fishshark
  • animalmovereproduceshark 還可以 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 當參數傳入。作者選最後一種,避免落回 STM
  • animal 不知道自己的位置:所以位置 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 拆成 waterwater-impfish 拆成 fishfish-imp

  • water:宣告類型與 is?
  • water-imp:實作 cell/tick,依賴 fishcell
  • fish:宣告 make
  • fish-imp:實作 cell/tickanimal/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 依賴 fishfish 依賴 animalanimal 依賴 world

解法一樣:拆出 world-impworld 只放介面(含 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/tickanimal/moveanimal/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-moveworld 讀出後,先把已被佔用的鄰居過濾掉。

(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 隻魚——通過。

鯊魚#

魚與鯊魚的 movereproduce 行為幾乎相同,差別在「繁殖年齡」。用 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。這就是軟體本來該長的樣子

  • 資料封裝且不可變
  • 行為與其操作的資料就近綁在一起
  • 資料流經行為,而不是行為對資料迭代

完整原始碼:github.com/unclebob/wator