1997-FRP與響應式程式#
案件摘要#
動畫、遊戲、互動程式本質上是「時間驅動」的:值隨時間變化、事件在離散時刻發生。命令式的回呼(callback)模型把這種連續的時間性切成破碎的事件處理函數,程式邏輯散落各處。偵探 Conal Elliott 與 Paul Hudak 於 1997 年在 ICFP 發表 Fran(Functional Reactive Animation),證明 Haskell 的純函數與惰性可以完整表達「隨時間變化的值」:Behavior 是從時間到值的函數,Event 是離散事件串流,兩者都可組合、可積分、可宣告式變換。這樁案子開創了「函數式響應式程式設計」(FRP),並在二十年後內建進所有現代前端框架。
前因 -- 為什麼會有這個案子#
- 動畫與互動程式的核心是「隨時間變化的值」(位置、速度、顏色),命令式模型要求程式設計師手動管理時間迴圈與狀態突變。
- 回呼(callback)讓程式破碎:每個事件一個處理函數,狀態散落在多個閉包之間,難以推理「在某時刻整體長什麼樣」。
- Haskell 的惰性與純函數帶來一個誘人的問題:能否把「隨時間變化的值」表達為一個純函數?
- Elliott 在 Microsoft Research 想用函數式方法做動畫;Hudak 在 Yale 有 Haskell 的一致性設計經驗,兩人合作。
- 同時期互動程式研究的既有方案(Tcl/Tk、事件迴圈)都是命令式的,缺少數學基礎——需要一個可組合的代數理論。
線索與推理 -- 數學式、程式、理論#
線索一:Behavior -- 隨時間變化的值是純函數#
Fran 的第一個關鍵線索:Behavior 就是從時間到值的函數:
位置、顏色、旋轉角度都是 Behavior。因為 Behavior 是函數,所有函數組合子自動升級為 Behavior 組合子——lift2 (+) b1 b2 就是兩個 Behavior 的線性組合:
線索二:Event -- 離散事件串流#
Event 是時間序列:
Behavior 與 Event 可以互相變換:Event 可以「接上」Behavior(在事件時刻切換行為),Behavior 可以取樣成 Event。積分把速度 Behavior 變成位置 Behavior:
線索三:FRP 的代數律#
FRP 不只是型別,還有一組代數律確保組合正確。Behavior 的線性組合律:
積分與微分的互逆律:
這些律讓優化器可以做「宣告式重算」:只重算受事件影響的子圖。
線索四:Elm 的簡化 FRP 與 Elm Architecture#
Evan Czaplicki 在 2012 年的 Elm 語言把 FRP 簡化:用 Signal(離散取樣的 Behavior)統一 Behavior 與 Event,並在 0.17 版之後收斂為「Elm Architecture」:Model -> Msg -> Model 的純函數更新 + 單向資料流。響應式的本質被定義為:對變化自動重算,資料流是宣告式的。
可執行程式碼:Python 實作 mini FRP#
# mini FRP:Behavior(時間取樣)+ Event(事件流)+ map/filter/foldp 組合
class Behavior:
def __init__(self, fn): # fn: Time -> a
self.fn = fn
def at(self, t):
return self.fn(t)
def map(self, g):
return Behavior(lambda t: g(self.fn(t)))
def __add__(self, other): # Behavior 的線性組合
return Behavior(lambda t: self.fn(t) + other.at(t))
def integral(self, dt=0.01): # 積分:速度 -> 位置
def pos(t):
n, s, i = int(t / dt), 0.0, 0.0
while i < t:
s += self.fn(i) * dt
i += dt
return s
return Behavior(pos)
class Event:
"""離散事件流:串列 [(time, value)]"""
def __init__(self, events=None):
self.events = events or []
def map(self, g):
return Event([(t, g(v)) for t, v in self.events])
def filter(self, pred):
return Event([(t, v) for t, v in self.events if pred(v)])
def foldp(self, step, init):
"""foldp:以事件驅動的摺疊(Elm 的 reduce over time)"""
out, acc = [], init
for t, v in self.events:
acc = step(acc, v)
out.append((t, acc))
return Event(out)
# --- 宣告式組合:只描述「是什麼」,不描述「何時重算」 ---
velocity = Behavior(lambda t: 10 * t) # 速度 = 10t
position = velocity.integral() # 位置 = 5t^2(積分自動得證)
print("位置(2s):", position.at(2)) # 20.0
clicks = Event([(0.5, 1), (1.2, 1), (1.8, 1), (2.5, 1)])
doubles = clicks.map(lambda v: v * 2).filter(lambda v: v > 0)
total = doubles.foldp(lambda acc, v: acc + v, 0)
print("累計:", total.events) # [(0.5,2),(1.2,4),(1.8,6),(2.5,8)]
破案時刻:宣告式資料流#
# 同一個 position 行為被多處取樣——自動重算、無回呼、無狀態突變
for t in [0, 1, 2, 3]:
print(f"t={t}: pos={position.at(t)}") # 5t^2:宣告即證明
結案 -- 後果與影響#
- Elm Architecture(2012-2016:Model、update、view 的單向資料流)成為 Redux 與 React 生態的藍本——Redux 作者明言受 Elm 啟發。
- RxJS / ReactiveX(2013 前後)把 Event 串流的思想帶進主流 JavaScript 開發,成為「響應式程式設計」的工業代表。
- SwiftUI(Apple, 2019)與 Jetpack Compose(Google, 2019)的宣告式 UI:UI 是狀態的純函數,FRP 思想內建進所有現代前端框架。
- 「響應式 = 對變化自動重算」從動畫研究的偏門課題,變成前端開發的基本範式。
- 本案證明:純函數可以表達時間性——「隨時間變化的值」不需要可變狀態,只需要把時間當參數。
關鍵人物與文獻#
- Conal Elliott:本案偵探,Fran 的設計者,FRP 的開創者。
- Paul Hudak:Yale 的 Haskell 建設者,Fran 的共同作者,2015 年逝世。
- Evan Czaplicki:Elm 語言的設計者,把 FRP 簡化並推進前端主流。
- Conal Elliott, Paul Hudak, "Functional Reactive Animation", ICFP, 1997.
- Conal Elliott, "Push-Pull Functional Reactive Programming", Haskell Symposium, 2009.
- Antony Courtney, Conal Elliott, "Genuinely Functional User Interfaces", Haskell Workshop, 2001.
- Evan Czaplicki, "Elm: Concurrent FRP for Functional GUIs", senior thesis, Harvard University, 2012.
- Evan Czaplicki, Stephen Chong, "Asynchronous Functional Reactive Programming for GUIs", PLDI, 2013.