霖呆呆的函數(shù)式編程之路(二)

前言

在第一章我們主要介紹了函數(shù)的一些基本功能和結(jié)構(gòu),以及介紹了一些實(shí)用的小技巧。這些都是為了后面一步一步入門打下好的基礎(chǔ)。因?yàn)楹瘮?shù)式編程并不是一個(gè)看看文檔就能很好掌握的東西,它需要你集合實(shí)際例子然后理解每一步為什么要這樣,如果你只是想粗略的看看,不去思考??,相信我,后面的案例你會(huì)感覺特別跳,特別繞(開始學(xué)習(xí)時(shí)我就是這樣??)。

在這一章中,我會(huì)針對(duì)函數(shù)式編程的另一個(gè)重點(diǎn):函數(shù)的輸入來做講解和案例分析,個(gè)人建議:打開你的vscode,關(guān)上文檔,把案例敲上一遍,需要的時(shí)候把每一步做個(gè)對(duì)比,確保自己是真的理解它們。

偏函數(shù)

先來看一個(gè)大家都很熟悉的函數(shù):

  1. 一個(gè)ajax函數(shù),第一個(gè)參數(shù)為請(qǐng)求的API地址,第二個(gè)為請(qǐng)求的參數(shù),第三個(gè)是請(qǐng)求成功之后的回調(diào)函數(shù)。
function ajax (url, data, callback) {
    // ...
}
  1. 現(xiàn)在如果你已經(jīng)很確定一個(gè)API地址,此外只是需要等待另外兩個(gè)參數(shù)的時(shí)候,比如獲取用戶信息和獲取訂單詳情的請(qǐng)求:
function getUser (data, cb) {
    ajax('/api/user', data, cb)
}
function getOrder (data, cb) {
    ajax('api/order', data, cb)
}
  1. 現(xiàn)在如果你已經(jīng)很確定一個(gè)API地址,同時(shí)已經(jīng)很確定請(qǐng)求的參數(shù)(比如用戶的id),此外只需要等待另一個(gè)參數(shù)的時(shí)候:
function getCurrentUser (cb) {
    getUser({ userId: 1 }, cb)
}
function getCurrentOrder (cb) {
    getUser({ orderId: 1 }, cb)
}

不知道大家發(fā)現(xiàn)了沒,從第一步到第三步,每過一步,函數(shù)的參數(shù)就少一個(gè),直到最后只需要傳遞一個(gè)cb。

用一句話來說明發(fā)生的事情:getUser(data, cb)ajax(url, data, cb)偏函數(shù)(partially-applied functions)。

(注意??:前方高能!)

關(guān)于該模式更正式的說法是:偏函數(shù)嚴(yán)格來講是一個(gè)減少函數(shù)參數(shù)個(gè)數(shù)(arity)的過程;這里的參數(shù)個(gè)數(shù)指的是希望傳入的形參的數(shù)量。我們通過 getUser(..) 把原函數(shù) ajax(..) 的參數(shù)個(gè)數(shù)從 3 個(gè)減少到了 2 個(gè)。

partial函數(shù)

在上面的例子中,getCurrentUser(cb)getCurrentOrder(cb)的模式其實(shí)很想,我們可以來定一個(gè)partial()實(shí)用函數(shù):

function partial (fn, ...prestArgs) {
    return function partiallyApplied (...laterArgs) {
        return fn(...prestArgs, ...laterArgs)
    }
}

partial函數(shù)接受一個(gè)fn函數(shù),和若干個(gè)參數(shù)…prestArgs。

它返回的是另一個(gè)函數(shù)partiallyApplied()函數(shù),這個(gè)函數(shù)也接受若干個(gè)參數(shù)…laterArgs,并返回partial函數(shù)傳遞進(jìn)來fn函數(shù)。

返回的fn函數(shù)會(huì)將partialpartiallyApplied中的參數(shù)都接收過去。

(這個(gè)實(shí)用函數(shù)我至少敲了3遍...)

好吧,我們還是來看看我參考資料的原版本是怎么描述這個(gè)實(shí)用函數(shù)的吧,感覺它說的也比較清晰:

partial(..) 函數(shù)接收 fn 參數(shù),來表示被我們偏應(yīng)用實(shí)參(partially apply)的函數(shù)。接著,fn 形參之后,presetArgs 數(shù)組收集了后面?zhèn)魅氲膶?shí)參,保存起來稍后使用。

