函數式編程進階:應用函子

圖片來源: 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

  • 給出相同的參數,那麼函數的返回值必定相同。該函數結果值不依賴任何隱藏信息或程序執行處理可能改變的狀態,也不能依賴於任何來自 I/O 的外部輸入。
  • 在對函數返回值的計算過程當中,不會產生任何語義上可觀察的反作用或輸出,例如對象的變化或者輸出到 I/O 的操做。

關於純函數的第一條很簡單,相同的輸入,總會返回相同的輸出,和中學數學中學習的「函數」徹底相似,傳入相同的參數,返回值必定相同,函數自己就是從集合到集合的「映射」。git

第二條不產生可觀察的反作用又是什麼意思呢?也就是函數不能夠和系統的其餘部分通訊。好比:打印日誌,讀寫文件,數據請求,數據存儲等等;github

從代碼編寫者的角度來看,若是一段程序運行以後沒有可觀察到的做用,那他到底運行了沒有?或者運行以後有沒有實現代碼的目的?有可能它只是浪費了幾個 CPU 週期以後就去睡大覺了!數據庫

從 JavaScript 語言的誕生之初就不可避免地須要可以與不斷變化的,共享的,有狀態的 DOM 互相做用;若是沒法輸入輸出任何數據,那麼數據庫有什麼用處呢?若是沒法從網絡請求信息,咱們的頁面又該如何展現?沒有 「side effect」 咱們幾乎步履維艱,反作用不可避免,上述的任何一個操做,都會產生反作用,違反了引用透明性,咱們彷佛陷入了兩難的境地!編程

世間安得雙全法,不負如來不負卿數組

如何在 keep pure 的前提下,又能妥善的處理 side effect 呢?

惰性盒子-LazyBox

要想較理想的解決這個問題,咱們把注意力轉回到 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 ,這樣作又有什麼意義呢?

  • 把代碼中不純的部分剝離出來,保障核心部分代碼的 「pure」 特性,好比上面的代碼中只有 app.fold(x => x) 是 「no pure」 的,其餘部分都是 「pure」
  • 相似於上一章中的錯誤集中管理,能夠經過 LazyBox 來把反作用集中管理,若是在項目中不斷的擴大 「pure」 的部分,咱們甚至能夠把不純的代碼推到代碼的邊緣,保證核心部分的 「pure」 和 「referential transparency」

LazyBox 也和 Rxjs 中的 Observable 有不少類似之處,二者都是惰性的,在 subscribe 以前,Observable 也不會推送數據。

此處請思考下 React 中的 useEffect 以及 Redux 中的 reduceraction 分離的設計理念。

應用函子

Function in Box

上一小結,介紹了把函數裝入 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 (應用函子)最擅長的操做了,看一下示意圖來描述應用函子的操做流程:

Applicative Functor

因此根據上面的講解和實例咱們能夠得出一個結論:先把一個值 x 裝進 Box,而後 map 一個函數 f 和把函數 f 裝進 Box,而後 apply 一個已經已經裝進 Boxx,是徹底等價的!

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 屬性的緣由在於須要記錄表單校驗的錯誤信息,這個很好理解,而新增的 isLeftisRight 屬性就更簡單了,用來區分 Left/Right 分支。

咱們仔細看一下新增的 ap 方法,先看 Right 分支的 ap: o => o.isLeft ? o : o.map(x),毫無疑問 ap 方法接收另外一個 functor ,若是另外一個 functorLeft 的實例,則不須要 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 理念這條主幹線,就再也不繼續引入新概念了。

PointFree風格

上面舉例說明的 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 中提供,不用本身手動去寫這些代碼。

Applicative Functor 和 Functor 的區別和聯繫

根據 F(f).ap(F(x)) == F(x).map(f),咱們能夠得出一個結論,假如一個盒子 (Box) ,實現了 ap 方法,那麼咱們必定能夠利用 ap 方法推導出一個 map 方法,若是擁有了 map 方法,那它就是一個 Functor ,因此咱們也能夠認爲 ApplicativeFunctor 的拓展,比 Functor 更強大。

那麼強大在何處呢?Functor 只能映射一個接收單個參數的函數(e.g., x => y),若是咱們想把接收多個參數的函數(e.g., x => y => z)應用到多個值上,則是 Applicative 的舞臺了,想一想 checkUserInfo 的例子。

毫無疑問,Applicative Funtor 能夠 apply 屢次(固然包括一次),那麼若是函數只有一個參數的狀況下,則能夠認爲 mapapply 是等效的,換句話說:map 至關於 apply 一次。

上面是實際應用中的對比,從抽象的數學層面來對比:

  • Functor: 應用一個函數到包裹的值:Box(1).map(x => x+1).
  • Applicative: 應用一個包裹的函數到包裹的值:Box(x => x+1).ap(Box(1))

applicative vs functor

總結與計劃

咱們從純函數與反作用的概念入手介紹了 LazyBox (惰性求值)的概念,從而引入了把函數這個「特殊的值」裝進 Box 中,以及怎麼 apply 這個「盒子中的函數」,而後介紹了函數柯里化與應用函子的關係(被裝進盒子裏的函數必須是柯里化的函數);而後使用使用擴展後的 Either 來作表單校驗,解耦合函數,最後介紹了使用 point-free 風格來編寫鏈式調用。

計劃

到目前爲止,咱們所討論的問題都是同步的問題,可是在 Javascript 的世界中 90% 的代碼都是異步,能夠說異步纔是 JavaScript 世界的主流,誰能更優雅的解決異步的問題,誰就是 JavaScript 中的大明星,從 callback ,到 Promise ,再到 async await ,那麼在函數式編程中異步又該如何解決呢,下一章咱們將會介紹一個重量級的概念 Monad 以及異步函數的組合

參考資料與引用文章:

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

相關文章
相關標籤/搜索