從回撥地獄到自函子上的么半群:解密熟悉又陌生的 Monad

doodlewind發表於2018-01-08

前端領域中許多老生常談的話題背後,其實都蘊含著經典的電腦科學基礎知識。在今天,只要你使用 JS 發起過網路請求,那其實你基本就使用過了函數語言程式設計中的 Monad。這是怎麼一回事呢?讓我們從回撥地獄說起吧……

回撥地獄與 Promise

熟悉 JS 的同學對於回撥函式一定不會陌生,這是這門語言中處理非同步事件最常用的手法。然而正如我們所熟知的那樣,順序處理多個非同步任務的工作流很容易造成回撥的巢狀,使得程式碼難以維護:

$.get(a, (b) => {
  $.get(b, (c) => {
    $.get(c, (d) => {
      console.log(d)
    })
  })
})
複製程式碼

長久以來這個問題一直困擾著廣大 JSer,社群的解決方案也是百花齊放。其中一種已經成為標準的方案叫做 Promise,你可以將非同步回撥包在 Promise 裡,由 Promise.then 方法鏈式組合非同步工作:

const getB = a =>
  new Promise((resolve, reject) => $.get(a, resolve))

const getC = b =>
  new Promise((resolve, reject) => $.get(b, resolve))

const getD = c =>
  new Promise((resolve, reject) => $.get(c, resolve))

getB(a)
  .then(getC)
  .then(getD)
  .then(console.log)
複製程式碼

雖然 ES7 裡已經有了更簡練的 async/await 語法,但 Promise 已經有了非常廣泛的應用。比如,網路請求的新標準 fetch 會將返回內容封裝為 Promise,目前最流行的 Ajax 庫 axios 也是這麼做的。至於一度佔領 70% 網頁的元老基礎庫 jQuery,早在 1.5 版本中就支援了 Promise。這就意味著,只要你在前端發起過網路請求,你基本上就和 Promise 打過交道。而 Promise 本身,就是一種 Monad。

不過,各類對 Promise 的介紹多半集中在它的各種狀態遷移和 API 使用上,這和 Monad 聽起來似乎完全八竿子打不著,這兩個概念之間有什麼聯絡呢?要講清楚這個問題,我們至少得搞懂 Monad 是什麼

冬三雪碧與 Monad

很多本來有興趣學習 Haskell 等函式式語言的同學,都可能被一句名言震懾到打退堂鼓——【Monad 不就是自函子上的么半群嗎,有什麼難以理解的】。其實這句話和白學家說的【冬馬小三,雪菜碧池】沒有什麼差別,不過是一句正確的廢話而已,聽完懂的人還是懂,不懂的人還是不懂。所以如果再有人和你這麼介紹 Monad,請放心地打死他吧——喂等等,誰說冬三雪碧是正確的了!

迴歸正題,Monad 到底是什麼呢?我們大可不必拿出 PLT 或 Haskell 那一套,而是在 JS 的語境裡好好考慮一下這個問題:既然 Promise 在 JS 裡是一個物件,類似地,你也可以把 Monad 當做一個特殊的物件

既然是物件,那麼它的黑魔法也不外乎存在於屬性和方法兩個地方里了。下面我們要回答一個至關重要的問題:Monad 有什麼特殊的屬性和方法,來幫助我們逃離回撥地獄呢?

我們可以用非常簡單的虛擬碼來澄清這個問題。假如我們有 A B C D 四件事要做,那麼基於回撥巢狀,你可以寫出最簡單的函式表示式形如:

A(B(C(D)))
複製程式碼

看到巢狀回撥的噩夢了吧?不過,我們可以抽絲剝繭地簡化這個場景。首先,我們把問題簡化到最普通的回撥巢狀:

A(B)
複製程式碼

基於新增中間層控制反轉的理念,我們只需十幾行程式碼,就能夠實現一個簡單的中間物件 P,把 A 和 B 分開傳給這個物件,從而把回撥拆分開:

P(A).then(B)
複製程式碼

