函數式編程進階:Monad 與 異步函數的組合

russian dolls

圖片來源: https://unsplash.com/photos/RPLwFFzNvp0javascript

本文做者: 趙祥濤

前面兩篇分別介紹了 FunctorApplicative 的概念和實際應用,並列舉了幾個具體的例子,說明了 Functor 和 Applicative 的實際用途,好比:使用 Either 來處理無處不在的 null 和建立可組合的 try-catch;使用 Applicative 來作高度靈活高度可拓展的表單校驗;相信讀者應該已經緊緊掌握了 Functor 的核心:map-應用一個函數到包裹的值,Applicative的核心:ap-應用一個包裹的函數到一個包裹的值
別忘了以前遺留的幾個問題:html

  • 如何解決嵌套的 try-catch
  • 異步函數的組合
  • Promise 也是一種 Functor ?

三個問題從易到難一個一個的解決,先從第一個:嵌套的 try-catch 開始入手。前端

本篇文章創建在前兩篇的基礎之上,因此建議先閱讀前兩篇的文章,再讀本篇,否則可能會對某些概念和名詞感到困惑

嵌套的 Array

Javascript Array 的 map 方法,相信開發者們都很熟悉,而且幾乎在天天的編程中都會用到,但 Array 原型鏈上的另外一個方法 Array.prototype.flatMap 可能不少人沒怎麼用過,從字面意思上理解就是扁平化的 map,實際做用也確實是的,看一個和 map 作對比的使用案例:java

const arr = [1, 2, 3, 4]
arr.map(x => [x * 2])  // => [[2], [4], [6], [8]]
arr.flatMap(x => [x * 2])  // => [2, 4, 6, 8]

flatMap 相對於 map 的區別是:git

  • map 是把函數執行的結果,放在一塊兒而後裝進 Box 中;
  • flatMap 的結果是把函數執行的結果分別去掉一層「包裝」,而後放在一塊兒裝進 Box 中

因此 flatMap 至關因而先 map (映射)而後 flat (拍平), 僅僅是多了一個「去包裝」的操做!github

俄羅斯套娃

上面介紹了 Array 的一種先 mapflat 的方法,Array 也是 Box 理念的一個具體實現案例,那其餘的 Box 呢?好比前面兩篇一直在用的 Either 又是如何實現的呢?從一個更簡單的函數組合的案例出發,需求是:編寫一個獲取用戶地址的街道名稱函數:編程

const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x)
const address = user => user.address
const street = address => address.street
const app = compose(street, address)
const user = {
 address: {
 street: '長河'
 }
}
const res = app(user) // => 長河

函數組合的理論也很是簡單,只要上一個函數的返回值類型能夠做爲下一個函數的入參就能夠放心大膽的進行組合了。
值得警戒的是,user 對象上面 address 屬性值可能爲 null ,上面的這段代碼若是不作任何防範,那麼 TypeError 的錯誤是可能發生的。這個問題不用擔憂,畢竟以前已經準備好了用來處理 null/undefinedEither 函子,可使用 fromNullable 包裝一下上面代碼:json

const address = user => fromNullable(user).map(u => u.address)
const street = address => fromNullable(address).map(a => a.street)
const app = user =>
 address(user)     // Either(address) 
 .map(street)  // Either(Either(street))
const res = app(user) // => Rirgt(Right('長河'))

審視一下上面的代碼,street 函數返回的是一個 Either ,可是別忘了,map 方法( map: f => Right(f(x)) )會把函數執行的結果從新包裝進「盒子」中,也就是:最終獲得的結果是:Rirgt(Right('長河'))
這很明顯不是咱們想要的結果,咱們只想要被包裹一層的 street ,問題是出現 map 方法上(map 會進行二次包裝),那麼只要使用 fold 方法把 street 函數執行的結果從「盒子」裏「拆包」解放出來便可。數組

const app = user =>
 address(user)                              // Either(address)
 .map(s => street(s).fold(x => x, x => x))   // Either(street)
 .fold(() => 'default street', x => x)       // street
 
