最近接了幾個大型的 web 開發專案,再加上之前寫 solid.js 的啟發,我對於函數式響應式編程逐漸熟練。函數式響應式編程在大型 web 專案中相當有用。
多練總會有收穫,我算是體會到了。沒想到能在鄙視鏈底端的 web 開發中學到鄙視鏈頂端的 FP 的東西。
Javascript 是函數式編程正統#
最典型的函數式編程語言應該是 Haskell,不過,雖然 Haskell 確實有 servant 框架,但我想應該沒幾個人用 Haskell 寫 web 服務,因為實在是太痛苦了。
不過,確實有適用於 web 開發的函數式編程語言,比如 elixir。這個語言有一個很著名的專案,policr-mini Telegram Bot 就是用這個語言寫的。除此以外,有個類似 Java 但是支持函數式的編程語言,叫做 Scala。根據 GitHub 倉庫 推特的算法就用的是這個語言寫的。
很多人沒有想到 Javascript 這種入門門檻很低,語法極其寬鬆,一般用於沒能力的程序員寫前端的語言,是十分適合使用函數式編程寫大型 Web 服務的。
V8 引擎的性能其實是不低的,只不過對於小型專案,會佔用更多的內存。此外,因為科技進步,現在的開發往往更關注開發成本而不是性能,所以 JS 的性能損失不是那麼重要。
JS 的函數式編程支持#
JS 的類型系統十分特別,看上去就好像沒有類型一樣。這種沒有類型讓人感覺難以維護,為此,發明了 Typescript。不過,換個角度,JS 的類型系統實現了函數式編程的要求:
- 頭等函數:函數可以像其他類型一樣,作為別的函數的參數、返回值,可以被賦值給變量或存儲在數據結構中。顯然,JS 可以做到。
- 閉包:閉包是一個函數以及該函數所捕獲的詞法環境。JS 程序員常常不動腦子地輕鬆寫閉包,以至於可能導致意外的內存泄漏。
- 高階函數:高階函數是接收函數為參數或者返回函數為結果的函數。JS 非常經常用到高階函數,比如各種回調,數組(或者一般對象)各種方法,都是高階函數。
JS 為什麼這麼簡單#
相較於 Rust 這種支持函數式編程的 web 開發常用語言,以及 Haskell 這種支持 web 開發的函數式編程語言,JS 寫起來要簡單得多。主要是,JS 依賴垃圾回收,有typeof
關鍵字,無類型(雖然 TS 有類型,但 TS 的類型都是泛型)。
具體來說,JS 的簡單之處在於:
- 不需要寫類型。就算是 Typescript 中,你也可以用
typeof
關鍵字來提取一個變量的類型並用於類型計算和聲明。 - 不需要考慮閉包變量的生命週期問題。不同於 Rust 那樣的零成本抽象或者 C++ 的手動管理,JS 使用更簡單的垃圾回收機制,所以不需要考慮閉包變量的生命週期,也不需要考慮一個閉包是否能夠重複調用。
- Lambda 表達式隨便寫。就算是對函數式編程一竅不通的人,也能隨手用
.map()
之類的方法,並往裡面塞一個 Lambda 表達式。
函數式編程的基本概念#
函數式編程以函數的使用作為構建軟件系統的主要手段。它與常見的命令式編程(如面向對象編程)有顯著不同。
不同於命令式編程指定機器怎麼做,函數式編程著眼於機器要做什麼。
要入門函數式編程,理解以下基本概念是非常重要的:
- 純函數(Pure Functions):這是函數式編程的核心。純函數指的是輸出只依賴於輸入的函數,沒有隱含的輸入(例如捕獲外部變量),且執行過程中沒有副作用(例如不修改全局狀態,不控制 IO)。純函數十分便於測試。
- 不可變性(Immutability):在函數式編程中,數據是不可變的。這意味著一旦數據被創建,就不能被更改。所有的數據變更都通過創建新的數據結構來實現,而不是更改現有的。這有助於降低程序的複雜性,因為不需要擔心數據在程序的不同部分被意外改變。
- 頭等函數(First-Class Functions):在函數式語言中,函數被視為 “一等公民”,這意味著它們可以像任何其他數據類型一樣被傳遞和操作。你可以將函數作為參數傳遞給其他函數,從函數返回函數,或將它們存儲在數據結構中。
- 高階函數(Higher-Order Functions):接受其他函數作為參數或者將函數作為返回值的函數。這是函數式編程中組合和抽象的強大工具。
- 閉包(Closures):這是指能夠捕獲外部作用域中的變量的函數。閉包使得函數即使在其定義環境之外也能使用那些變量。
- 遞歸(Recursion):由於函數式編程中通常不使用循環結構(如 for 或 while 循環),遞歸成為執行重複任務的主要方法。
- 引用透明性(Referential Transparency):一個表達式在不改變程序的前提下可以被其計算結果所替換,這種特性稱為引用透明性。這意味著函數的調用(例如
add(1,2)
)可以被其輸出(例如3
)替代,而不會對程序的其他部分產生影響。 - 惰性求值(Lazy Evaluation):在函數式編程中,表達式不是在綁定到變量時立即計算,而是在需要其結果時才計算。這可以提高效率,特別是對於大型數據集。
- 模式匹配(Pattern Matching):這是一種檢查數據並根據其結構選擇不同操作的技術。在一些函數式語言中,模式匹配被用來簡化對複雜數據結構的操作。
- 函數組合(Function Composition):在函數式編程中,函數可以通過組合來構建更複雜的操作,即一個函數的輸出直接成為另一個函數的輸入。
- 單子(Monad):這是一個在函數式編程中常見的抽象概念,用於處理副作用、狀態變化等。Monad 提供了一種結構,使得可以順序地應用一系列函數,同時避免了常見的副作用。
多數前端開發都知道用 jest 做測試,但如果不寫純函數,必定會因為副作用而抓耳撓腮不知道怎麼測試。養成寫純函數的習慣是重要的。
Monad#
這個概念值得單獨拿出來說,因為有一定理解難度,而且常用。
Monad 這個概念來自於範疇論,被函數式編程語言用於實現惰性求值和延遲副作用。從數學的角度看,Monad 是自函子範疇中的一個幺半群,其二元運算定義為bind
操作,單位元實現為return
操作(即恆等變換)。
不過,因為眾所周知程序員不學數學,這種話沒幾個人能聽得懂,不如看看這個概念是怎麼提出和應用的。
在函數式編程中,我們強調純函數、沒有副作用。不過,在實際的應用中,這是幾乎不可能的。比如,在 web 開發中,可能需要用到數據庫查詢操作、產生隨機數操作、數據庫寫入操作,而這些都是必須依賴副作用的。
我們希望推遲副作用的執行,利用惰性求值,在求值時再執行這些副作用。實現延遲副作用的就是 Monad。
Promise 是 Monad#
JS 中,有一個天然的 Monad,就是 Promise。
Promise 作為 JavaScript 中的一個原生構造,提供了一種優雅的方式來處理異步操作。它可以被看作是 Monad 的一個實例,因為它滿足 Monad 的兩個基本操作:bind
(Promise 中的.then()
方法)和return
(Promise 中的Promise.resolve()
)。使用 Promise,我們可以鏈式調用異步操作,同時保持代碼的可讀性和可維護性。
比如說你讀取配置文件
const reading = new Promise((resolve, reject) => {
const file = fs.readFileSync('./config.json');
resolve(file);
});
titlePromise = reading.then(file => JSON.parse(file)).then(jsonResult => jsonResult.appName);
title = await titlePromise;
在這裡,fetch
函數返回一個 Promise 對象,它代表了一個未來會完成而現在還沒有完成的異步操作。
通過.then()
方法(bind
),我們可以定義當 Promise 成功時要對數據進行的運算。
在上面的例子中,我們用 monad 將包含副作用的函數(readFileSync
)進行封裝,直到bind
完成時(即jsonResult.appName);
的分號那裡),都沒有執行任何有副作用的操作。直到最後(title = await titlePromise;
),才執行了有副作用的操作。
想像一下,如何調試上面的代碼。對於沒有副作用的部分,我們可以用純函數的調試方法。而對於有副作用的部分,可以單獨調試而無須擔心後續處理邏輯是否有誤。
Monad 的定義#
有了上面的例子,就能更容易理解定義了。現在,來解釋自函子範疇中的一個幺半群這種鬼話到底是什麼意思。
範疇解釋起來挺吃力的,這東西本來就抽象。簡單來說,範疇是一堆點連同點之間的箭頭。這個點是抽象的點,甚至可以是範疇本身。
一個範疇到另一個範疇的映射叫做函子,如果起點和終點都是自己,那就叫做自函子。當抽象的點是自函子時,所構造出的範疇就是自範疇了。
在數學中,幺半群是一種代數結構,包括一組元素、一個二元運算和一個單位元素。這個二元運算必須是封閉的(運算後的結果在範疇內)、結合的((a+b)+c = a+(b+c)
),並且單位元素在這個運算下是中立的(0+n=n
)。
從抽象到實現#
monad 被實現為一個數據結構,包含兩個方法,有時還會有unwrap
方法。
Promise 在 js 和 ts 中是沒有 unwrap 的
type Monad<A> = Promise<A>;
function pure<A>(a: A): Monad<A> {
// 因為 return 是關鍵字,這裡用 pure
return Promise.resolve(a);
}
function bind<A, B>(monad: Monad<A>, func: (a: A) => Monad<B>): Monad<B> {
return monad.then(func);
}
return
(pure
)要求返回內容為參數,類型為 Monad 的數據結構bind
是對值進行計算的純函數(bind
不需要知道有Monad
)
這是一個帶有unwrap
的 Typescript 實現
export default interface Monad<T> {
unwrap(): T;
bind: <U = T> (func: (value: T) => Monad<U>) => Monad<U>;
}
export function pure<T>(initValue: T): Monad<T> {
const value: T = initValue;
return Object.freeze({
unwrap: () => value,
bind: <U = T>(func: (value: T) => Monad<U>) => func(value)
});
}
export const Monad = pure;
響應式編程與函數式響應式編程#
寫過 vue 或者 svelte 的讀者肯定熟悉這玩意。
簡單來說就是,當a
或者b
變化時,a+b
會自動變化。
當我們在使用 vue 開發時,只要一有綁定的數據發生改變,相關的數據及畫面也會跟著變動,而開發者不需要寫關於「如何通知發生變化」的代碼(比如ref.value = newValue
),只需要關注發生變化時要做什麼事,這就是典型的響應式編程。
所以,不難猜到:函數式響應式編程 = 函數式編程 + 響應式編程
函數式響應式編程以 Monad(被觀察者序列)處理數據流。前端有這樣的框架,利用 monad 的惰性求值實現響應式,將狀態以 monad 保存。這個框架叫做 Solid.js.
自己找用這個框架寫的專案,自然就理解了。
菜就多練
邁出第一步#
函數式編程中有 3 種很重要的函數,它們是
-
Map(映射):
- 作用:
map
函數主要用於對集合中的每個元素應用同一個函數,並返回一個新的集合,這個新集合包含了應用函數後的元素。 - 例子:比如你有一個數字列表
[1, 2, 3]
,使用map
函數可以將每個數字乘以 2,結果是[2, 4, 6]
。
- 作用:
-
Reduce(歸約):
- 作用:
reduce
函數通常用於將一個集合中的所有元素通過某種方式合併成一個單一的結果。它會連續地將操作應用到集合的每個元素和累積到目前為止的結果上。 - 例子:如果你有一個數字列表
[1, 2, 3, 4]
,使用reduce
函數可以將它們相加得到 10(1+2+3+4)。
- 作用:
-
Filter(過濾):
- 作用:
filter
函數用於從一個集合中選出符合特定條件的元素,形成一個新的集合。 - 例子:假設你有一個數字列表
[1, 2, 3, 4, 5]
,使用filter
函數可以選出所有大於 2 的數字,結果是[3, 4, 5]
。
- 作用:
這三個函數都是無副作用的,意味著它們不會改變原有的數據集合,而是生成新的集合。這是函數式編程強調 “不可變性” 的一個重要特徵。
在本文寫作時,我正在負責 2 個大型 web 全棧專案,其中一個是為我的組織寫的閉源的程序,不方便透露;而另一個是開源的。
那個函數式響應式編程的專案名字叫做 NodeBoard-Core,旨在成為 v2board 更高性能、更易擴展、更易維護、更便於部署、更安全的上位替代。如果你對這個專案感興趣,請聯繫 [email protected]
求求你們了,來我博客主站逛逛吧,不引流真沒人看。