我們創(chuàng)建并 return 了一個(gè)新的內(nèi)部函數(shù)(為了清晰明了,我們把它命名為partiallyApplied(..)),該函數(shù)中,laterArgs 數(shù)組收集了全部實(shí)參。

你注意到在內(nèi)部函數(shù)中的 fnpresetArgs 引用了嗎?他們是怎么如何工作的?在函數(shù) partial(..) 結(jié)束運(yùn)行后,內(nèi)部函數(shù)為何還能訪問 fnpresetArgs 引用?你答對(duì)了,就是因?yàn)?strong>閉包!內(nèi)部函數(shù) partiallyApplied(..) 封閉(closes over)了 fnpresetArgs 變量,所以無論該函數(shù)在哪里運(yùn)行,在 partial(..) 函數(shù)運(yùn)行后我們?nèi)匀豢梢栽L問這些變量。所以理解閉包是多么的重要!

當(dāng) partiallyApplied(..) 函數(shù)稍后在某處執(zhí)行時(shí),該函數(shù)使用被閉包作用(closed over)的 fn 引用來執(zhí)行原函數(shù),首先傳入(被閉包作用的)presetArgs 數(shù)組中所有的偏應(yīng)用(partial application)實(shí)參,然后再進(jìn)一步傳入 laterArgs 數(shù)組中的實(shí)參。

當(dāng)然你也可以用更便捷的箭頭函數(shù)語法來重寫上面的函數(shù):

var partial = (fn, ...presetArgs) => 
                                                        (...laterArgs) => 
                                                                fn(...prestArgs, ...laterArgs);

優(yōu)點(diǎn):更加簡潔,甚至代碼稀少。

缺點(diǎn):函數(shù)會(huì)變成匿名函數(shù),可讀性上失去益處,此外,由于作用域邊界變得模糊,我們會(huì)更加難以辯認(rèn)閉包。

不過是否采用箭頭函數(shù)都是你的個(gè)人喜好。

ajax案例

  1. 介紹完上面的函數(shù),我們現(xiàn)在可以用partial實(shí)用函數(shù)來制造這些之前提及的偏函數(shù):
// example1
function partial (fn, ...prestArgs) {
    return function partiallyApplied (...laterArgs) {
        return fn(...prestArgs, ...laterArgs)
    }
}

var getUser = partial(ajax, '/api/user')

var getOrder = partial(ajax, '/api/order')

不知道大家腦中是否有getUser 函數(shù)的外形和內(nèi)在,它其實(shí)就相當(dāng)于這樣:

var getUser = partial(ajax, '/api/user')
// 相當(dāng)于=>
var getUser = function partailApplication (...laterArgs) {
  return ajax('/api/user', ...laterArgs)
}
  1. 我相信大家已經(jīng)知道怎樣用partial來寫getUser函數(shù)了

那么再進(jìn)一層,getCurrentuser函數(shù)可以怎么寫呢?

// example2
var getCurrentUser = partial(ajax, '/api/user', { userId: 1 })

哈哈??,看到這里你是否想到了還能用案例1中的getUserpartial配合:

// example3
var getCurrentUser = partial(getUser, { userId: 1 })

過程是這樣的:

function ajax (url, data, callback) {
    // ...
}

function partial (fn, ...prestArgs) {
    return function partiallyApplied (...laterArgs) {
        return fn(...prestArgs, ...laterArgs)
    }
}

var getUser = partial(ajax, '/api/user')

var getCurrentUser = partial(getUser, { userId: 1 })

我們可以像案例2一樣通過指定urldata兩個(gè)實(shí)參來定義getCurrentUser(...)函數(shù)。

也可以像案例3將getCurrentUser(…)函數(shù)定義成getUser(…)的偏應(yīng)用,該偏應(yīng)用僅指定一個(gè)附加的 data 實(shí)參。

案例3的函數(shù)包含了一個(gè)額外的函數(shù)包裝層。這看起來有些奇怪而且多余,但對(duì)于你真正要適應(yīng)的函數(shù)式編程來說,這僅僅是它的冰山一角。隨著本文的繼續(xù)深入,我們將會(huì)把許多函數(shù)互相包裝起來。記住,這就是函數(shù)式編程!

add案例

理解了上面的一個(gè)案例之后,我們?cè)賮砜聪旅娴陌咐龖?yīng)該就會(huì)變得非常簡單了:

這是一個(gè)計(jì)算返回兩數(shù)之和的函數(shù):

function add (x, y) {
    return x + y
}

