在前一篇談到了函數式編程的概念與實作分離的觀點,那實作上有什麼 FP 的機制 (mechanism) 是我們在一般的編程可以使用的呢?不論是 Neovim 插件開發、前端開發、後端開發等等。
從一段很普通的 FP 程式碼開始吧 (以 Fennel 為例子)
;; 使用 Clojure inspired library nfnl
(local core (require "conjure.nfnl.core"))
;; 定義一個函數 is-even?
(fn is-even? [n]
(= (% n 2) 0))
;; map + filter
(core.filter (fn [y] (is-even? y))
(core.map (fn [x] (+ 1 x)) [1 2 3 4 5]))
;; => [2 4 6]
解說:
core.map
會將 [1 2 3 4 5]
變成 [2 3 4 5 6]
core.filter
會將 [2 3 4 5 6]
的偶數取出,變成 [2 4 6]
在這邊有一個支援 FP 的顯式機制:高階函數;一個隱式機制:值的複制。
最常用的高階函數,自然是 map, filter, reduce
。於是,初學者接下來就會問,那還有其它必學的嗎?像是擔心自己少學了幾招高階函數而寫的程式還不夠 FP 一樣。
對於這個問題,我曾經思考過,是否應該要對大量的 github repo 來做函數的使用頻率統計,藉此歸納出除了 map, filter, reduce
之外,最常用的十種高階函數?考慮常用的高階函數自然會服從於 80/20 分布,有可能只需要掌握最常用的前十種,日後就自然很少遇到自己不會的高階函數。
然而,後來我覺得上述的頻率統計概念理論上可行,實際上,要選哪些 github repo 才會有足夠的代表性,這又是另一個問題。比方說,有一些 github repo 它提供了許多的 Macro ,這種函式庫裡就很有可能會用到 partition
,因為 partition 在寫 Macro 時,特別有用。
最後,我做了一個粗顯的歸納,除了最常用的 map, filter, reduce
,次常用的 6 種高階函數應該是:
仔細看最初的範例的話,考慮不使用 nfnl 函式庫的話,原來的程式應該可以改寫成:
;; 定義一個函數 is-even?
(fn is-even? [n]
(= (% n 2) 0))
(icollect [k v (ipairs [1 2 3 4 5])]
(when (is-even? (+ 1 v))
(+ 1 v)))
個寫法相當於直接迭代原始陣列 [1 2 3 4 5]
,然後在迴圈中對每個元素執行 (+ 1 v)
並檢查是否為偶數。在這種情況下,並沒有建立中間的陣列。相對地,前面的 map + filter 寫法,先用 map 創建了一個新的陣列 [2 3 4 5 6]
,然後 filter 再根據這個新陣列創建了另一個新的陣列 [2 4 6]
。
這就是 FP 在處理容器 (collections) 時,一個非常常見且重要的議題:值的複制。因為 FP 講求不可變性 (immutability),所以當你對一個容器執行操作時,像是 map 或 filter,它並不是在原地修改原始容器,而是創建一個新的容器來存放結果。這確保了函數的純粹性,也避免了許多副作用 (side effects) 的問題。
然而,這種大量的複制可能會帶來效能上的考量,特別是對於大型的資料集。為了應對這個問題,FP 社群發展出不同的策略:
Fennel 作為一個編譯到 Lua 的 Lisp,它大量利用了 Macro 來解決這個問題。Macro 是一種在編譯時期 (compile time) 展開程式碼的工具。你可以把它想像成一個「程式碼生成器」。
舉例來說,Fennel 的 icollect 其實就是一個 Macro。當你寫 (icollect ...)
時,它在編譯時會被展開成一個 Lua 的 for 迴圈。這個迴圈直接操作資料,而不會產生中間的陣列。
這樣的好處是,在語法層次上,你仍然可以享受 FP 語法帶來的優雅和抽象,但在底層,它被轉換為效能更好的命令式 (imperative) 程式碼,大大減少了值的複制。
Clojure 是另一個 FP 的佼佼者,它解決大量複制問題的方案是 Immutable Collection。什麼是 Immutable Collection 呢?
它是一種特殊的資料結構,一旦創建後就不能被修改。如果需要「修改」它,你會得到一個新的 Immutable Collection,但這個新的容器和舊的容器會共享大部分的底層結構。
以 Clojure 的向量 (vector) 為例,它使用了Persistent Vector 的資料結構。它背後的原理是樹狀結構 (tree structure)。當你對一個向量執行 assoc
(新增或修改元素)操作時,它並不會複製整個向量,而是只複製從根節點到需要修改的那個葉節點的路徑。這使得每次「修改」的開銷非常小,大約是 log_32(n),而非 O(n)。
這個設計的優點是:
概括地來說,FP 在處理值複製時,透過 Macro 或特殊的 Immutable Collection 結構,在保持不變性這個核心概念的同時,也有效地解決了潛在的效能問題。這也正是 FP 在現代軟體開發中能夠實用化的重要基礎之一。
FP 有兩大機制在實務上常用且重要:高階函數和值的複制。
高階函數如 map
、filter
和 reduce
幫助我們用簡潔的方式處理資料,而其他如 thread first 等也各有用途。
同時,FP 的不可變性會造成大量的值複制。對值複制問題,我們討論了兩種常見的解決方案:Fennel Macro 在編譯時將 FP 語法轉換為高效的底層程式碼;Clojure 則利用特殊的不可變資料結構來共享底層記憶體,大幅減少複製開銷。