程式 = y = f(x)#
任何程式說到底都是 y = f(x):x 是全部輸入,y 是全部輸出。對批次任務(batch job)這個定義已足夠:
- 薪資系統:輸入
x是員工資料與打卡記錄,輸出y是薪水與報表
但對於互動式應用,輸入往往依賴於程式上一次的輸出。所以可改寫成:
void p(Input x) {
while (x != DONE)
x = getInput(f(x));
}每次迭代當下,x 就是系統的「狀態」(state)。除錯時你會盯著 x 看,並把它稱作 system state。
把 x 變數消掉#
上面這段程式有個顯式的變數 x 在被覆寫。改寫成函式式風格:
void p(Input x) {
if (x != DONE)
p(getInput(f(x)));
}現在沒有任何變數被覆寫——狀態是以參數從一次
p呼叫傳到下一次。
大型互動程式:spacewar 範例#
幾年前 Bob 大叔用 Clojure 寫了一個老遊戲 Spacewar! 的版本(github.com/unclebob/spacewar ↗)。這個遊戲視覺化、互動、跑在 30 fps 的大螢幕上,內部狀態極為複雜——但整體仍以函式式風格寫成。
整個遊戲的核心循環就是:
(defn spacewar [world]
(when (:done? world)
(System/exit 0))
(recur (update-world world (get-input world))))Clojure 的關鍵字(keyword)以冒號為前綴,例如
:done?。當作為函式使用時,關鍵字像是雜湊表的存取器;(:done? world)等同於從world雜湊表中取出:done?欄位。
整個遊戲的所有複雜性(企業號(Enterprise)、克林貢艦(Klingons)、星星、魚雷、爆炸……)都被裝在一個叫 world 的物件裡。
update-world:threading macro 的優雅#
(defn update-world [ms 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)))->> 是 threading macro:把 world 依序餵進每一個函式。
每個被串接的函式都回傳一個新的 world——
world從未被「就地修改」,而是不斷被「重新生成」。整個系統照樣 30 fps 跑得順暢。
ms 參數是上次 update 至今的毫秒數,是整個遊戲對外的主要輸入。
用 clojure.spec 控制狀態結構#
(s/def ::ship (s/keys :req-un
[::x ::y ::warp ::warp-charge
::impulse ::heading ::velocity
::selected-view ::selected-weapon
::antimatter ::core-temp ::dilithium
::shields ::kinetics ::torpedos
::life-support-damage ::hull-damage
::sensor-damage ::impulse-damage
::warp-damage ::weapons-damage
::strat-scale ::destroyed
::corbomite-device-installed]))clojure.spec 提供比多數靜態語言更精確的資料結構描述能力。即使整體採函式式風格,仍可對狀態結構施加嚴格的型別與值範圍約束。
spacewar 的故事告訴我們:沒有任何複雜度高到必須放棄不可變性。狀態的複雜不是問題,問題是「我們是否願意以函式式紀律來組織它」。
必須變動狀態的時刻#
雖然複雜度本身不會逼我們放棄函式式,但外部框架有時會:
- Quil(quil.info ↗,spacewar 用的 GUI 框架):底層其實不是函式式(它包了 Java 的 Processing),但它對外裝得像函式式,使用者不必直接面對可變狀態
- Java Swing(Bob 大叔的 more-speech 應用 [github.com/unclebob/more-speech] 使用):明確的可變物件框架,model-view 結構由 Swing 控制,幾乎不可能維持完全不可變的 model
多執行緒讓問題加倍#
很多框架不只強迫你用可變狀態,還把你拖進多執行緒世界:
- Swing 跑在自己的特殊執行緒上
- 修改 Swing 資料結構必須切到那個執行緒
「在多執行緒下變動狀態」帶來的後果是競態條件(race condition)與並行更新異常——是程式碼裡最難除錯的災難。
軟體交易記憶體(STM)#
幸運的是,函式式語言通常提供工具,讓我們在不得不變動狀態時把傷害降到最低。其中之一是軟體交易記憶體(Software Transactional Memory, STM)。
STM 把記憶體當成可交易、可 commit/rollback 的資料庫。所有變動都被一個 compare-and-swap 協定保護,不會被其他執行緒打斷。
鎖的問題:致命擁抱(deadly embrace)#
傳統做法是上鎖(locking):
- 函式
f變動o之前,先鎖住o - 其他執行緒想動
o必須等
但兩個鎖一起出現就可能死鎖:
f(o, p)鎖住o後,被g(p, o)中斷g鎖住p,但無法鎖o(被f占用)f醒來嘗試鎖p,卻被g占用- 互相等待,永遠無法繼續——這就是 deadly embrace
要避免:所有人都同意「永遠先鎖 o 再鎖 p」。但這種規約在大型系統難以強制執行。
STM 的解法:不鎖,改用 swap#
STM 的核心是比較並交換(compare-and-swap):
- 把
o當下的值存到oh - 計算
of = f(o) - 在原子(atomic)操作中比較
o與oh:- 相同 → 把
o換成of,成功 - 不同(被別人改過)→ 整個操作從頭重試
- 相同 → 把
重點:STM 不鎖任何東西。當衝突發生時,被衝突的那一方會自動重試,直到成功為止。
Clojure 的 atom#
atom 是 Clojure 中最常見的 STM 工具:
(def counter (atom 0))
(defn add-one [x]
(let [y (inc x)]
(print (str "(" x ")"))
y))
(defn increment [n id]
(dotimes [_ n]
(print id)
(swap! counter add-one)))
(defn -main []
(let [ta (future (increment 10 "a"))
tx (future (increment 10 "x"))
_ @ta
_ @tx]
(println "\nCounter is: " @counter)))執行流程:
future啟動兩條執行緒,分別呼叫increment@ta與@tx阻塞主執行緒等待兩條子執行緒結束add-one內的print故意製造延遲,讓另一條執行緒有機會插入
實際輸出(取一例):
a(0)a(1)a(2)a(3)a(4)xa(5)x(5)(6)(6)x(7)(7)a(8)(8)
x(9)(9)a(10)(10)x(11)a(11)(12)(12)a(13)x(13)(14)(14)
x(15)(15)(16)x(17)x(18)x(19)
Counter is: 20可以看到:
a線程一開始順利累加到 4- 第 5 次時
x跳進來,兩條線開始撞車 - 重複出現的數字來自
swap!偵測到衝突後自動重試 - 最終
Counter is: 20——結果正確
結語#
函式式世界沒有競態條件——沒更新就沒有並行更新問題。但現實中我們常被框架或既有程式拖進多執行緒、可變的世界。
當你被迫離開函式式世界時,STM 提供的機制(如 atom 與 swap!)能把這個糟糕的狀況變得還算可以接受。