練習簡介:離散事件模擬#
物件導向是 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,回傳新的world:world流經一條函式管線,每一步都被微調成稍微不同的形狀。
這指出 Clojure 的切分不是依物件,而是依函式。資料相對未被切割地穿過一個個獨立函式。
結論:何時兩種風格差異最大#
- 質因數分解:差異微小
- 保齡球計分:差異略大
- 閒聊巴士司機:差異顯著
差異變大的原因有二:
- 程式規模較大(約是前兩題的兩倍)
- 它是真正的有限狀態機(finite state machine)——根據事件與當前狀態演進
對於主要做計算(如質因數分解)的程式,OO 與函式式幾乎沒有差別。對於「狀態變動主要來自陣列索引這種瑣事」的程式,差別也很小。
但只要程式的主軸是逐刻變化的狀態,兩種風格就會展現出深刻差異:
- OO 風格:把狀態藏進可變物件、透過方法操作 → 切分依資料內聚
- 函式式風格:把狀態外置成不可變資料結構、流經函式管線 → 切分依行為內聚
哪一種比較好?這個問題留到後續章節回答。