現(xiàn)在我們有一個(gè)數(shù)組,要給數(shù)組中的每一項(xiàng)都固定加上一個(gè)數(shù)3,也許你想到了可以用JS中的map來寫:

var arr = [1, 2, 3, 4]
var arr2 = arr.map(function adder (val) => {
    return add(3, val)
})

map中執(zhí)行的事情其實(shí)也是返回一個(gè)函數(shù)add的計(jì)算結(jié)果,那么我們就可以用partial函數(shù)來寫它:

// example4
var arr2 = arr.map(partial(add, 3))

注意: 如果你沒見過 map(..) ,別擔(dān)心,我會(huì)在后面的部分詳細(xì)介紹它。目前你只需要知道它用來循環(huán)遍歷(loop over)一個(gè)數(shù)組,在遍歷過程中調(diào)用函數(shù)產(chǎn)出新值并存到新的數(shù)組中。

柯里化

我們來看一個(gè)跟偏應(yīng)用類似的技術(shù),該技術(shù)將一個(gè)期望接收多個(gè)實(shí)參的函數(shù)拆解成連續(xù)的鏈?zhǔn)胶瘮?shù)(chained functions),每個(gè)鏈?zhǔn)胶瘮?shù)接收單一實(shí)參(實(shí)參個(gè)數(shù):1)并返回另一個(gè)接收下一個(gè)實(shí)參的函數(shù)。

這就是柯里化(currying)技術(shù)。

還記得前面的ajax函數(shù)嗎?

function ajax (url, data, callback) {
    // ...
}

現(xiàn)在想象一下我們已經(jīng)創(chuàng)建了一個(gè)ajax(…)的柯里化版本:

curriedAjax('/api/user')
                        ({ userId: 1 })
                            ( function foundUser(user) { ... } )    

我們將三次調(diào)用分別拆解開來,這也許有助于我們理解整個(gè)過程:

var userFetcher = curriedAjax('/api/user')
var getCurrentUser = userFetcher({ userId: 1 })
getCurrentUser( function foundUser(user){ /* .. */ } )

可以看到curriedAjax函數(shù)在每次調(diào)用的時(shí)候只接收一個(gè)實(shí)參,而不是一次性接收所有實(shí)參(像 ajax(..) 那樣),也不是先傳部分實(shí)參再傳剩余部分實(shí)參(借助 partial(..) 函數(shù))。

柯里化和偏應(yīng)用進(jìn)行對(duì)比

相同點(diǎn):

  • 每個(gè)類似偏應(yīng)用的連續(xù)柯里化調(diào)用都把另一個(gè)實(shí)參應(yīng)用到原函數(shù),一直到所有實(shí)參傳遞完畢。

不同點(diǎn):

  • 柯里化會(huì)明確地返回一個(gè)期望只接收下一個(gè)實(shí)參 data 的函數(shù),而偏應(yīng)用是能接收所有的剩余參數(shù)。

curry函數(shù)

下面我們來看看如何定義一個(gè)用來柯里化的實(shí)用函數(shù):

function curry(fn, arity = fn.length) {
  return (function nextCurried(prevArgs) {
    return function curried(nextArg) {
      var args = prevArgs.concat([nextArg])
      if (args.length >= arity) {
        return fn(...args)
      } else {
        return nextCurried(args)
      }
    }
  })([])
}

ES6箭頭函數(shù)版本:

var curry = (fn, arity = fn.length, nextCurried) => 
                                (nextCurried = prevArgs => {
                                    nextArg => {
                                        var args = prevArgs.concat( [nextArg] );
                    if (args.length >= arity) {
                      return fn( ...args );
                    }
                    else {
                      return nextCurried( args );
                    }
                                    }
                                })([])

此處的實(shí)現(xiàn)方式是把空數(shù)組 [] 當(dāng)作 prevArgs 的初始實(shí)參集合,并且將每次接收到的 nextArgprevArgs 連接成 args 數(shù)組。當(dāng) args.length 小于 arity(原函數(shù) fn(..) 被定義和期望的形參數(shù)量)時(shí),返回另一個(gè) curried(..)函數(shù)(譯者注:這里指代 nextCurried(..) 返回的函數(shù))用來接收下一個(gè) nextArg 實(shí)參,與此同時(shí)將 args 實(shí)參集合作為唯一的 prevArgs 參數(shù)傳入 nextCurried(..) 函數(shù)。一旦我們收集了足夠長度的 args 數(shù)組,就用這些實(shí)參觸發(fā)原函數(shù) fn(..)。

