練習簡介:離散事件模擬#

物件導向是 1966 年由 Ole-Johan Dahl 與 Kristen Nygaard 為了讓 ALGOL-60 適合**離散事件模擬(discrete event simulation)**而誕生的——他們的成果是 SIMULA-67,史上第一個真正的 OO 語言。

傳說 Dahl 與 Nygaard 當時模擬的是挪威的海運。

本章選一個離散事件模擬作業:Gossiping Bus Drivers kata 。題目應該對 OO 風格相當友善——畢竟離散事件模擬是 OO 的「主場」。

規則#

  • 共有 n 個司機,每人擁有自己循環的路線(一串站名)
  • 每個 tick 所有人前進一站
  • 若兩個(含以上)司機同時抵達同一站,他們交換彼此知道的全部謠言
  • 模擬上限 480 個 tick,超過視為「永遠無法擴散」

範例:

  • Bob 知道謠言 X,路線 [p, q, r]
  • Jim 知道謠言 Y,路線 [s, t, u, p]
  • 從 t=0 開始,t=3 兩人會在 p 站相遇——這時謠言交換

Java 版本:傳統 OO 設計#

物件模型#

從一張簡單的物件圖開始——沒有繼承也沒有多型:

  • Simulator 持有多個 Driver
  • 每個 Driver 擁有一個 Route、一群 Rumor
  • 每個 Route 由多個 Stop 組成
  • 每個 Stop 持有當下停在那裡的多個 Driver

Figure 8.1: Java 版本的簡單物件模型

測試一覽#

12 個測試集中於一個檔案:

public class GossipTest {
  private Stop stop1, stop2, stop3;
  private Route route1, route2;
  private Rumor rumor1, rumor2, rumor3;
  private Driver driver1, driver2;

  @Before
  public void setUp() {
    stop1 = new Stop("stop1");
    stop2 = new Stop("stop2");
    stop3 = new Stop("stop3");
    route1 = new Route(stop1, stop2);
    route2 = new Route(stop1, stop2, stop3);
    rumor1 = new Rumor("Rumor1");
    rumor2 = new Rumor("Rumor2");
    rumor3 = new Rumor("Rumor3");
    driver1 = new Driver("Driver1", route1, rumor1);
    driver2 = new Driver("Driver2", route2, rumor2, rumor3);
  }
  // 12 個 @Test 略
}

主要實作#

Driver 維護自己的 stopNumber 與 rumors:

public class Driver {
  private String name;
  private Route route;
  private int stopNumber = 0;
  private Set<Rumor> rumors;

  public Driver(String name, Route theRoute, Rumor... theRumors) {
    this.name = name;
    route = theRoute;
    rumors = new HashSet<>(Arrays.asList(theRumors));
    route.stopAt(this, stopNumber);
  }

  public Stop getStop() { return route.get(stopNumber); }

  public void drive() {
    route.leave(this, stopNumber);
    stopNumber = route.getNextStop(stopNumber);
    route.stopAt(this, stopNumber);
  }

  public Set<Rumor> getRumors() { return rumors; }
  public void addRumors(Set<Rumor> newRumors) { rumors.addAll(newRumors); }
}

Route 是循環的站序,Stop 是當下站著的司機集合:

public class Route {
  private Stop[] stops;
  public int getNextStop(int stopNumber) { return (stopNumber + 1) % stops.length; }
  public void stopAt(Driver d, int n)  { stops[n].addDriver(d); }
  public void leave(Driver d, int n)   { stops[n].removeDriver(d); }
}

public class Stop {
  private List<Driver> drivers = new ArrayList<>();

  public void gossip() {
    Set<Rumor> rumorsAtStop = new HashSet<>();
    for (Driver d : drivers) rumorsAtStop.addAll(d.getRumors());
    for (Driver d : drivers) d.addRumors(rumorsAtStop);
  }
}

模擬主迴圈:

public class Simulation {
  public static int driveTillEqual(Driver... drivers) {
    int time;
    for (time = 0; notAllRumors(drivers) && time < 480; time++)
      driveAndGossip(drivers);
    return time;
  }

  private static void driveAndGossip(Driver[] drivers) {
    Set<Stop> stops = new HashSet<>();
    for (Driver d : drivers) {
      d.drive();
      stops.add(d.getStop());
    }
    for (Stop stop : stops) stop.gossip();
  }
}

這是相當典型的 OO 寫法:物件封裝自己的狀態、彼此以方法互動。整個解法分成 5 個檔案,共 145 行。

Clojure 版本:以函式管線為主軸#

撰寫 Clojure 版本時沒有事先畫物件設計圖——測試直接驅動了結構:

(describe "gossiping bus drivers"
  (it "drives one bus at one stop"
    (let [driver (make-driver "d1" [:s1] #{:r1})
          world [driver]
          new-world (drive world)]
      (should= 1 (count new-world))
      (should= :s1 (-> new-world first :route first))))

  (it "merges rumors"
    (should= [{:name "d1" :rumors #{:r2 :r1}}
              {:name "d2" :rumors #{:r2 :r1}}]
             (merge-rumors [{:name "d1" :rumors #{:r1}}
                            {:name "d2" :rumors #{:r2}}])))

  (it "passes acceptance test 1"
    (let [world [(make-driver "d1" [3 1 2 3] #{1})
                 (make-driver "d2" [3 2 3 1] #{2})
                 (make-driver "d3" [4 2 3 4 5] #{3})]]
      (should= 6 (drive-till-all-rumors-spread world))))

  (it "passes acceptance test 2"
    (let [world [(make-driver "d1" [2 1 2] #{1})
                 (make-driver "d2" [5 2 8] #{2})]]
      (should= :never (drive-till-all-rumors-spread world)))))

#{...} 是 Clojure 的集合(set)字面量。

8 個測試對 Java 的 12 個——較少是因為結構切割不同。Clojure 測試也較「鬆」:例如 merges rumors 測試傳入的 driver 並非完整結構,只滿足 merge-rumors 需要的欄位即可。

完整實作(42 行)#

(ns gossiping-bus-drivers-clojure.core
  (:require [clojure.set :as set]))

(defn make-driver [name route rumors]
  (assoc {} :name name :route (cycle route) :rumors rumors))

(defn move-driver [driver]
  (update driver :route rest))

(defn move-drivers [world]
  (map move-driver world))

(defn get-stops [world]
  (loop [world world
         stops {}]
    (if (empty? world)
      stops
      (let [driver (first world)
            stop (first (:route driver))
            stops (update stops stop conj driver)]
        (recur (rest world) stops)))))

(defn merge-rumors [drivers]
  (let [rumors (map :rumors drivers)
        all-rumors (apply set/union rumors)]
    (map #(assoc % :rumors all-rumors) drivers)))

(defn spread-rumors [world]
  (let [stops-with-drivers (get-stops world)
        drivers-by-stop (vals stops-with-drivers)]
    (flatten (map merge-rumors drivers-by-stop))))

(defn drive [world]
  (-> world move-drivers spread-rumors))

(defn drive-till-all-rumors-spread [world]
  (loop [world (drive world)
         time 1]
    (cond
      (> time 480) :never
      (apply = (map :rumors world)) time
      :else (recur (drive world) (inc time)))))

關鍵 Clojure 函式速查#

  • assoc:在 map 中加上元素,(assoc {} :a 1) 得到 {:a 1}
  • cycle:回傳惰性、永遠循環的序列;(cycle [1 2 3]) 得到 [1 2 3 1 2 3 ...]
  • update:以函式更新 map 的某個鍵;(update {:x 1} :x inc) 得到 {:x 2}
  • set/union:集合聯集
  • vals / keys:map 的所有值/所有鍵
  • flatten:把巢狀串列攤平;(flatten [[1 2] [3 4]]) 得到 [1 2 3 4]

對照觀察#

觀察點Java 版本Clojure 版本
行數145 行 / 5 檔42 行 / 1 檔
物件結構Driver、Route、Stop、Rumor、Simulation 各自封裝都坐落在 Driver map 內,Route/Stop/Rumor 都不是獨立實體
狀態可變、封裝在物件內不可變、放在外部資料結構
切分依據資料內聚(data cohesion)行為內聚(behavioral cohesion)
drive 的角色Driver.drive() + Stop.gossip() 共同協作一條清楚的管線:world → move-drivers → spread-rumors

Clojure 版本的 Driver 並不是傳統 OO 意義下的物件——它沒有方法。整支程式的 8 個函式中,6 個只接收 world 作為參數。要硬說的話,唯一的「物件」是 world

world 也不可變——drive 接收一個 world,回傳新的 worldworld 流經一條函式管線,每一步都被微調成稍微不同的形狀。

這指出 Clojure 的切分不是依物件,而是依函式。資料相對未被切割地穿過一個個獨立函式。

結論:何時兩種風格差異最大#

  • 質因數分解:差異微小
  • 保齡球計分:差異略大
  • 閒聊巴士司機:差異顯著

差異變大的原因有二:

  • 程式規模較大(約是前兩題的兩倍)
  • 它是真正的有限狀態機(finite state machine)——根據事件與當前狀態演進

對於主要做計算(如質因數分解)的程式,OO 與函式式幾乎沒有差別。對於「狀態變動主要來自陣列索引這種瑣事」的程式,差別也很小。

但只要程式的主軸是逐刻變化的狀態,兩種風格就會展現出深刻差異:

  • OO 風格:把狀態藏進可變物件、透過方法操作 → 切分依資料內聚
  • 函式式風格:把狀態外置成不可變資料結構、流經函式管線 → 切分依行為內聚

哪一種比較好?這個問題留到後續章節回答。