const res = app(user)  // => '長河'

毫無疑問,有幾回包裝,就須要幾回「拆包」操做,這樣作邏輯上天然沒有問題。但這豈不是和前端常見的回調地獄問題很相似,這樣的代碼寫下去實在是太難維護和閱讀,總不能寫一行就要數數有幾層包裝吧!
這簡直是代碼版本的俄羅斯套娃:
russian-dolls
出現兩層包裝的緣由是:map 會把函數計算的結果從新包裝進 Box 中,而這一層包裝有點贅餘,由於以後當即進行了拆箱操做,這很是相似於 Array flatmap (先 map 而後 flat )。
由於函數的執行結果已是被包裝好了的,因此只須要一個方法( flatMap )直接執行函數,不作其餘的任何操做promise

const Right = x => ({
 flatMap: f => f(x),
});
const Left = x => ({
 flatMap: f => Left(x),
});
const app = user =>
 address(user)                             // Either(address)
 .flatMap(street)                      // Either(street)
 .flod(() => 'default street',x => x)  // street

mapflatMap 的不一樣點:map 方法接收一個僅僅變換容器內值的函數,因此須要用 Box 從新包裝;可是 flatMap 接收一個返回Box類型的函數,直接調用便可。
mapflatMap 的相同點倒是很是明顯的:都是返回一個 Box 的實例,方便後面繼續鏈式的調用。

flatMap 方法和 flod 方法邏輯同樣?這裏得認可他們確實很相似,可是他們的使用場景卻徹底不一樣! flod 的用途是把一個值從 Box 中解放出來; flatMap 的用途是把一個返回 Box 的函數應用到一個 Box 上,這樣後面能夠繼續保持鏈式的調用。
根據 規範 flatMap 方法後面會改寫爲 chain,在別的語言中,可能也稱爲 bind

既然解決了嵌套的 Either 問題,那麼嵌套的 try-catch ,天然用一樣的理論也能夠迎刃而解了:
舉例來講,若是要從文件系統讀取一個配置文件,而後讀取內容(請注意 fs.readFileSyncJSON.parse 都是可能發生錯誤的,因此會用 try-catch 包裹):

const readConfig = (filepath) => {
 try {
 const str = fs.readFileSync(filepath);
 const config = JSON.parse(str);
 return config.version
 } catch (e) {
 return '0.0.0'
 }
}
const result = readConfig('/config.json');
console.log(result) // => '1.0.0'

如今使用「盒子」理念 + 「chain」 函數重寫上面的代碼爲:

const readConfig = (filepath) =>
 tryCatch(() => fs.readFileSync(filepath))             // Either('')
 .chain(json => tryCatch(() => JSON.parse(json)))  // Either('') 
 .fold(() => '0.0.0', c => c.version)
const result = readConfig('/config.json');
console.log(result) // => '1.0.0'

若是一個 Functor 實現了 chain 方法,那麼咱們能夠稱這個函子爲單子(Monad),不錯單子的概念就是這麼簡單;
若是你去 Google 搜索 Monad ,有無數篇在講解 Monad 的文章,其中最經(戲)典(虐)的一個解釋爲:

「A monad is just a monoid in the category of endofunctors. What’s the problem?」
monad
上面這句話的出處是 brief-incomplete-and-mostly-wrong,徹底是爲了吐槽 Haskell,理論上沒有錯,但更多的是調侃( 該文章極其經典,點明瞭全部主流開發語言的「特點與優勢」,推薦閱讀背誦)。
而 Monad 的準肯定義是:
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. -- Saunders Mac Lane

因此上面這個定義你看懂了嗎?(別打我)看不懂,真的沒有關係,由於那是爲專業的數學學生而準備的,咱們只要掌握 Monad 在編程中能夠理解爲 chainable 的對象,用來解決嵌套的 Box 問題,抓住這個重點已經足夠了。

異步