默認(rèn)地,我們的實(shí)現(xiàn)方案基于下面的條件:在拿到原函數(shù)期望的全部實(shí)參之前,我們能夠通過檢查將要被柯里化的函數(shù)的 length 屬性來得知柯里化需要迭代多少次。

假如你將該版本的 curry(..) 函數(shù)用在一個(gè) length 屬性不明確的函數(shù)上 —— 函數(shù)的形參聲明包含默認(rèn)形參值、形參解構(gòu),或者它是可變參數(shù)函數(shù),用 ...args 當(dāng)形參;參考第 2 章 —— 你將要傳入 arity 參數(shù)(作為 curry(..) 的第二個(gè)形參)來確保 curry(..) 函數(shù)的正常運(yùn)行。

ajax案例

我們用 curry(..) 函數(shù)來實(shí)現(xiàn)此前的 ajax(..) 例子:

var curriedAjax = curry( ajax )
var userFetcher = curriedAjax('/api/user')
var getCurrentUser = userFetcher({ userId: 1 })
getCurrentUser( function foundUser(user){ /* .. */ } )

可以看到在每次函數(shù)調(diào)用的時(shí)候都會(huì)新增一個(gè)實(shí)參,最終給原函數(shù)ajax使用,直到收齊了三個(gè)實(shí)參并執(zhí)行ajax函數(shù)為止。

add案例

現(xiàn)在我們還可以來回顧一下在partial中用到的例子:

var arr = [1, 2, 3, 4]
var arr2 = arr.map( partial(add, 3) )

由于柯里化是和偏應(yīng)用相似的,所以我們可以用幾乎相同的方式以柯里化來完成那個(gè)例子。

var arr2 = arr.map( curry( add )( 3 ) );
// [4,5,6,7,8]

partial(add,3)curry(add)(3) 兩者有什么不同呢?為什么你會(huì)選 curry(..) 而不是偏函數(shù)呢?當(dāng)你先得知 add(..) 是將要被調(diào)整的函數(shù),但如果這個(gè)時(shí)候并不能確定 3 這個(gè)值,柯里化可能會(huì)起作用:

var adder = curry( add );

// later
[1,2,3,4,5].map( adder( 3 ) );
// [4,5,6,7,8]

sum案例

下面這個(gè)案例,是將一個(gè)列表的數(shù)字相加:

function sum(...args) {
    var sum = 0;
    for (let i = 0; i < args.length; i++) {
        sum += args[i];
    }
    return sum;
}

普通調(diào)用:

sum(1, 2, 3, 4, 5)
// 15

柯里化調(diào)用

// (5 用來指定需要鏈?zhǔn)秸{(diào)用的次數(shù))
var curriedSum = curry( sum, 5 )
curriedSum( 1 )( 2 )( 3 )( 4 )( 5 ) // 15

柯里化調(diào)用的好處:

  • 每次函數(shù)調(diào)用傳入一個(gè)實(shí)參,并生成另一個(gè)特定性更強(qiáng)的函數(shù),之后我們可以在程序中獲取并使用那個(gè)新函數(shù)。
  • 偏應(yīng)用則是預(yù)先指定所有將被偏應(yīng)用的實(shí)參,產(chǎn)出一個(gè)等待接收剩下所有實(shí)參的函數(shù)。

柯里化和偏應(yīng)用有什么用?

柯里化和偏應(yīng)用這兩種風(fēng)格的簽名都比普通的函數(shù)要奇怪很多,那么為什么要用這么奇怪的方式去構(gòu)造那些函數(shù)呢?主要是有這么幾個(gè)方面:

  • 使用柯里化和偏應(yīng)用可以將指定分離實(shí)參的時(shí)機(jī)和地方獨(dú)立開來,傳統(tǒng)函數(shù)是需要預(yù)先確定所有實(shí)參的。
  • 當(dāng)函數(shù)只有一個(gè)形參時(shí),我們能夠比較容易地組合它們

柯里化多個(gè)參數(shù)

在上面介紹的函數(shù)柯里化中,我們知道,它在每次調(diào)用的時(shí)候只支持傳入一個(gè)實(shí)參。這樣的柯里化我們可以稱之為“嚴(yán)格柯里化”。

其實(shí)在大多數(shù)流行的JavaScript函數(shù)式編程都使用了一種不嚴(yán)格的柯里化(loose currying)。

也就是說,往往 JS 柯里化實(shí)用函數(shù)會(huì)允許你在每次柯里化調(diào)用中指定多個(gè)實(shí)參,如在上面提到的sum函數(shù),我們使用嚴(yán)格柯里化需要調(diào)用5次,但在松散柯里化我們可以這樣:

var curriedSum = looseCurry(sum, 5)
curriedSum(1)(2, 3)(4, 5)

相比于嚴(yán)格的柯里化,語法上我們節(jié)省了()的使用,并且把五次函數(shù)調(diào)用減少成三次,間接提高了性能。

注意: 松散柯里化允許你傳入超過形參數(shù)量(arity,原函數(shù)確認(rèn)或指定的形參數(shù)量)的實(shí)參。如果你將函數(shù)的參數(shù)設(shè)計(jì)成可配的或變化的,那么松散柯里化將會(huì)有利于你。

現(xiàn)在我們可以將之前的柯里化函數(shù)調(diào)整一下,使其適應(yīng)這種常見的更松散的定義:

        function looseCurry (fn, arity = fn.length) {
            return (function nextCurried (prevArgs) {
                return function curried(...nextArgs) {
                    var args = prevArgs.concat(nextArgs)
                    if (args.length >= arity) {
                        return fn(...args)
                    } else {
                        return nextCurried(args)
                    }
                }
            })([])
        }

ES6版本:

        var looseCurry = (fn, arity = fn.length, nextCurried) =>
            (nextCurried = prevArgs =>
                (...nextArg) => {
                    var args = prevArgs.concat(nextArg);
                    if (args.length >= arity) {
                        return fn(...args);
                    }
                    else {
                        return nextCurried(args);
                    }
                })([])

反柯里化

你也會(huì)遇到這種情況:拿到一個(gè)柯里化后的函數(shù),卻想要它柯里化之前的版本 —— 這本質(zhì)上就是想將類似 f(1)(2)(3) 的函數(shù)變回類似 g(1,2,3) 的函數(shù)。

處理這個(gè)需求的標(biāo)準(zhǔn)實(shí)用函數(shù)通常被叫作 uncurry(..)

function uncurry(fn) {
    return function uncurried(...args){
        var ret = fn;

        for (let i = 0; i < args.length; i++) {
            ret = ret( args[i] );
        }

        return ret;
    };
}

ES6版本

var uncurry = fn => 
    uncurried = (...args) => {
        var ret = fn
        for (let i = 0; i < args.length; i++) {
            ret = ret( args[i] )
        }
        return ret
    }

使用反柯里化后,可以讓我們函數(shù)的傳參形式變?yōu)榭吕锘暗男问剑?/p>

// example5
function sum(...args) {
    var sum = 0;
    for (let i = 0; i < args.length; i++) {
        sum += args[i];
    }
    return sum;
}

var curriedSum = curry( sum, 5 );
var uncurriedSum = uncurry( curriedSum );

curriedSum( 1 )( 2 )( 3 )( 4 )( 5 );        // 15
uncurriedSum( 1, 2, 3, 4, 5 );              // 15

注意??

但不要以為使用了反柯里化之后的函數(shù)會(huì)和原函數(shù)的行為完全一樣(也就是uncurry(curry(fn))和 fn ),雖然在某些庫中,反柯里化使函數(shù)變成和原函數(shù)(譯者注:這里的原函數(shù)指柯里化之前的函數(shù))類似的函數(shù)。

但是凡事皆有例外,例如我們上面的案例5,采用反柯里化之后,如果你少傳了實(shí)參,就會(huì)得到一個(gè)仍然在等待傳入更多實(shí)參的部分柯里化函數(shù)。我們?cè)谙旅娴拇a中說明這個(gè)怪異行為。

uncurriedSum( 1, 2, 3, 4, 5 ) // 15
uncurriedSum( 1, 2, 3 )( 4, 5 ) // 15

這兩種傳參方式都會(huì)得到相同的結(jié)果。

uncurry() 函數(shù)最為常見的作用對(duì)象很可能并不是人為生成的柯里化函數(shù)(例如上文所示),而是某些操作所產(chǎn)生的已經(jīng)被柯里化了的結(jié)果函數(shù)。我會(huì)在后面關(guān)于 “無形參風(fēng)格” 的討論中闡述這種應(yīng)用場(chǎng)景。

后語

在這一章節(jié)中,我主要介紹了函數(shù)式編程中兩個(gè)比較重要的知識(shí)點(diǎn)偏應(yīng)用柯里化,徹底的理解它們,才能繼續(xù)接下去的學(xué)習(xí)之路。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容