函數式編程進階:傑克船長的黑珍珠號

banner

本文做者: 趙祥濤

函數式編程(Functional Programming)這一理念不管是在前端領域仍是後端領域,都逐漸熱門起來,如今不大量使用函數式編程技術的大型應用程序已經很罕見了,好比前端流行的 React(核心思路數據即視圖),Vue3.0 的 Composition API ,Redux ,Lodash 等等前端框架和庫,無不充斥着函數式的思惟,實際上函數式編程毫不是最近幾年才被創造的編程範式,而是在計算機科學的開始,Alonzo Church 在 20 世紀 30 年代發表的 lambda 演算,能夠說是函數式編程的前世此生。javascript

本系列文章適合擁有紮實的 JavaScript 基礎和有必定函數式編程經驗的人閱讀,本文的目的是結合 JavaScript 的語言特性來說解範疇論的一些概念和邏輯在編程中的實際應用。css

黑珍珠號的詛咒

揚帆起航!

首先咱們看一段 雙11大促銷 的代碼,即做爲對函數組合等概念的回顧,也做爲即將開啓的新徵程的第一步:html

const finalPrice = number => {
    const doublePrice = number * 2
    const discount = doublePrice * .8
    const price = discount - 50
    return price
}

const result = finalPrice(100)
console.log(result) // => 110

看看上面這段簡單的 雙11購物狂歡節 的代碼,原價 100 的商品,通過商家一頓花式大促銷(打折(八折) + 優惠券(50))的操做以後,你們成功拿到剁手價 110好划算,快剁手前端

若是你已經閱讀了咱們雲音樂前端團隊的另一篇函數式編程入門文章,我相信你已經知道如何書寫函數式的程序了:即經過管道把數據在一系列純函數間傳遞的程序。咱們也知道了,這些程序就是聲明式的行爲規範。如今再次使用函數組合的思路保持數據管道操做,並消除這麼多的中間變量,保持一種 Point-Free 風格:java

const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x)

    const double = x => x * 2
    const discount = x => x * 0.8
    const coupon = x => x - 50

    const finalPrice = compose(coupon, discount, double)

    const result = finalPrice(100)
    console.log(result) // => 110

嗯!終於有了點函數式的味道!這個時候,咱們發現傳給函數 finalPrice 的參數 100 像一個工廠的零配件同樣在流水線上前後被函數 doublediscountcoupon 所操做。100 像水同樣在管道中流通。看到這一幕咱們是否是有點眼熟,Array 的 mapfilter ,不就是徹底相似的概念嗎?因此咱們能夠用 Array 把咱們輸入的參數進行包裝:node

const finalPrice = number =>
    [number]
        .map(x => x * 2)
        .map(x => x * 0.8)
        .map(x => x - 50)

const result = finalPrice(100)
console.log(result) // => [110]

如今咱們把 number 放進 Array 這個容器內,而後連續調用了三次 map ,來實現數據的管道流動。仔細觀察發現 Array 只是咱們數據的容器,咱們也只是想利用 Array 的 map 方法罷了,其餘的方法咱們暫時用不到,那麼咱們何不建立一個 Box 容器呢?git

const Box = x => ({
    map: f => Box(f(x)),
    inspect: () => `Box(${x})`
})

const finalPrice = str =>
    Box(str)
        .map(x => x * 2)
        .map(x => x * 0.8)
        .map(x => x - 50)

const result = finalPrice(100)