現在,A 被我們包裝了一層,P 這個容器就是 Promise 的雛形了!在筆者的博文 從原始碼看 Promise 概念與實現 中,已經解釋了這樣將回撥巢狀解除的基本機制了,相應的程式碼實現在此不再贅述。

但是,這個解決方案只適用於 A B 兩個函式之間發生巢狀的場景。只要你嘗試去實現過這個版本的 P,你一定會發現,我們現在沒有這種能力:

P(A).then(B).then(C).then(D)
複製程式碼

也沒有這種能力:

P(P(P(A))).then(B)
複製程式碼

這就是 Monad 大展身手的時候了!我們首先給出答案: Monad 物件是這個簡陋版 P 的強化,它的 then 能支援這種巢狀和鏈式呼叫的場景。 當然,正統的 Monad 裡這個 API 不是這個名字,但作為參照,我們可以先看看 Promise/A+ 規範中的一個關鍵細節

在每次 Resolve 一個 Promise 時,我們需要判斷兩種情況:

  1. 如果被 Resolve 的內容仍然是 Promise(即所謂的 thenable),那麼遞迴 Resolve 這個 Promise。
  2. 如果被 Resolve 的內容不是 Promise,那麼根據內容的具體情況(如物件、函式、基本型別等),去 fulfillreject 當前 Promise。

直觀地說,這個細節能夠保證下面兩種呼叫方式完全等效:

// 1
Promise.resolve(1).then(console.log)

// 1
Promise.resolve(
  Promise.resolve(
    Promise.resolve(
      Promise.resolve(1)
    )
  )
).then(console.log)
複製程式碼

這裡的巢狀是否似曾相識?這實際上就是披著 Promise 外衣的 Monad 核心能力:對於一個 P 這樣裝著某種內容的容器,我們能夠遞迴地把容器一層層拆開,直接取出最裡面裝著的值。只要實現了這個能力,通過一些技巧,我們就能夠實現下面這個優雅的鏈式呼叫 API:

Promise(A).then(B).then(C).then(D)
複製程式碼

這更帶來了額外的好處:不管這裡面的 B C D 函式返回的是同步執行的值還是非同步解析的 Promise,我們都能完全一致地處理。比如這個同步的加法:

const add = x => x + 1
Promise
  .resolve(0)
  .then(add)
  .then(add)
  .then(console.log)
// 2
複製程式碼

和這個略顯擰巴的非同步加法:

const add = x =>
  new Promise((resolve, reject) => setTimeout(() => resolve(x + 1), 1000))

Promise
  .resolve(0)
  .then(add)
  .then(add)
  .then(console.log)
// 2
複製程式碼

不分同步與非同步,它們的呼叫方式與最終結果完全一致!

作為一個總結,讓我們看看從回撥地獄到 Promise 的過程中,背後運用了哪些函數語言程式設計中的概念呢?

  • 最簡單的 P(A).then(B) 實現裡,它的 P(A) 相當於 Monad 中的 unit 介面,能夠把任意值包裝到 Monad 容器裡
  • 支援巢狀的 Promise 實現中,它的 then 背後其實是 FP 中的 join 概念,在容器裡還裝著容器的時候,遞迴地把內層容器拆開,返回最底層裝著的值。
  • Promise 的鏈式呼叫背後,其實是 Monad 中的 bind 概念。你可以扁平地串聯一堆 .then(),往裡傳入各種函式,Promise 能夠幫你抹平同步和非同步的差異,把這些函式逐個應用到容器裡的值上

迴歸這節中最原始的問題,Monad 是什麼呢?只要一個物件具備了下面兩個方法,我們就可以認為它是 Monad 了:

  1. 能夠把一個值包裝為容器 - 在 FP 裡面這叫做 unit
  2. 對容器裡裝著的值,能夠把一個函式應用到值上 - 這裡的難點在於,容器裡可能巢狀著容器,因此應用函式到值上的時候需要遞迴。在 FP 裡面這叫做 bind(這和 JS 裡的 bind 完全是兩個概念,請不要混淆了)。

正如我們已經看到的,Promise.resolve() 能夠把任意值包裝到 Promise 裡,而 Promise/A+ 規範裡的 Resolve 演算法則實際上實現了 bind。因此,我們可以認為:Promise 就是一個 Monad。其實這並不是一個新奇的結論,在 Github 上早有人從程式碼角度給出了證明,有興趣的同學可以去感受一下 :-)

作為總結,最後考慮這個問題:我們是怎麼把 Promise 和 Monad 聯絡起來呢?Promise 消除回撥地獄的關鍵在於:

  1. 拆分 A(B)P(A).then(B) 的形式。這其實就是 Monad 用來構建容器的 unit
  2. 不分同步非同步,都能寫 P(A).then(B).then(C)...,這其實是 Monad 裡的 bind

到這裡,我們就能夠從 Promise 的功能來理解 Monad 的作用,並用 Monad 的概念來解釋 Promise 的設計啦 ?

何謂自函子上的么半群

到了這裡,只要你理解了 Promise,那麼你應該就已經可以理解 Monad 了。不過,Monad 傳說中【自函子上的么半群】又是怎麼一回事呢?其實只要你讀到了這裡,你就已經見識過自函子么半群了(這裡的理解未必準確,權當拋磚引玉之用,希望 dalao 指正)。

自函子

函子即所謂的 Functor,是一個能把值裝在裡面,通過傳入函式來變換容器內容的容器:簡化的理解裡,前文中的 Promise.resolve 就相當於這樣的對映,能把任意值裝進 Promise 容器裡。而自函子則是【能把範疇對映到本身】的 Functor,可以對應於 Promise(A).then() 裡仍然返回 Promise 本身。

么半群

么半群即所謂的 Monadic,滿足兩個條件:單位元與結合律。

單位元是這樣的兩個條件:

首先,作用到單位元 unit(a) 上的 f,結果和 f(a) 一致:

const value = 6
const f = x => Promise.resolve(x + 6)

// 下面兩個值相等
const left = Promise.resolve(value).then(f)
const right = f(value)
複製程式碼

其次,作用到非單位元 m 上的 unit,結果還是 m 本身:

const value = 6

// 下面兩個值相等
const left = Promise.resolve(value)
const right = Promise.resolve(value).then(x=> Promise.resolve(x))
複製程式碼

至於結合律則是這樣的條件:(a • b) • c 等於 a • (b • c)

const f = a => Promise.resolve(a * a)
const g = a => Promise.resolve(a - 6)

const m = Promise.resolve(7)

// 下面兩個值相等
const left = m.then(f).then(g)
const right = m.then(x => f(x).then(g))
複製程式碼

上面短短的幾行程式碼,其實就是對【Promise 是 Monad】的一個證明了。到這裡,我們可以發現,日常對接介面編寫 Promise 的時候,我們寫的東西都可以先提升到函數語言程式設計的 Monad 層面,然後用抽象代數和範疇論來解釋,逼格是不是瞬間提高了呢 XD

總結

上面所有的論證都沒有牽扯到 >>== 這樣的 Haskell 內容,我們可以完全用 JS 這樣低門檻的語言來介紹 Monad 是什麼,又有什麼用。某種程度上筆者認同王垠的觀點:函數語言程式設計的門檻被人為地拔高或神話了,明明是實際開發中非常實用且易於理解的東西,卻要使用更難以懂的一套概念去形式化地定義和解釋,這恐怕並不利於優秀工具和理念的普及。

當然了,為了體現逼格,如果下次再有同學問你 Promise 是什麼,請這麼回覆:

Promise 不就是自函子上的么半群嗎,有什麼難以理解的 ?

最後插播廣告:筆者寫這篇文章的動機,是源自實現一個完全 Promise 化的非同步資料轉換輪子 Bumpover 時對 Promise 的一些新理解。有興趣的同學歡迎關注哦 XD

相關文章