毫無疑問 異步 是 JavaScript 世界的主流,從按鈕的 onclick 點擊回調,到 AJAX 請求的 onload 回調,再到 Node.js 裏的 readFile 回調,這種根基級的手法都萬變不離其宗,「異步非阻塞」意味着一種以回調函數爲基礎的編程範式。
關於異步和事件循環的理論,能夠參考網易雲音樂團隊的另外一篇文章:聊聊 JavaScript 的併發、異步和事件循環

callback 與 異步

從最簡單的回調函數開始,首先看一典型的 Node.js 風格的 callback :

const getUrl = url => callback => request(url, callback)
const getSomeJSON = getUrl('http://example.com/somedata.json')
getSomeJSON((err, data)=>{
 if(err){
 //do something with err
 }else {
 //do something with data
 }
})

這是一段簡單的異步 HTTP 請求,首先採用柯里化的方式傳入 url ,而後傳入 callback ,這種風格有什麼缺點呢?

  • 1.函數的調用者沒法直接控制請求,必需要把全部的後續操做放在 callback 裏面
  • 2.函數沒法組合,由於 getSomeJSON 調用以後沒有返回任何結果

還有一個關鍵點在於,callback 接收兩個參數,一個錯誤信息,一個成功的數據,這致使咱們不得不在一個函數裏面同時處理錯誤與數據的邏輯。
那麼轉換一下思路,與其傳遞一個接收兩個參數(err & data)的函數,不如傳遞兩個函數(handleError & handleData),每一個接收一個參數

const getUrl = url =>
 (reject, resolve) =>
 request(url, (err, data)=>{
 err? reject(err) : resolve(data)
 })

如今調用 getUrl 以後,咱們能夠繼續傳遞 handleErrorhandleData

const getSomeJSON = getUrl('http://example.com/somedata.json')
const handleError = err => console.error('Error fetching JSON', err)
const handleData = compose(renderData, JSON.parse)
getSomeJSON(handleError, handleData) // 觸發請求

如今徹底分離了 handleDatahandleError 的邏輯,而且 handleData 函數已經能夠按照咱們的指望進行組合了,而(reject, resolve) => {} 函數咱們稱之爲fork,意爲:兩個「分支」。

Task與異步

如今咱們發現了另一個問題,咱們老是須要在 handleData 中進行 JSON.parse 操做,由於把字符串轉換爲 JSON 是任何數據處理邏輯的第一步,若是咱們能把 getSomeJSONJSON.parse 函數組合在一塊兒就行了;如今問題明確了:如何把一個普通的函數和fork函數進行組合?
這個問題看上去很是棘手,不過能夠從簡單的問題開始一步步解決,假設如今有字符串 stringifyJson ,如何轉換爲 JSON 呢,借用前面一章中介紹的LazyBox的概念:

const stringifyJson= '{"a":1}'
LazyBox(() => stringifyJson).map(JSON.parse) // => LazyBox({ a: 1 })

咱們能夠把一個函數包裝進 LazyBox 中,而後經過 map 不斷的進行函數組合,直到最後調用 fold 函數,真正的觸發函數調用;
LazyBox 用來包裹同步的函數,那麼同理對於處理異步邏輯的 fork 函數,也能夠用一個盒子包裝起來,而後 map 普通函數 f ,不也能夠實現函數組合嗎?對於異步的邏輯,能夠稱之爲 Task (任務:未來纔會完成某個目標或者達成某種結果,是否是很好理解)

const Task = fork => ({
 map: f => Task((reject, resolve) =>          // return another Task, including a new fork.
 fork(reject, x => resolve(f(x)))),   // when called,the new fork will run `f` over the value, before calling `resolve`
 fork,
 inspect: () => 'Task(?)'
})

Taskmap 方法,接收一個函數 f ,返回一個新的 Task,關鍵點在:新的 fork 函數會調用上一個 fork ,若是是正確的分支則 resolve 被函數 f 計算事後的結果,若是是失敗的分支,則傳遞 reject

若是以前沒有深刻了解過 Promise 的實現原理,可能這裏比較難以理解,可是請停下來,花點時間思考一下。
如今使用 Task 改寫一下 readConfig 函數:
const readConfig = filepath => Task((reject, resolve) =>
 fs.readFile(filepath, (err, data) =>
 err ? reject(err) : resolve(data)
 ))
