圖片來源: unsplash.com/photos/FqYM…javascript
本文做者:趙祥濤html
上一章中介紹了 Functor(函子)
的概念,簡單來講,就是把一個 「value」 填裝進 「Box」 中,繼而可使用 map
方法映射變換 Box 中的值:Box(1).map(x => x+1)
。本章咱們在繼續在 Box
的基礎上繼續擴展其餘更強大的理念,從純函數與反作用的概念及用途做爲承上啓下的開端,繼續鞏固 Functor
的概念以及接下來將要介紹的 Applicative Functor
的引子。前端
函數式編程中純函數是一個及其重要的概念,甚至能夠說是函數組合的基礎。你可能已經聽過相似的言論:「純函數是引用透明( Referential Transparency )的」,「純函數是無反作用( Side Effect )的」,「純函數沒有共享狀態( Shared State )」。下面簡單介紹下純函數。java
在計算機編程中,假如知足下面這兩個條件的約束,一個函數能夠被描述爲一個「純函數」( pure function )node
關於純函數的第一條很簡單,相同的輸入,總會返回相同的輸出,和中學數學中學習的「函數」徹底相似,傳入相同的參數,返回值必定相同,函數自己就是從集合到集合的「映射」。git
第二條不產生可觀察的反作用又是什麼意思呢?也就是函數不能夠和系統的其餘部分通訊。好比:打印日誌,讀寫文件,數據請求,數據存儲等等;github
從代碼編寫者的角度來看,若是一段程序運行以後沒有可觀察到的做用,那他到底運行了沒有?或者運行以後有沒有實現代碼的目的?有可能它只是浪費了幾個 CPU 週期以後就去睡大覺了!數據庫
從 JavaScript 語言的誕生之初就不可避免地須要可以與不斷變化的,共享的,有狀態的 DOM 互相做用;若是沒法輸入輸出任何數據,那麼數據庫有什麼用處呢?若是沒法從網絡請求信息,咱們的頁面又該如何展現?沒有 「side effect」 咱們幾乎步履維艱,反作用不可避免,上述的任何一個操做,都會產生反作用,違反了引用透明性,咱們彷佛陷入了兩難的境地!編程
世間安得雙全法,不負如來不負卿數組
如何在 keep pure
的前提下,又能妥善的處理 side effect
呢?
要想較理想的解決這個問題,咱們把注意力轉回到 JavaScript 的核心 function 上,咱們知道在 JavaScript 裏,函數是「一等公民」,JavaScript 容許開發人員像操做變量同樣操做函數,例如將函數賦值給變量、把函數做爲參數傳遞給其餘函數、函數做爲另外一個函數的返回值,等等...
JavaScript 函數具備值的行爲,也就是說,函數就是一個基於輸入的且還沒有求值的不可變的值,或者能夠認爲函數自己就是一個等待計算的惰性的值。那麼咱們徹底能夠把這個「惰性的值」裝入 Box
中,而後延遲調用便可,仿照上一章的 Box
,能夠實現一個 Lazy Box
:
const LazyBox = g => ({
map: f => LazyBox(() => f(g())),
fold: f => f(g())
})
複製代碼
注意觀察,map 函數所作的一直都是在組合函數,函數並無被實際的調用;而調用 fold 函數纔會真正的執行函數調用,看例子:
const finalPrice = str =>
LazyBox(() => str)
.map(x => { console.log('str:', str); return x })
.map(x => x * 2)
.map(x => x * 0.8)
.map(x => x - 50)
const res = finalPrice(100)
console.log(res) // => { map: [Function: map], fold: [Function: fold] }
複製代碼
在調用 finalPrice
函數的時候,並無打印出 'str:100'
,說明正如咱們預期的那樣,函數並無真正的被調用,而只是在不斷的進行函數組合。在沒有調用 fold
函數以前,咱們的代碼都是 "pure" 的。
這有點相似於遞歸,在未知足終止條件以前(沒有調用
fold
以前),遞歸調用會在棧中不斷的堆疊(組合函數),直到知足終止條件(調用fold
函數),纔開始真正的函數計算。
const app = finalPrice(100)
const res2 = app.fold(x => x)
console.log(res2) // => 110
複製代碼
fold
函數就像打開潘多拉魔盒的雙手;經過 LazyBox
咱們把可能會「弄髒雙手(產生反作用)」的代碼扔給了最後的 fold
,這樣作又有什麼意義呢?
app.fold(x => x)
是 「no pure」 的,其餘部分都是 「pure」LazyBox
來把反作用集中管理,若是在項目中不斷的擴大 「pure」 的部分,咱們甚至能夠把不純的代碼推到代碼的邊緣,保證核心部分的 「pure」 和 「referential transparency」LazyBox 也和 Rxjs 中的
Observable
有不少類似之處,二者都是惰性的,在subscribe
以前,Observable
也不會推送數據。
此處請思考下 React 中的
useEffect
以及 Redux 中的reducer
,action
分離的設計理念。
上一小結,介紹了把函數裝入 LazyBox
中,放在最後延遲執行,以保障最後大多數代碼的 「pure」 特性。
轉換下思惟,函數能夠認爲是「惰性的值」,那麼咱們把這個稍顯特殊的值,裝入普通的 Box
,又會發生什麼呢?仍是從小學數學開始吧。
const Box = x => ({
map: f => Box(f(x)),
inspect: () => `Box(${x})`
})
const addOne = x => x + 1
Box(addOne) // => Box(x => x + 1)
複製代碼
inspect
方法的目的是爲了使用 Node.js 中的console.log
隱式的調用它,方便咱們查看數據的類型;而這一方法在瀏覽器中不可行,能夠用console.log(String(x))
來代替; Node.js V12 API 有變動,能夠採用Symbol.for('nodejs.util.inspect.custom')
替代inspect
如今咱們獲得了一個包裹着函數的 Box
,但是咱們怎麼使用這個函數呢?畢竟 Box(x).map
方法都是接收一個函數!繼續回到函數 addOne
上,咱們須要一個數字,傳遞給 addOne
,對吧!因此換句話說就是,咱們怎麼傳遞一個數字進去應用這個 addOne
函數呢,答案很是簡單,繼續傳遞一個被包裹的值,而後 map
這個函數 (addOne
) 不就能夠啦! 看代碼:
const Box = x => ({
map: f => Box(f(x)),
apply: o => o.map(x),
flod: f => f(x),
inspect: () => `Box(${x})`
})
Box(addOne).apply(Box(2)) // => Box(3)
複製代碼
看看 Box 神奇的新方法,首先被包裹的值是一個函數 x ,而後咱們繼續傳遞另外一個 Box(2)
進去,不就可使用 Box(2)
上的 map
方法調用 addOne
函數了嗎!
如今從新審視一下咱們 Box(addOne)
,Box(1)
,那麼這個問題實際上能夠歸結爲:把一個 functor
應用到另外一個上 functor
上,而這也就是 Applicative Functor
(應用函子)最擅長的操做了,看一下示意圖來描述應用函子的操做流程:
因此根據上面的講解和實例咱們能夠得出一個結論:先把一個值 x
裝進 Box
,而後 map
一個函數 f
和把函數 f
裝進 Box
,而後 apply
一個已經已經裝進 Box
的 x
,是徹底等價的!
F(x).map(f) == F(f).ap(F(x))
Box(2).map(addOne) == Box(addOne).apply(Box(2)) // => Box(3)
複製代碼
根據規範,apply 方法後面咱們會簡寫爲
ap
!
Applicative functor (應用函子)
也是函數式編程中一大堆「故弄玄虛」的概念中惟一的比較「名副其實」的了,想一想Functor(函子)
在繼續學習函數柯里化以前,先複習一下中學數學中的高斯消元法:設函數 f(x,y) = x + y
,在 y = 1
的時候,函數能夠修改成 f(x) = x + 1
。基本思路就是把二元變成一元,同理咱們能夠把三元函數降元爲二元,甚至把多元函數降元爲一元函數。
那麼咱們能夠在必定程度上認爲函數求值的過程,就是就是函數消元的過程,當全部的元都被消完以後,那麼就能夠求的函數值。
數學中的高斯消元法和函數式編程中的「柯里化」是有點相似的,所謂函數柯里化就是把一個接收多個參數的函數,轉換爲一次接收一個參數,直到收到所有參數以後,進行函數調用(計算函數值),看例子:
const add = (x, y) => x + y
const curriedAdd = x => y => x + y
複製代碼
好了,簡單理解了函數柯里化的概念以後,繼續往前走一步,思考一下,若是如今有兩個「被包裹的值」,怎麼把一個函數應用上去呢?舉個例子:
const add = x => y => x + y
add(Box(1))(Box(2))
複製代碼
上面的方案明顯是走不通的,咱們沒辦法直接把 Box(1)
和 Box(2)
相加,他們都在盒子裏;
但是咱們的需求不就是把 Box(1)
,Box(2)
,add
三者互相應用一下,想要獲得最後的結果 Box(3)
。
從第一章開始,咱們的函數運算都是在 Box 的「保護」下進行的,如今不妨也把 add
函數包裝進 Box 中,不就獲得了一個應用函子 Box(add)
,而後繼續 「apply」 其餘的函子了嗎?
Box(add).ap(Box(1)) // => Box(y => 1 + y) (獲得另外一個應用函子)
Box(add).ap(Box(1)).ap(Box(2)) // => Box(3) (獲得最終的結果)
複製代碼
上面的例子,由於每次 apply
一個 functor
,至關於把函數降元一次,咱們能夠得出一個結論,一個柯里化的函數,有幾個參數,咱們就能夠 apply 幾回。
每次 apply 以後都會返回包裹新函數的應用函子,換句話說就是:應用多個數據到多個函數,這和多重循環很是相似。
表單校驗是咱們平常開發中常見的一個需求,舉個具體的例子,假如咱們有一個用戶註冊的表單,咱們須要校驗用戶名,密碼兩個字段,常見的代碼以下:
const checkUserInfo = user => {
const { name, pw, phone } = user
const errInfo = []
if (/^[0-9].+$/.test(name)) {
errInfo.push('用戶名不能以數字開頭')
}
if (pw.length <= 6) {
errInfo.push('密碼長度必須大於6位')
}
if (errInfo.length) {
return errInfo
}
return true
}
const userInfo = {
name: '1Melo',
pw: '123456'
}
const checkRes = checkUserInfo(userInfo)
console.log(checkRes) // => [ '用戶名不能以數字開頭', '密碼長度必須大於6位' ]
複製代碼
這個代碼天然沒有問題,可是,假如咱們要繼續添加須要校驗的字段(e.g.,電話號碼,郵箱), checkUserInfo
函數毫無疑問會愈來愈龐大,而且若是咱們要修改某一個字段的校驗規則的話,整個 checkUserInfo
函數可能會受到影響,咱們須要增長的單元測試工做要更多了。
回想一下第一章中介紹的 Either(Left or Rigth) Right
指代正常的分支,Left
指代出現異常的分支,他們二者毫不會同時出現,如今咱們稍微換個理解方式:Right
指代校驗經過的分支,Left
指代校驗不經過的分支。
此時咱們繼續在第一章 Either 的基礎上擴展其餘的屬性和方法,用來作表單校驗的工具:
const Right = x => ({
x,
map: f => Right(f(x)),
ap: o => o.isLeft ? o : o.map(x),
fold: (f, g) => g(x),
isLeft: false,
isRight: true,
inspect: () => `Right(${x})`
})
const Left = x => ({
x,
map: f => Left(x),
ap: o => o.isLeft ? Left(x.concat(o.x)) : Left(x),
fold: (f, g) => f(x),
isLeft: true,
isRight: false,
inspect: () => `Left(${x})`
})
複製代碼
相對比與原 Either
,新增了 x
屬性和 ap
方法,其餘的屬性徹底相似,就不作解釋了;新增 x
屬性的緣由在於須要記錄表單校驗的錯誤信息,這個很好理解,而新增的 isLeft
,isRight
屬性就更簡單了,用來區分 Left/Right
分支。
咱們仔細看一下新增的 ap
方法,先看 Right
分支的 ap: o => o.isLeft ? o : o.map(x)
,毫無疑問 ap
方法接收另外一個 functor
,若是另外一個 functor
是 Left
的實例,則不須要 Right
處理直接返回,若是是 Right
,則和日常 applicative functor
同樣,對 o
做爲主體進行 map
。
Left
分支上的 ap: o => o.Left ? Left(x.concat(o.x)) : Left(x)
,若是是 Left
的實例,則進行一個「疊加」,實際上就是爲了累加錯誤信息,而若是不是 Left
的實例則直接返回本來已經記錄的錯誤信息。
作好了前期的準備工做,咱們就能夠大刀闊斧的按照函數式的思惟(函數組合)來拆分一下 checkUserInfo
函數:
const checkName = name => {
return /^[0-9].+$/.test(name) ? Left('用戶名不能以數字開頭') : Right(true)
}
const checkPW = pw => {
return pw.length <= 6 ? Left('密碼長度必須大於6位') : Right(true)
}
複製代碼
上面把兩個字段校驗從一個函數中拆分紅了兩個函數,更重要的是徹底解耦;返回值要麼是校驗不經過的 Left
,要麼是校驗經過的 Right
,因此咱們能夠理解爲如今有了兩個 Either
,只要咱們再擁有一個 被包裹進Either盒子而且柯里化兩次的函數 不就可讓他們互相 apply 了嗎?
const R = require('ramda')
const success = () => true
function checkUserInfo(user) {
const { name, pw, phone } = user
// 2 是由於咱們須要 `ap` 2 次。
const returnSuccess = R.curryN(2, success);
return Right(returnSuccess)
.ap(checkName(name))
.ap(checkPW(pw))
}
const checkRes = checkUserInfo({ name: '1Melo', pw: '123456' })
console.log(checkRes) // => Left(用戶名不能以數字開頭密碼長度必須大於6位)
const checkRes2 = checkUserInfo({ name: 'Melo', pw: '1234567' })
console.log(checkRes2) // => Right(true)
複製代碼
如今 checkUserInfo
函數的返回值是一個 Either(Left or Righr) 函子,具體後面就能夠繼續使用 fold
函數,展現校驗不經過彈窗或者進行下一步的表單提交了。
關於校驗參數使用 Validation 函子更合適 ,這裏爲了聚焦講解 Applicative Functor 理念這條主幹線,就再也不繼續引入新概念了。
上面舉例說明的 checkUserInfo
函數,須要 ap
兩次,感受有點繁瑣(想一想若是咱們須要校驗更多的字段呢?),咱們能夠抽象出一個 point-free 風格的函數來完成上述操做:
const apply2 = (T, g, funtor1, functor2) => T(g).ap(funtor1).ap(functor2)
function checkUserInfo(user) {
const { name, pw, phone } = user
const returnSuccess = R.curryN(2, success);
return apply2(Right, returnSuccess, checkName(name), checkPW(pw))
}
複製代碼
apply2
函數的參數特別多,尤爲是須要傳遞 T
這個不肯定的容器,用來把普通函數 g
裝進盒子裏。
把一個「值」(任意合法類型,固然包括函數),裝進容器中 (Box or Context) 中有一個統一的方法叫
of
,而這個過程被稱爲lift
,意爲提高:即把一個值提高到一個上下文中。
再回頭看看前面介紹的:Box(addOne).ap(Box(2))
和 Box(2).map(addOne)
從結果 (Box(3)
) 上來看是同樣。也就說執行 map 操做 (map(addOne)
)等同於先執行 of (Box(addOne)
),而後執行 ap (ap(Box(2))
),用公式表達就是:
F(f).ap(F(x)) == F(x).map(f)
複製代碼
套用公式,咱們能夠修改簡化 apply2
函數體中的 T(g).ap(funtor1)
爲 funtor1.map(g)
,看下面的對比:
const apply2 = (T, g, funtor1, functor2) => T(g).ap(funtor1).ap(functor2)
const liftA2 = (g, funtor1, functor2) => funtor1.map(g).ap(functor2)
複製代碼
看到了上面的關鍵點了嗎?上面的 liftA2
函數中再也不耦合於 「T」 這個特定類型的盒子,這樣更加的通用靈活。
按照上面的理論,能夠改寫 checkUserInfo
函數爲:
function checkUserInfo(user) {
const { name, pw, phone } = user
const returnSuccess = R.curryN(2, success);
return liftA2(returnSuccess, checkName(name), checkPW(pw))
}
複製代碼
如今再假設一下咱們新增了須要校驗的第三個字段「手機號碼」,那徹底能夠擴展 liftA2 函數爲 liftA3,liftA4 等等:
const liftA3 = (g, funtor1, functor2, functor3) => funtor1.map(g).ap(functor2).ap(functor3)
const liftA4 = (g, funtor1, functor2, functor3, functor4) => funtor1.map(g).ap(functor2).ap(functor3).ap(functor4)
複製代碼
剛開始可能會以爲
liftA2-3-4
看起來又醜又不必;這種寫法的意義在於:固定參數數量,通常會在函數式的 lib 中提供,不用本身手動去寫這些代碼。
根據 F(f).ap(F(x)) == F(x).map(f)
,咱們能夠得出一個結論,假如一個盒子 (Box) ,實現了 ap
方法,那麼咱們必定能夠利用 ap
方法推導出一個 map
方法,若是擁有了 map
方法,那它就是一個 Functor
,因此咱們也能夠認爲 Applicative
是 Functor
的拓展,比 Functor
更強大。
那麼強大在何處呢?Functor
只能映射一個接收單個參數的函數(e.g., x => y
),若是咱們想把接收多個參數的函數(e.g., x => y => z
)應用到多個值上,則是 Applicative
的舞臺了,想一想 checkUserInfo
的例子。
毫無疑問,Applicative Funtor 能夠
apply
屢次(固然包括一次),那麼若是函數只有一個參數的狀況下,則能夠認爲map
和apply
是等效的,換句話說:map
至關於apply
一次。
上面是實際應用中的對比,從抽象的數學層面來對比:
Box(1).map(x => x+1)
.Box(x => x+1).ap(Box(1))
。咱們從純函數與反作用的概念入手介紹了 LazyBox
(惰性求值)的概念,從而引入了把函數這個「特殊的值」裝進 Box 中,以及怎麼 apply 這個「盒子中的函數」,而後介紹了函數柯里化與應用函子的關係(被裝進盒子裏的函數必須是柯里化的函數);而後使用使用擴展後的 Either
來作表單校驗,解耦合函數,最後介紹了使用 point-free 風格來編寫鏈式調用。
到目前爲止,咱們所討論的問題都是同步的問題,可是在 Javascript 的世界中 90% 的代碼都是異步,能夠說異步纔是 JavaScript 世界的主流,誰能更優雅的解決異步的問題,誰就是 JavaScript 中的大明星,從 callback
,到 Promise
,再到 async await
,那麼在函數式編程中異步又該如何解決呢,下一章咱們將會介紹一個重量級的概念 Monad
以及異步函數的組合
。
參考資料與引用文章:
本文發佈自 網易雲音樂大前端團隊,文章未經受權禁止任何形式的轉載。咱們常年招收前端、iOS、Android,若是你準備換工做,又剛好喜歡雲音樂,那就加入咱們 grp.music-fe(at)corp.netease.com!