若是你接觸過函數式編程,你極可能遇到過 Monad 這個奇怪的名詞。因爲各類神奇的緣由,Monad 成了一個很難懂的概念。Douglas Crockford 曾轉述過這樣一句話來形容 Monad:javascript
Once you understand Monad, you lose the ability to explain it to someone else.html
這篇文章中,我會從使用場景出發來一步步推演出 Monad。而後,我會進一步展現一些 Monad 的使用場景,並解釋一些我從 Haskell 翻譯成 JS 的 ADT (Algebraic Data Type)。最後,我會介紹 Monad 在範疇論中的意義,並簡單介紹下範疇論。java
假設你被一個奇怪的叢林部落抓住了,部落長老知道你是程序員,要你寫個應用,寫出來就放你走。做爲一個資深碼農,你暗自竊喜,內心想着老夫經歷了這麼多年產品經理各類變態需求的千錘百煉,沒什麼需求能難倒我!長老彷佛看出了你的心思,加了一個要求:這個應用只能用純函數寫,不能有狀態機,不能有反作用!而後你崩潰了……react
再假設你不知道函數式編程,但你足夠聰明,你可能會發明出一個函數來知足這個奇葩的要求。這個函數如此強大,你可能會叫它超級函數,但其實它無可避免就是一個 Monad。git
接下來咱們就來一步步推演出這個超級函數吧。程序員
函數組合你們都應該很是熟悉。好比,Redux 裏面在組合中間件的時候會用到一個 compose
函數 compose(middleware1, middleware2)
。函數組合的意思就是,在若干個函數中,依順序把前一個函數執行的結果傳個下一個函數,逐次執行完。compose
函數的簡單實現以下:github
const compose = (...fns) => fns.reduce((f, g) => (...args) => f(g(...args)))
複製代碼
函數組合是個很強大的思想。咱們能夠利用它把複雜問題拆解成簡單問題,把這些簡單問題逐個解決了以後,再把這些解決方案組合起來,就造成了最終的解決方案。編程
這裏偷個懶再舉一下我以前文章的例子吧:後端
// 在產品列表中找到相應產品,提取出價格,再把價格格式化
const formalizeData = compose(formatCurrency, pluckPrice, findProduct);
formalizeData(products)
複製代碼
若是你理解了上面的代碼,那麼恭喜你,你已經懂了 Monoid!數組
所謂 Monoid 能夠簡單定義以下:
同時,這個二元運算要知足這些條件:
注意,上面這個定義是集合論中的定義,這裏還沒涉及到範疇論。
函數要能組合,類型簽名必須一致。若是前一個函數返回一個數字,後一個函數接受的是字符串,那麼是沒辦法組合的。因此,compose
函數接受的函數都符合以下函數簽名:fn :: a -> a 也就是說函數接受的參數和返回的值類型同樣。知足這些類型簽名的函數就組成了 Monoid,而這個 Monoid 中的特殊元素就是 identity
函數:const identity = x => x;
結合律和單元律的證實比較簡單,我就不演示了。
上面演示的函數組合看起來很舒服,可是實際用處還不是很大。由於 compose
接受的函數都是純函數,只適合用來計算。而現實世界沒有那麼純潔,咱們要處理 IO,邏輯分支,異常捕獲,狀態管理等等。單靠簡單的純函數組合是不行的。
先假設咱們有兩個純函數:
const addOne = x => x + 1
const multiplyByTwo = x => 2 * x
複製代碼
理想狀態下是咱們能夠組合這兩個函數:
compose(
addOne,
multiplyByTwo
)(2) // => 5
複製代碼
可是咱們出於各類緣由要執行一些反作用。這裏僅爲了演示,就簡單化了。假設上面兩個函數在返回值以前還向控制檯打印了內容:
const impureAddOne = x => {
console.log('add one!')
return x + 1
}
const impureMultiplyByTwo = x => {
console.log('multiply by two!')
return 2 * x
}
複製代碼
如今這兩個函數再也不純潔了,咱們看不順眼了。怎樣讓他們恢復純潔?很簡單,做弊偷個懶:
const lazyImpureAddOne = x => () => {
console.log('add one!')
return x + 1
}
// Java 代碼看多了以後我也學會取長變量名了^_^
const lazyImpureMultiplyByTwo = x => () => {
console.log('multiply by two!')
return 2 * x
}
複製代碼
修改以後的函數,提供一樣的參數,每次執行他們都返回一樣的函數,能夠作到引用透明。這就叫純潔啊!
而後咱們能夠這樣組合這兩個偷懶函數:
composeImpure = (f, g) => x => () => f(g(x)())()
const computation = composeImpure(lazyImpureAddOne, lazyImpureMultiplyByTwo)(8)
computation() // => multiply by two!add one! 17
複製代碼
在執行 computation
以前,咱們都在寫純函數。
我知道,我知道,上面的寫法可讀性不好。這樣子寫也不可維護。咱們來寫個工具函數方便咱們組合這些不純潔的函數:
const Effect = f => ({
map: g => Effect(x => g(f(x))),
runWith: x => f(x),
})
Effect.of = value => Effect(() => value)
複製代碼
這個 Effect
函數接受一個非純函數 f 爲參數,返回一個對象。這個對象裏面的 map
方法把自身接受的非純回調函數 g 和 Effect
的非純回調函數組合後,將結果再塞回給 Effect
。因爲 map
返回的也是對象,咱們須要一個方法把最終的計算結果取出來,這就是 runWith
的做用。
用 Effect
重現咱們上一步的計算以下:
Effect(impureAddOne)
.map(impureMultiplyByTwo)
.runWith(2) // => add one!multiply by two! 6
複製代碼
如今咱們就能夠直接用非純函數了,不用再用那麼難讀的函數調用了。在執行 runWith
以前,程序都是純的,任你怎麼組合和 map
。
若是你懂了上面的代碼,那麼恭喜你,你已經懂了 Functor!
一樣,Functor 還要知足一些條件:
你能夠把 Functor 理解成一個映射函數,它把一個類型裏的值映射到同一個類型的其它值。好比數組操做 [1, 2, 3].map(String) // -> ['1', '2', '3'], 映射以後數據類型同樣(仍是數組),內部結構不變。我在以前的文章中說數組就是個 Functor,這種表述是有誤的,應該是說數組知足 Functor 的返回值條件。
上面的 Effect 函數把非純操做都放進了一個容器裏面,這樣子作了以後,若是要對兩個獨立非純操做的結果進行運算,就會很麻煩。
好比,咱們在 window 全局讀取兩個值 x, y, 並將讀取結果求和。我知道這個例子很簡單,不用函數式編程很容易作到,我只是在舉簡單例子方便理解。
假設 window 對象已經存在兩個值 {x: 1, y: 2, ...otherProps}。咱們這樣取:
const win = Effect.of(window)
const xFromWindow = win.map(g => g.x)
const yFromWindow = win.map(g => g.y)
複製代碼
xFromWindow
和 yFromWindow
返回的都是一個 Effect 容器,咱們須要給這個容器新添加一個方法,以便將兩個容器裏層的值進行計算。
const Effect = f => ({
map: g => Effect(x => g(f(x))),
runWith: x => f(x),
ap: other => Effect(x => other.map(f(x)).runWith()),
})
複製代碼
而後,咱們提供一個相加函數 add:
const add = x => y => x + y
複製代碼
接下來藉助這個 ap 函數,咱們能夠進行計算了:
xFromWindow
.map(add)
.ap(yFromWindow)
.runWith() // => 3
複製代碼
因爲這種先 map 再 ap 的操做很廣泛,咱們能夠抽象出一個工具函數 liftA2:
const liftA2 = (f, m1, m2) => m1.map(f).ap(m2)
複製代碼
而後能夠簡化點寫了:
liftA2(add, xFromWindow, yFromWindow).runWith() // => 3;
複製代碼
注意運算函數必須是柯里化函數。
新增 ap 方法以後的 Effect 函數除了是 Functor,仍是 Applicative Functor。這部分徹底看代碼還不是很好懂。若是你不理解上面的代碼,沒有關係,它並不影響你理解 Monad。另外,不用糾結於本文代碼裏的具體實現。不一樣的 Applicative 的 ap 方法實現都不同,能夠多看幾個。Applicative 是介於 Functor 和 Monad 之間的數據類型,不提它就不完整了。
Applicative 要知足下面這些條件:
假設咱們要從 window 全局讀取配置信息,此配置信息提供目標 DOM 節點的類名 userEl
;根據這個類名,咱們定位到 DOM 節點,取出內容,而後打印到控制檯。啊,讀取全局對象,讀取 DOM,控制檯輸出,全是做用,好可怕…… 咱們先用以前定義的 Effect
試試看行不行:
// DOM 讀取和控制檯打印的行爲放進 Effect
const $ = s => Effect(() => document.querySelector(s))
const log = s => Effect(() => console.log(s))
Effect.of(window)
.map(win => win.userEl)
.map($)
.runWith() //因爲上一個 map 裏層也返回了 Effect,這裏須要抹平一層
.map(e => e.innerHTML)
.map(log)
.runWith()
.runWith()
複製代碼
勉強能作到,可是這樣子先 map
再 runWith
實在太繁瑣了,咱們能夠再給 Effect
新增一個方法 chain:
const Effect = f => ({
map: g => Effect(x => g(f(x))),
runWith: x => f(x),
ap: other => Effect(x => other.map(f(x)).runWith()),
chain: g =>
Effect(f)
.map(g)
.runWith(),
})
複製代碼
而後這樣組合:
Effect.of(window)
.map(win => win.userEl)
.chain($)
.map(e => e.innerHTML)
.chain(log)
.runWith();
複製代碼
線上 Demo 見這裏
Voila! 咱們發現了 Monad!
在寫上面的代碼的時候我仍是以爲逐行解釋代碼比較繁瑣。咱們先無論代碼具體實現,從函數簽名開始看 Monad 是怎麼回事。
讓咱們回到 Monoid。咱們知道函數組合的前提條件是類型簽名一致。fn :: a -> a. 但在寫應用時,咱們會讓函數除了返回值以外還幹其餘事。這裏無論具體幹了哪些事,咱們能夠把這些行爲扔到一個黑盒子裏(好比剛剛寫的 Effect),而後函數簽名就成了 fn :: a -> m a。m 指的是黑盒子的類型,m a 意思是黑盒子裏的 a. 這樣操做以後,Monoid 接口再也不知足,函數不能簡單組合。
但咱們仍是要組合。
其實很簡單,在組合以前把黑盒子裏的值提高一層就好了。最終咱們實現的組合實際上是這樣:fn :: m a -> (a -> m b) -> m b. 這個簽名裏,函數 fn 接受黑盒子裏的 a 爲參數,再接受一個函數爲參數,這個函數的入參類型是 a,返回類型是黑盒子裏的 b。最終,外層函數返回的類型是黑盒子裏的 b。這個就是 chain 函數的類型簽名。
fn :: a -> m a 簽名裏面的箭頭叫 Kleisli Arrow,其實就是一種特殊的函數。Kleisli 箭頭的組合叫 Kleisli Composition,這也是 Ramda 裏面 composeK 函數的來源。這裏先了解一下,等下還會用到這個概念。
Monad 要知足的一些定律以下:
不少人誤解 JS 裏面的 Promise 就是個 Monad,我以前也有這樣的誤解,但後來想明白了。按照上面的定律來看檢查 Promise:
Left identity:
Promise.resolve(a).then(f) === f(a)
複製代碼
看起來知足。可是若是 a 是個 Promise 呢?要處理 Promise,那 f 應該符合符合這個函數的類型簽名:
const f = p => p.then(n => n * 2)
複製代碼
來試一下:
const a = Promise.resolve(1)
const output = Promise.resolve(a).then(f)
// output :: RejectedPromise TypeError: p.then is not a function
複製代碼
報錯的緣由是,a 在傳給 f 以前,就已經被 resolve 掉了。
Right identity:
p.then(x => Promise.resolve(x)) === p
複製代碼
知足。
Associativity:
p.then(f).then(g) === p.then(x => f(x).then(g))
複製代碼
和左單元律同樣,只有當 f 和 g 接受的參數不爲 Promise,上面才成立。
因此,Monad 的三個條件,Promise 只符合一條。
上面演示的 Effect 函數,和我以前文章《不完整解釋 Monad 有什麼用》 裏面演示的 IO 函數是同一個 ADT,它是用來處理程序中的做用的。函數式編程中還有不少不一樣用處的 ADT,好比,處理異步的 Future,處理狀態管理的 State,處理依賴注入的 Reader 等。關於爲何這個 Monad 是代數數據類型,Monad 和你們熟知的代數有什麼關係,這裏不展開了,有興趣進一步瞭解的話能夠參考 Category Theory for Programmers 這本書。
這裏再展現兩個 ADT,Reader 和 State,比較它們 chain 和 ap 的不一樣實現,對比 Monadic bind 函數類型簽名 chain :: m a -> (a -> m b) -> m b,思考下它們是怎樣實現 Monad 的。
const Reader = computation => {
const map = f => Reader(ctx => f(computation(ctx)))
const contramap = f => Reader(ctx => computation(f(ctx)))
const ap = other => Reader(ctx => computation(ctx)(other.runWith(ctx)))
const chain = f => {
return Reader(ctx => {
const a = computation(ctx)
return f(a).runWith(ctx)
})
}
const runWith = computation
return Object.freeze({
map,
contramap,
ap,
chain,
runWith,
})
}
Reader.of = x => Reader(() => x)
複製代碼
題外話補充下,上面這種叫「冰凍工廠」的工廠函數寫法,是我我的偏好。這樣寫會有必定性能和內存消耗問題。用 Class 性能更好,看你選擇。
程序中可能會遇到某個函數對外部環境有依賴。用純函數的寫法,咱們能夠把這個依賴同時傳進函數。這樣子,函數簽名就是 fn :: (a, e) -> b。e 表明外部環境。這個簽名不符合咱們前面提到的 a -> m b. 咱們到如今還只提到了一次函數柯里化,這個時候再一次要用柯里化了。柯里化後,有依賴的函數類型簽名是 fn :: a -> (e, b), 你可能認出來了,中間那個箭頭就是 Kleisli Arrow。
假設咱們有一段程序的多個模塊依賴了共同的外部環境。要作到引用透明,咱們必須把這個環境傳進函數。可是每個模塊若是都接受外部環境爲多餘參數,那這些模塊是沒辦法組合的。Reader 幫咱們解決這個問題。
來寫個簡單程序,執行這個程序時輸出「你好,xx ... 再見,xx」。xx 由執行時的參數決定。
const concat = x => y => y.concat.call(y, x)
const greet = greeting => Reader(name => `${greeting}, ${name}`)
const addFarewell = farewell => str =>
Reader(name => `${str}${farewell}, ${name}`)
const buildSentence = greet('你好')
.map(concat('...'))
.chain(addFarewell('再見'))
buildSentence.runWith('張三')
// => 你好, 張三...再見, 張三
複製代碼
上面這個例子過於簡單。輸出一個字符串用一個函數就行,用不了解構和組合。可是,咱們能夠很容易擴展想象,若是 greet
和 addFarewell
是很複雜的模塊,必須拆分,此時組合的價值就出現了。
在學習 Reader 時,我發現一篇很不錯的文章。這篇文章大開腦洞,用 Reader 實現 React 裏面的 Context。有興趣能夠了解下。The Reader monad and read-only context
// 這個寫法你可能不習慣。
// 這是 K Combinator,Ramda 裏面對應函數是 always, Haskell 裏面是 const
const K = x => y => x
const State = computation => {
const map = f =>
State(state => {
const prev = computation(state)
return { value: f(prev.value), state: prev.state }
})
const ap = other =>
State(state => {
const prev = computation(state)
const fn = prev.value
return other.map(fn).runWith(prev.state)
})
const chain = fn =>
State(state => {
const prev = computation(state)
const next = fn(prev.value)
return next.runWith(prev.state)
})
const runWith = computation
const evalWith = initState => computation(initState).value
const execWith = initState => computation(initState).state
return Object.freeze({
map,
ap,
chain,
evalWith,
runWith,
execWith,
})
}
const modify = f => State(state => ({ value: undefined, state: f(state) }))
State.get = (f = x => x) => State(state => ({ value: f(state), state }))
State.modify = modify
State.put = state => modify(K(state))
State.of = value => State(state => ({ value, state }))
複製代碼
State 裏層最終返回的值由對象構成,對象裏面包含了此時計算結果,以及當前的應用狀態。
再舉個簡單的例子。假設咱們根據某狀態數字進行計算,首先咱們在這個初始狀態上加某個數字,而後咱們把狀態 + 1, 再把新的狀態和前一步的計算相乘,算出最終結果。一樣,例子很簡單,但已經包含了狀態管理的核心。來看代碼:
const add = x => y => x + y
const inc = add(1)
const addBy = n => State.get(add(n))
const multiplyBy = a => State.get(b => b * a)
const incState = n => State.modify(inc).map(K(n))
addBy(10)
.chain(incState)
.chain(multiplyBy)
.runWith(2) // => {value: 36, state: 3}
複製代碼
上面最後一步組合,每一個函數類型簽名一致,a -> m b, 構成 kleisli 組合,咱們還能夠用工具函數改進一下寫法:
const composeK = (...fns) =>
fns.reduce((f, g) => (...args) => g(...args).chain(f))
const calculate = composeK(
multiplyBy,
incState,
addBy
)
calculate(10).runWith(2) // => {value: 36, state: 3}
複製代碼
Monad 有一個「臭名昭著」的定義,是這樣:
A monad is just a monoid in the category of endofunctors, what's the problem?
我見過這句話的中文翻譯。可是這種「鬼話」無論翻不翻譯都差很少的表達效果,我以爲仍是不用翻譯了。不少人看到這句話不去查出處和上下文,就以此爲據來批評 FP 社區故弄玄虛,我感到很無奈。
這句話出自這篇文章 Brief, Incomplete and Mostly Wrong History of Programming Languages. 這篇文章用戲謔調侃的方式把全部主流編程語言黑了一個遍。上面那句話是用來黑 Haskell 的。原本是句玩笑,結果就以訛傳訛了。
上面那句話的原始出處是範疇論的奠定之做 Categories for the Working Mathematician 原話更拗口:
All told, a monad in X is just a monoid in the category of endofunctors of X, with product × replaced by composition of endofunctors and unit set by the identity endofunctor.
注意書名,那是給數學家看的,不是給程序員看的。你看不懂很正常,看不懂還要罵這些學術泰斗裝逼就是你的不對了。
首先,說明下我數學學得差,我接下來要講的名詞我知道是在研究什麼,再深刻細節我就不知道了。
你們知道數學有不少分支,好比集合論,邏輯學,類型論(Type Theory) 等等。後來,有些數學家發現,若是用足夠抽象的概念工具去考察這些分支,其實他們都在講一樣的東西。橋接這些概念的工具是 isomorphism (同構)。isomorphic 就是在對象之間能夠來回轉換,每次轉換沒有信息丟失。好比,在邏輯學裏面研究的某個問題,可能和類型論裏面研究是同一個問題,只要二者之間能造成 isomorphism。
統一數學各分支的理論就是範疇論。範疇論須要足夠抽象,避免細節,才能在相差巨大的各數學分支之間發現同構。這也是爲何範疇論必須要用一些生僻的希臘詞根合成詞。由於它實在太抽象了,很難找到現有的詞彙去對應它裏面的一些概念。混用詞彙確定會致使誤解。
再後來,FP 祖師爺之一 Haskell Curry,和另外一個數學家一塊兒發現了 Curry–Howard Isomorphism。這個理論證實了 proofs as programs, 就是說寫電腦程序(固然是函數式)和寫邏輯證實是一回事,二者造成同構。再後來,這個理論被擴展了一下,成了 Curry–Howard-Lambek Isomorphism, 就是說邏輯學,程序函數,和範疇論,三者之間造成同構。
看了上面的理論背景,你應該明白了爲何函數式編程要從範疇論裏面獲取理論資源。
範疇實際上是很簡單的一個概念。範疇由一堆(這個量詞好難翻譯,我見過 a bunch, a collection, 可是不能說 a set)對象,以及對象之間的關係構成。我分兩部分介紹。
對象 (Object): 範疇論裏面的對象和編程裏面的對象是兩回事。範疇中的對象沒有屬性,沒有結構,你能夠把它理解爲不可描述的點。
箭頭 (arrow, morphism, 兩個詞說的是同一個東西, 我後面就用箭頭了): 鏈接對象,表示對象之間的關係。一樣,箭頭也是一個沒有結構沒有屬性的一種 primitive。它只說明瞭對象之間存在關係,並不能說明是什麼關係。
對象和箭頭要構成一個範疇,還要知足這兩個條件:
能夠看出範疇論的起點真的很是簡單。很難想象基於這麼簡單的概念能構建出一個完整的數學理論。
我一開始試着在範疇論中來解釋 Monad,以失敗了結。要介紹的拗口名詞太多了,一篇文章根本講不完。因此本文會折中一下,仍是用集合論的視角來解釋一下範疇論概念。(範疇論的單個對象能夠對應成一個集合,可是範疇論禁止談論集合元素,全部關於對象的知識都由箭頭和組合推理出來,因此很頭疼。)
勘誤:如下內容是我在倉促學了範疇論知識後想固然的推斷,不夠準確,請參考知乎評論區討論。目前我暫無精力重學重寫,見諒。
還記得咱們是用集合來定義 Monoid 的吧?Monoid 其實就是一個只有一個對象的範疇。範疇和範疇之間的映射叫 Functor。若是一個 Functor 把範疇映射回自身,那麼這個 Functor 就叫 Endofunctor。Functor 和 Functor 之間的映射叫 Natural Transformation. 函數式編程其實只處理一個範疇,就是數據類型(Types)。因此,咱們前面提到的 Functor 也是 Endofunctor。
回到前面 Monad 中 chain 的類型簽名:
chain :: m a -> (a -> m b) -> m b
能夠看出 Monad 是把一個類型映射回自身(m a -> m b),那麼它就是一個 Endofunctor。
再看看 Monad 中所運用的 Natural Transformation。仍是看 chain 的簽名,前半部分 m a -> (a -> m b) 執行以後,類型簽名是 m (m b), 而後再和後面的連起來,就是 m (m b) -> m b. 這其實就是把一個 functor (m (m b)) 映射到另外一個 Functor (m b)。m (m b) -> m b 看起來是否是很眼熟?一個 Functor 和本身組合,造成同一個範疇裏的 Functor,這種組合就是 Monoid 啊!咱們一開始定義的 Monoid 中的二元運算,在 Monad 中其實就是 Natural Transformation。
那麼,再回到這一部分開始時的定義:
A monad is just a monoid in the category of endofunctors.
有沒有好理解一點?
這篇文章的目的不是鼓勵你在你的代碼中消滅狀態機,消滅反作用,我本身都作不到的。我司後端是用 Java 寫的,若是我告訴後端同事 「Yo,你的程序裏不能出現狀態機哦……」,怕是會被哄出辦公室的。那麼,爲何要了解這些知識?
計算機科學中有兩條截然相反的路徑。一條是自下而上,從底層指令開始往上抽象(優先考慮性能),逐漸靠近數學。好比,一開始的 Unix 操做系統是用匯編寫的,後來發現用匯編寫程序太痛苦了,須要一些抽象,因此出現了高級語言 C,再後來因爲各類編寫應用的需求,出現了更高級的語言如 Python 和 JavaScript。另外一條路徑是自上而下的,直接從數學開始(Lambda 演算),不考慮性能和硬件情況,按需逐漸減小抽象。前一條路徑明顯佔了主流,表明語言是 Fortran, C, C++, Pascal, 和 Java 等。後面一條路徑不夠實用,比較小衆,表明語言是 Algo, LISP 和 Haskell 等。
這兩個陣營確定是有爭論的。前者想勸後者從良:你別扔給我這麼多函數,我無法不影響性能狀況下處理那麼多垃圾回收和函數調用!後者也想叫醒前者:不要過早深刻硬件細節,你會把本身鎖定在沒法逆轉的設計錯誤上!二者分道揚鑣了 60 多年,這些年總算開始融合了。好比,新出現的程序語言如 Scala,Kotlin,甚至系統編程語言 Rust,都大量借鑑了函數式編程的思想。
學些高階抽象還能幫助你更容易理解一些看起來很複雜的概念。轉述一個例子。C++ 編程裏面最高的抽象是模板元編程(Template Meta Programming),聽說很難懂。可是據 Bartosz Milewski 的解釋,之因此這個概念難懂,是由於 C++ 的語言設計不適合表達這些抽象。若是你會 Haskell,就會發現其實一行代碼就完成了。
本文還發表在 Lambda Academy
參考: