程式 = 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 的故事告訴我們:沒有任何複雜度高到必須放棄不可變性。狀態的複雜不是問題,問題是「我們是否願意以函式式紀律來組織它」。

必須變動狀態的時刻#

雖然複雜度本身不會逼我們放棄函式式,但外部框架有時會:

  • Quilquil.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)

  1. o 當下的值存到 oh
  2. 計算 of = f(o)
  3. 在原子(atomic)操作中比較 ooh
    • 相同 → 把 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!)能把這個糟糕的狀況變得還算可以接受