前言
在第一章我們主要介紹了函數(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ù):
- 一個(gè)
ajax函數(shù),第一個(gè)參數(shù)為請(qǐng)求的API地址,第二個(gè)為請(qǐng)求的參數(shù),第三個(gè)是請(qǐng)求成功之后的回調(diào)函數(shù)。
function ajax (url, data, callback) {
// ...
}
- 現(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)
}
- 現(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ì)將partial和partiallyApplied中的參數(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ù)中的
fn和presetArgs引用了嗎?他們是怎么如何工作的?在函數(shù)partial(..)結(jié)束運(yùn)行后,內(nèi)部函數(shù)為何還能訪問fn和presetArgs引用?你答對(duì)了,就是因?yàn)?strong>閉包!內(nèi)部函數(shù)partiallyApplied(..)封閉(closes over)了fn和presetArgs變量,所以無論該函數(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案例
- 介紹完上面的函數(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)
}
- 我相信大家已經(jīng)知道怎樣用
partial來寫getUser函數(shù)了
那么再進(jìn)一層,getCurrentuser函數(shù)可以怎么寫呢?
// example2
var getCurrentUser = partial(ajax, '/api/user', { userId: 1 })
哈哈??,看到這里你是否想到了還能用案例1中的getUser和partial配合:
// 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一樣通過指定url和data兩個(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í)參集合,并且將每次接收到的nextArg同prevArgs連接成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í)之路。