const app = readConfig('config.json')
 .map(JSON.parse)
app.fork(() => console.log('something went wrong'), json => console.log('json', json))

Task.mapLazyBoxmap 徹底相似,一直都是在作函數組合的工做,並無進行實際的函數調用,LazyBox 最後經過調用 fold 真正實現函數調用,而 Task 最後經過調用 fork ,實現異步函數的執行。

Task與異步函數的組合

如今經過 Task 實現了一個比較「優雅」的 readConfig 函數,若是要繼續修改配置文件並保存到本地,又該如何處理呢?先從 writeConfig 函數開始吧,徹底仿照 readConfig 函數的寫法:

const app = readConfig(readPath)
 .map(JSON.parse)
 .map(c => ({ version: c.version + 1 }))
 .map(JSON.stringify)
const writeConfig = (filepath, contents) =>
 Task((reject, resolve) => {
 fs.writeFile(filepath, contents, (err, _) =>
 err ? reject(err) : resolve(contents)
 )
 })

那麼怎麼繼續把 writeConfig 應用到 app 上呢,既然 writeConfig 函數返回一個 Task,那麼很明顯須要一個相似 Array.prototype.flatMapEither.chain 函數,幫咱們把這個返回 Task 的函數應用到 app 上:

const Task = fork => ({
 chain: f => Task((reject, resolve) =>                   // return another Task
 fork(reject, x => f(x).fork(reject, resolve)))  // calling `f` with the eventual value
})

相似於 Either 中的 chain 函數,首先會直接調用函數 f (返回TaskB),而後傳入(reject, resolve)調用 TaskBfork 函數去處理後續的邏輯。
如今就能夠流暢的使用 chain 繼續組合 writeConfig 函數了

const app = readConfig(readPath)
 .map(JSON.parse)
 .map(c => ({ version: c.version + 1 }))
 .map(JSON.stringify)
 .chain(c => writeConfig(writeFilepath, c))
app.fork(() => console.log('something went wrong'), 
 () => console.log('read and write config success'))

看到這裏,應該能夠觸類旁通的想到,須要鏈式調用的 HTTP 請求,好比:連續調用兩個接口,第二個接口依賴第一個接口的返回值做爲參數,那麼徹底能夠用 chain 組合兩個異步 HTTP 請求:

const httpGet = content => Task((rej, res) => {
 setTimeout(() => res(content), 2000)
})
const getUser = (id) => httpGet('Melo')
const getAge = name => httpGet(name)
getUser('id')
 .chain(name => getAge(name + ' 18'))
 .fork(console.error, console.log) // => 4000ms later, log: "Melo 18"

Monad VS Promise

Task 的代碼實現,不如以前介紹的 BoxEitherLazyBox 那麼直觀和好理解,可是請仔細思考和理解一下,你會發現 TaskPromise 是很是很是類似的,甚至咱們能夠認爲 Task 就是一個 Lazy-Promise :Promise 是在建立的時候當即開始執行,而 Task 是在調用 fork 以後,纔會開始執行
關於讀取配置文件,修改內容,而後從新保存到本地,我想你們均可以輕鬆的寫出來 Promise 版本的實現,做爲對比展現一下示例代碼:

const readConfig = filepath => new Promise((resolve, reject) =>
 fs.readFile(filepath, (err, data) =>
 err ? reject(err) : resolve(data)
 ))
const writeConfig = (filepath, contents) => new Promise((resolve, reject) => {
 fs.writeFile(filepath, contents, (err, _) =>
 err ? reject(err) : resolve(contents)
 )
})
readConfig(readPath)
 .then(JSON.parse)
 .then(c => ({ version: c.version + 1 }))
 .then(JSON.stringify)
 .then(c => writeConfig(writeFilepath, c))

