iT邦幫忙

2025 iThome 鐵人賽

DAY 14
0

在前一篇談到了函數式編程的概念與實作分離的觀點,那實作上有什麼 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 種高階函數應該是:

  • concat
  • thread firist (->)
  • some (用於處理 early return from loop 的情境)
  • partition (用於 macro)
  • take
  • unpack

值的複制

仔細看最初的範例的話,考慮不使用 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 社群發展出不同的策略:

  1. 使用 Macro 來消除值的拷貝
  2. 使用 Immutable Collection

使用 Macro 來消除值的拷貝

Fennel 作為一個編譯到 Lua 的 Lisp,它大量利用了 Macro 來解決這個問題。Macro 是一種在編譯時期 (compile time) 展開程式碼的工具。你可以把它想像成一個「程式碼生成器」。

舉例來說,Fennel 的 icollect 其實就是一個 Macro。當你寫 (icollect ...) 時,它在編譯時會被展開成一個 Lua 的 for 迴圈。這個迴圈直接操作資料,而不會產生中間的陣列。

這樣的好處是,在語法層次上,你仍然可以享受 FP 語法帶來的優雅和抽象,但在底層,它被轉換為效能更好的命令式 (imperative) 程式碼,大大減少了值的複制。

使用 Immutable Collection

Clojure 是另一個 FP 的佼佼者,它解決大量複制問題的方案是 Immutable Collection。什麼是 Immutable Collection 呢?

它是一種特殊的資料結構,一旦創建後就不能被修改。如果需要「修改」它,你會得到一個新的 Immutable Collection,但這個新的容器和舊的容器會共享大部分的底層結構。

以 Clojure 的向量 (vector) 為例,它使用了Persistent Vector 的資料結構。它背後的原理是樹狀結構 (tree structure)。當你對一個向量執行 assoc(新增或修改元素)操作時,它並不會複製整個向量,而是只複製從根節點到需要修改的那個葉節點的路徑。這使得每次「修改」的開銷非常小,大約是 log_32(n),而非 O(n)。

這個設計的優點是:

  • 效能優化: 大幅減少了資料複製的開銷,特別是對於大型容器。-
  • 記憶體效率: 舊的容器和新的容器共用大部分的記憶體空間,降低了記憶體的使用量。
  • 保持純粹性: 依然符合 FP 不可變的原則,讓程式碼更安全、更易於推論。

概括地來說,FP 在處理值複製時,透過 Macro 或特殊的 Immutable Collection 結構,在保持不變性這個核心概念的同時,也有效地解決了潛在的效能問題。這也正是 FP 在現代軟體開發中能夠實用化的重要基礎之一。

小結

FP 有兩大機制在實務上常用且重要:高階函數值的複制

高階函數如 mapfilterreduce 幫助我們用簡潔的方式處理資料,而其他如 thread first 等也各有用途。

同時,FP 的不可變性會造成大量的值複制。對值複制問題,我們討論了兩種常見的解決方案:Fennel Macro 在編譯時將 FP 語法轉換為高效的底層程式碼;Clojure 則利用特殊的不可變資料結構來共享底層記憶體,大幅減少複製開銷。


上一篇
深入淺出函數式編程 (FP)—定義的難題
下一篇
深入淺出函數式編程 (FP)—進階議題
系列文
在 Neovim 中探索 Fennel 與函數式編程19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言