console.log(result) // => Box(110)
這裏使用函數 Box 而不是 ES6 的 Class 來生產對象的緣由是,儘可能避免了「糟糕」的 new 和 this 關鍵字(摘自《 You Don't Know JS 上冊》), new 讓人誤覺得是建立了 Class 的實例,但其實根本不存在所謂的 實例化,只是簡單的 屬性委託機制(對象組合的一種),而 this 則引入了執行上下文和詞法做用域的問題,而我只是想建立一個簡單的對象而已!

inspect 方法的目的是爲了使用 Node.js 中的 console.log 隱式的調用它,方便咱們查看數據的類型;而這一方法在瀏覽器中不可行,能夠用 console.log(String(x)) 來代替; Node.js V12 API 有變動,能夠採用 Symbol.for('nodejs.util.inspect.custom') 替代 inspect 程序員

這裏使用連續 dot、dot、dot 的鏈式調用而不是使用 compose 組合的緣由是爲了更方便的理解,compose 更爲函數式。github

被封印的黑珍珠號

傑克船長的黑珍珠號

Box 中這個 map 跟數組那個著名的 map 同樣,除了前者操做的是 Box(x) 然後者是 [x] 。它們的使用方式也幾乎一致,把一個值丟進 Box ,而後不停的 map,map,map...:編程

Box(2).map(x => x + 2).map(x => x * 3);
// => Box(12)

Box('hello').map(s => s.toUpperCase());
// => Box('HELLO')

這是講解函數式編程的第一個容器,咱們將它稱之爲 Box,而數據就像傑克船長瓶子中的黑珍珠號同樣,咱們只能經過 map 方法去操做其中的值,而 Box 像是一種虛擬的屏障,也能夠說在必定程度上保護 Box 中的值不被隨意的獲取和操做。

爲何要使用這樣的思路?由於咱們可以在不離開 Box 的狀況下操做容器裏面的值。Box 裏的值傳遞給 map 函數以後,就能夠任咱們操做;操做結束後,爲了防止意外再把它放回它所屬的 Box。這樣作的結果是,咱們能連續地調用 map,運行任何咱們想運行的函數。甚至還能夠改變值的類型,就像上面最後一個例子中那樣。

map 是可使用 lambda 表達式變換容器內的值的有效且安全的途徑。

等等,若是咱們能一直調用 map.map.map ,那咱們是否是能夠稱這種類型爲 Mappable Type ? 這樣理解徹底沒有問題!

map 知道如何在上下文中映射函數值。它首先會打開該容器,而後把值經過函數映射爲另一個值,最後把結果值再次包裹到一個新的同類型的容器中。而這種變換容器內的值的方法(map)稱爲 Functor(函子)

函子圖解

Functor(函子)範疇論裏的概念。範疇論又是什麼??? 我不懂!!!

不慌!後面咱們會再繼續簡單的討論一下範疇論與 Functor 的概念和理論,讓咱們暫時忘記這個奇怪的名字,先跳過這個概念。

仍是繼續稱之爲咱們都能理解的 Box 吧!

黑珍珠號的救贖

相似於 Box(2).map(x => x + 2) 咱們已經能夠把任何類型的值,包裝到 Box 中,而後不斷的 map,map,map...。

另外一個問題,咱們怎麼取出來咱們的值呢?我想要的結果是 4 而不是 Box(4)!

若是黑珍珠號不能從瓶子中釋放出來又有什麼用處呢?接下來讓傑克斯派洛船長搶過黑鬍子的寶劍,釋放出來黑珍珠號!

是時候爲咱們的這個最爲原始的 Box 添加別的方法了。

const Box = x => ({
    map: f => Box(f(x)),
    fold: f => f(x),
    inspect: () => `Box(${x})`
})

Box(2)
    .map(x => x + 2)
    .fold(x => x)  // => 4

嗯,看出來 foldmap 的區別了嗎?

map 是把函數執行的結果從新包裝到 Box 中後然返回一個新的 Box 類型,而 fold 則是直接把函數執行的結果 return 出來,就結束了!

Box 的實際應用

Try-Catch

在許多狀況下都會發生 JavaScript 錯誤,特別是在與服務器通信時,或者是在試圖訪問一個 null 對象的屬性時。咱們老是要預先作好最壞的打算,而這種大部分都是經過 try-catch 來實現的。

舉例來講:

const getUser = id =>
    [{ id: 1, name: 'Loren' }, { id: 2, name: 'Zora' }]
        .filter(x => x.id === id)[0]

const name = getUser(1).name
console.log(name) // => 'Loren'

const name2 = getUser(4).name
console.log(name2) // => 'TypeError: Cannot read property 'name' of undefined'

那麼如今代碼報錯了,使用 try-catch 能夠必定程度上解決這個問題:

try {
    const result = getUser(4).name
    console.log(result)
} catch (e) {
    console.log('error', e.message) // => 'TypeError: Cannot read property 'name' of undefined'
}

一旦發生了錯誤,JavaScript 會當即終止執行,並建立致使該問題的函數的調用堆棧跟蹤,並保存到 Error 對象中,catch 就像是咱們代碼的避風港灣同樣。可是 try-catch 能妥善的解決咱們的問題嗎?try-catch 存在如下缺點:

  • 違反了引用透明原則,由於拋出異常會致使函數調用出現另外一個出口,因此不能確保單一的可預測的返回值。
  • 會引發反作用,由於異常會在函數調用以外對堆棧引起不可預料的影響。
  • 違反局域性的原則,由於用於恢復異常的代碼和原始的函數調用漸行漸遠,當發生錯誤的時候,函數會離開局部棧和環境。
  • 不能只關心函數的返回值,調用者須要負責聲明 catch 塊中的異常匹配類型來管理特定的異常;難以與其餘函數組合或連接,總不能讓管道中的下一個函數處理上一個函數拋出的錯誤吧。
  • 當有多個異常條件的時候會出現嵌套的異常處理塊
異常應該由一個地方拋出,而不是隨處可見。

上面的描述和代碼能夠看出,try-catch 是徹底被動的解決方式,也很是的不「函數式」,如果能輕鬆的處理錯誤甚至包容錯誤,該有多好?下面不妨讓咱們使用Box理念,來優化這些問題

向左? 向右?

仔細分析 try-catch 代碼塊的邏輯,發現咱們的代碼出口要麼在 try 中,要麼在 catch 中(函數總不能有兩個返回值吧)。按照咱們代碼設計的指望,咱們是但願代碼從 try 分支走完的,catch 是咱們的一個兜底方案,那麼咱們能夠類比 try 爲 Right 指代正常的分支,catch 爲 Left 指代出現異常的分支,他們二者毫不會同時出現!那麼咱們擴展一下咱們的 Box ,分別爲 LeftRight ,看代碼:

const Left = x => ({
    map: f => Left(x),
    fold: (f, g) => f(x),
    inspect: () => `Left(${x})`
})

const Right = x => ({
    map: f => Right(f(x)),
    fold: (f, g) => g(x),
    inspect: () => `Right(${x})`
})

const resultLeft = Left(4).map(x => x + 1).map(x => x / 2)
console.log(resultLeft)  // => Left(4)

const resultRight = Right(4).map(x => x + 1).map(x => x / 2)
console.log(resultRight)  // => Right(2.5)

LeftRight 的區別在於 Left 會自動跳過 map 方法傳遞的函數,而 Right 則相似於最基本的 Box,會執行函數並把返回值從新包裝到 Right 容器裏面。LeftRight 徹底相似於 Promise 中的 RejectResolve,一個 Promise 的結果要麼是 Reject 要麼是 Resolve,而擁有 Right 和 Left 分支的結構體,咱們能夠稱之爲 Either ,要麼向左,要麼向右,很好理解,對吧!上面的代碼說明了 Left 和 Right 的基本用法,如今把咱們的 LeftRight 應用到 getUser 函數上吧!

const getUser = id => {
    const user = [{ id: 1, name: 'Loren' }, { id: 2, name: 'Zora' }]
        .filter(x => x.id === id)[0]
    return user ? Right(user) : Left(null)
}

const result = getUser(4)
    .map(x => x.name)
    .fold(() => 'not found', x => x)

console.log(result) // => not found

不可相信!咱們如今居然能線性的處理錯誤,而且甚至可以給出一個 not found 的提醒了(經過給 fold 提供),可是再仔細思考一下,是否是咱們原始的 getUser 函數,有可能會返回 undefined 或者一個正常的值,是否是能夠直接包裝一下這個函數的返回值呢?

const fromNullable = x =>
    x != null ? Right(x) : Left(null)

const getUser = id =>
    fromNullable([{ id: 1, name: 'Loren' }, { id: 2, name: 'Zora' }]
            .filter(x => x.id === id)[0])

const result = getUser(4)
    .map(x => x.name)
    .fold(() => 'not found', c => c.toUpperCase())

console.log(result) // => not found

如今咱們已經成功處理了可能出現 null 或者 undefined 的狀況,那麼 try-catch 呢?是否也能夠被 Either 包裝一下呢?

const tryCatch = (f) => {
    try {
        return Right(f())
    } catch (e) {
        return Left(e)
    }
}

const jsonFormat = str => JSON.parse(str)

const app = (str) =>
    tryCatch(() => jsonFormat(str))
        .map(x => x.path)
        .fold(() => 'default path', x => x)

const result = app('{"path":"some path..."}')
console.log(result) // => 'some path...'

const result2 = app('the way to death')
console.log(result2) // => 'default path'

如今咱們的 try-catch 即便報錯了,也不會打斷咱們的函數組合了,而且錯誤獲得了合理的控制,不會隨意的 throw 出來一個 Error 對象了。

此處建議打開網易雲音樂聽一首 《向左向右》!放鬆一下,順帶回味一下咱們的 Right 與 Left。

什麼是 Functor? 怎麼使用 Functor? 爲何使用 Functor?

什麼是 Functor?

上面咱們定義了一個簡單的 Box,其實也就是擁有 mapfold 方法的類型。讓咱們把腳步放慢一點,再仔細觀察和思考一下咱們的 mapBox(a) -> Box(b) ,本質上就是經過一個函數 a -> b 把一個 Box(a) 映射爲 Box(b)。這和中學代數中的函數知識何其相似,不妨再回顧一下代數課本中函數的定義:

假設 A 和 B 是兩個集合,若按照某種對應法則,使得 A 的任一元素在 B 中都有惟一的元素和它對應,則稱這種對應爲從集合 A 到集合 B 的函數。

上面的集合 A 和集合 B,拿到咱們的程序世界,徹底能夠類比與 String 、Boolean、Number 和更抽象的 Object,一般咱們能夠把數據類型視做全部可能值的一個集合( Set )。像 Boolean 就能夠看做是 [true,false] 的集合,Number 是全部實數的集合,全部的集合,以集合爲對象,集合之間的映射做爲箭頭,則構成了一個範疇:

範疇

看圖:a,b,c 分別表示三個範疇,如今咱們作個類比:a 爲字符串的集合(String),b 爲實數的集合(Number),c 爲 Boolean 的集合;那麼咱們徹底能夠實現映射函數 gstr => str.length,而函數 fnumber => number >=0 ? true : false,那麼咱們就能夠經過函數 g 完成從字符串範疇到實數範疇的映射,而後經過函數 f 從實數範疇映射到 Boolean 範疇。

如今從新回顧一下以前跳過的那個晦澀的名字: Functor (函子)就是範疇到範疇之間映射的那個箭頭!而這個箭頭通常經過 map 方法配合一個變換函數(i.e. str => str.length )來實現,這樣理解起來就很容易了,對吧(纔怪

若是咱們有了函數 g 和函數 f,那麼咱們必定能夠推導出函數 h = f·g ,也就是 const h = compose(f,g),而這就是上圖下半部分 a -> c的變換過程,這不就是中學的數學結合律嗎? 咱們可都是學太高數的,誰不會啊

等等,a,b,c 上面的那個 id 箭頭又是什麼鬼?本身映射到本身?不錯!
對於任何 Functor,經過函數 const id = x => x 能夠實現 fx.map(id) == id(fx),而這被稱爲 Identity,也就是數學中的同一概

這也是爲何咱們必定要引入範疇論,引入 Functor 的概念,而不僅是簡單的把他們稱爲 mappale 或者其餘什麼東西,由於這樣咱們就能夠在保持名稱不變的基礎上更加理解伴隨着數學原理而來的 Functor 的其餘的定理(CompositionIdentity),不要由於這個晦澀的名稱而讓咱們駐步不前。

上面的介紹僅僅是方便前端渣渣們( 和Haskell大神相比),在必定程度上理解範疇。並非十分的嚴謹( 很是不嚴謹好不),範疇中的對象能夠不是集合,箭頭也能夠不是映射...停停!!打住!再說下去我就能夠轉行作代數老師了(ahhhh.jpg)。

怎麼使用 Functor?

如今再次讓咱們回到代碼的世界,毫無疑問 Functor 這個概念太常見了。其實絕大多數的開發人員一直在使用 Functor 卻沒有意識到而已。好比:

  • Array 的 mapfilter
  • jQuery 的 cssstyle
  • Promise 的 thencatch 方法(Promise 也是一種 Functor? Yes!)。
  • Rxjs Observable 的 mapfilter (異步函數的組合?Relax!)。

都是返回一樣類型的 Functor,所以能夠不斷的鏈式調用,其實這些都是 Box 理念的延伸:

[1, 2, 3].map(x => x + 1).filter(x => x > 2)

$("#mybtn").css("width","100px").css("height","100px").css("background","red");

Promise.resolve(1).then(x => x + 1).then(x => x.toString())

Rx.Observable.fromEvent($input, 'keyup')
    .map(e => e.target.value)
    .filter(text => text.length > 0)
    .debounceTime(100)

爲何使用 Functor?

把值裝進一個容器(好比 Box,Right,Left 等),而後只能用 map 來操做它,這麼作的理有究竟是什麼呢?若是咱們換種方式來思考,答案就很明顯了:讓容器本身去運用函數能給咱們帶來什麼好處呢?答案是:
抽象,對於函數運用的抽象。

縱觀整個函數式編程的核心就在於把一個個的小函數組合成更高級的函數。
舉個函數組合的例子:若是想給任何 Functor 應用一個統一的 map ,該如何處理?答案是 Partial Application

const partial =
    (fn, ...presetArgs) =>
        (...laterArgs) =>
            fn(...presetArgs, ...laterArgs);

const double = n => n * 2
const map = (fn, F) => F.map(fn)
const mapDouble = partial(map, double)

const res = mapDouble(Box(1)).fold(x => x)
console.log(res)  // => 2

關鍵在於 mapDouble 函數返回的結果是一個等待接收第二個參數 F (Box(1)) 的函數; 一旦收到第二個參數,則會直接執行 F.map(fn) ,至關於 Box(1).map(double) ,該表達式返回的結果爲 Box(2) ,因此後面能夠繼續 .fold等等鏈式操做。

總結與計劃

總結

上面經過雙十一購物狂歡節的例子,介紹了函數式編程的幾個基本概念(pure function,compose)等,並逐漸引入了功能強大的 Box 理念,也就是最基本的 Functor。後面經過無時不刻可能出現的 null ,介紹了 Either 能夠用來作 null 的包容器。再經過 try-catch 的例子,瞭解了比較 pure 的處理錯誤的方式, Either 固然不只僅是這兩種用法,後面會繼續介紹其餘高級的用法。最後總結了什麼是 Functor,怎麼使用 Functor,以及使用 Functor 的優點何在。

計劃

Functor 是咱們介紹的範疇論中的最最最基本的一個概念,不過咱們目前解決的都是最簡單的問題(更優秀的組合(map),更健壯的代碼(fromNullAble),更純的錯誤處理(TryCatch)),可是嵌套的 try-catch 呢?異步函數怎麼組合呢?後面會繼續經過 雙11購物狂歡節的案例 來介紹範疇論中的其餘概念和實際用法示例(實際目的:繼續揭露奸商的套路,順便轉行作代數老師,擺脫 34歲 淘汰的潛規則; 狗頭.jpg)。

參考資料與引用文章:

本文發佈自 網易雲音樂前端團隊,文章未經受權禁止任何形式的轉載。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們
相關文章
相關標籤/搜索