兩個版本中 readConfigwriteConfig 的實現很是相似,再也不敘述;關鍵的不一樣點在於:Task 版本的組合函數使用的是 mapchain 函數,而 Promise 版本一直使用的都是 then。因此 Promise 看上去和 Monad 很是相似,那麼不由要問,Promise 是否是 Monad 呢?
那麼能夠和最簡單的 Box Monad 作個對比:

const Box = x => ({
 map: f => Box(f(x)),
 chain: f => f(x),
})
const box1 = Box(1)                          // => Box(1)
const promise1 = Promise.resolve(1)          // => Promise(1)
 
box1.map(x => x + 1)                         // => Box(2)
promise1.then(x => x + 1)                    // => Promise(2)
// -----------------
box1.chain(x => Box(x + 1))                 // => Box(2)
promise1.then(x => Promise.resolve(x + 1))  // => Promise(2)

能夠發現,若是函數返回的是沒有被包裹的值,thenmap 的行爲很相似;若是函數返回的是包裹的值,thenchain 很相似,都會去掉一層包裝,從這個角度看 Promise 和Functor/Monad 都很相似,符合他們的數學規則。
下面繼續看:

box1.map(x => Box(x + 1))                   // => Box(Box(2))
promise1.then(x => Promise.resolve(x + 1))  // => Promise(2)
box1.chain(x => x + 1)                      // => 2
promise1.then(x => x + 1)                   // => Promise(2)

若是把一個返回包裹的值的函數,傳遞給 then,不會像 Functor 那樣獲得一個被包裹兩層的值,而是隻有一層;一樣的把一個返回普通值的函數傳遞給 then,咱們依然獲得的是一個Promise,而 chain 的結果是去掉一層包裹,獲得了值。從這個角度看,Promise 同時打破了 Functor 和 Monad 的數學規則。因此嚴格意義來講 Promise 不是一個 Monad,可是不能否認 Promise 的設計確定有很多靈感來自 Monad。

這一小節的內容較爲難理解,主要難在 Task 的實現原理和異步函數的組合,在邏輯上須要很好的數學思惟,但願能多思考一下,必定會有更多的收穫,畢竟咱們用了短短几行代碼,就實現了增強版的 Promise-> Lazy Promise -> Task。
更多的關於 Promise 和 Monad 的對比能夠參考: Javascript: Promises and Monads, difference between promise and task

應用函子與單子

Monad 更擅長處理的是一種擁有 Context(上下文) 的場景,上面的 getUsergetAge 的例子中,getAge 函數必須等到 getUser 函數中的異步執行完成才能開始調用,這是一種縱向(串行)的鏈路;
Applicative更擅長的是處理一種橫向(並行)的鏈路,好比上一章介紹的表單校驗的例子,每一個字段的校驗之間徹底沒有什麼關聯關係。
如今不由要問 Task 能夠實現異步的並行嗎?答案是確定的!假設 getUsergetAge 互不依賴,則徹底能夠採用 Applicative 的 apply 方法來進行組合。

Task
 .of(name => age => ({ name, age }))
 .ap(getUser)
 .ap(getAge)
 .fork(console.error, console.log) // 2000ms later, log: "{name: 'Melo', age: 18}"
Task.ap 能夠參考 Promise.all 的原理,具體實現能夠參考 gist.github

總結

  • Functor 是一種實現 map 方法的數據類型
  • Applicative 是一種實現了 apply 方法的數據類型
  • Monad 是一種實現了 chainflatmap 方法的數據類型

那麼FunctorApplicativeMonad 三個區別是什麼?
functor-applicative-monad

  • Functor: 應用一個函數到包裹的值,使用 map.
  • Applicative: 應用一個包裹的函數到包裹的值,使用 ap
  • Monad: 應用一個返回包裹值的函數到一個包裹的值,使用 chain

參考資料與引用文章:

本文發佈自 網易雲音樂大前端團隊,文章未經受權禁止任何形式的轉載。咱們常年招收前端、iOS、Android,若是你準備換工做,又剛好喜歡雲音樂,那就加入咱們 grp.music-fe(at)corp.netease.com!
相關文章
相關標籤/搜索