簡介
首先,柯里化(Currying)是什么呢?
簡單說,假如有一個函數(shù),接受多個參數(shù),那么一般來說就是一次性傳入所有參數(shù)并執(zhí)行。而對其執(zhí)行柯里化后,就變成了可以分多次接收參數(shù)。
在這里小編建了一個前端學(xué)習(xí)交流扣扣群:1093794329,我自己整理的最新的前端資料和高級開發(fā)教程,如果有想需要的,可以加群一起學(xué)習(xí)交流
實現(xiàn)
階段1
現(xiàn)在有一個加法函數(shù):
function add(x, y, z) {? ? return x + y + z}
調(diào)用方式是 add(1, 2, 3)。
如果執(zhí)行柯里化,變成了 curriedAdd(),從效果來說,大致就是變成 curriedAdd(1)(2)(3) 這樣子。
現(xiàn)在先不看怎么對原函數(shù)執(zhí)行柯里化,而是根據(jù)這個調(diào)用方式重新寫一個函數(shù)。代碼可能是這樣的:
function curriedAdd1(x) {? ? return function (y) {? ? ? ? return function (z) {? ? ? ? ? ? return x + y + z? ? ?? }? ? }}
階段2
假如現(xiàn)在想要升級一下,不止可以接受三個參數(shù)。可以使用 arguments,或者使用展開運算符來處理傳入的參數(shù)。
但是有一個衍生的問題。因為之前每次只能傳遞一個,總共只能傳遞三個,才保證了調(diào)用三次之后參數(shù)個數(shù)剛好足夠,函數(shù)才能執(zhí)行。
既然我們打算修改為可以接受任意個數(shù)的參數(shù),那么就要規(guī)定一個終點。比如說,可以規(guī)定為當(dāng)不再傳入?yún)?shù)的時候,就執(zhí)行函數(shù)。
下面是使用 arguments 的實現(xiàn)。
function getCurriedAdd() {? ? // 在外部維護一個數(shù)組保存?zhèn)鬟f的變量? ? let args_arr = []? ? // 返回一個閉包? ? let closure = function () {? ? ?? // 本次調(diào)用傳入的參數(shù)? ? ? ? let args = Array.prototype.slice.call(arguments)? ? ? ? // 如果傳進了新的參數(shù)? ? ?? if (args.length > 0) {? ? ? ? ? ? // 保存參數(shù)? ? ? ? ?? args_arr = args_arr.concat(args)? ? ? ? ? ? // 再次返回閉包,等待下次調(diào)用? ? ? ? ? ? // 也可以 return arguments.callee? ? ? ? ? ? return closure? ? ? ? ? }? ? // 沒有傳遞參數(shù),執(zhí)行累加? ? return args_arr.reduce((total, current) => total + current)? }? return closure}curriedAdd = getCurriedAdd()curriedAdd(1)(2)(3)(4)()復(fù)制代碼
階段3
這時可以發(fā)現(xiàn),上面的整個函數(shù)里,與函數(shù)具體功能(在這里就是執(zhí)行加法)有關(guān)的,就只是當(dāng)沒有傳遞參數(shù)時的部分,其他部分都是在實現(xiàn)怎樣多次接收參數(shù)。
那么,只要讓 getCurriedAdd 接受一個函數(shù)作為參數(shù),把沒有傳遞參數(shù)時的那一行代碼替換一下,就可以實現(xiàn)一個通用的柯里化函數(shù)了。
把上面的修改一下,實現(xiàn)一個通用柯里化函數(shù),并把一個階乘函數(shù)柯里化:
function currying(fn) {? ? let args_arr = []? ? let closure =? function (...args) {? ? ? ? if (args.length > 0) {? ? ? ? ? ? args_arr = args_arr.concat(args)? ? ? ? ? ? return closure? ? ?? }? ? ? ? // 沒有新的參數(shù),執(zhí)行函數(shù)? ? ? ? return fn(...args_arr)?? }? ? return closure}function multiply(...args) {?? return args.reduce((total, current) => total * current)}curriedMultiply = currying(multiply)console.log(curriedMultiply(2)(3, 4)()
階段4
上面的代碼里,對于函數(shù)執(zhí)行時機的判斷,是根據(jù)是否有參數(shù)傳入。但是更多時候,更合理的依據(jù)是原函數(shù)可以接受的參數(shù)的總數(shù)。
函數(shù)名的 length 屬性就是該函數(shù)接受的參數(shù)個數(shù)。比如:
function test1(a, b) {}function test2(...args){}console.log(test1.length) // 2console.log(test2.length) // 0
改寫一下:
function currying(fn) {? ? let args_arr = [],? ? ?? max_length = fn.length? let closure = function (...args) {? ? // 先把參數(shù)加進去? ? args_arr = args_arr.concat(args)? ? // 如果參數(shù)沒滿,返回閉包等待下一次調(diào)用? ? if (args_arr.length < max_length) return closure? ? // 傳遞完成,執(zhí)行? ? return fn(...args_arr)? }? return closure}function add(x, y, z) {? return x + y + z}curriedAdd = currying(add)console.log(curriedAdd(1, 2)(3))復(fù)制代碼
Lodash 中的柯里化
讓我們先看一下 lodash.js 的文檔,看看一個真正的 curry 方法到底是做什么的。
var abc = function(a, b, c) { return [a, b, c];};var curried = _.curry(abc);curried(1)(2)(3); // => [1, 2, 3]curried(1, 2)(3); // => [1, 2, 3]curried(1, 2, 3); // => [1, 2, 3]// Curried with placeholders.curried(1)(_, 3)(2); // => [1, 2, 3]
在我理解看來,curry 能夠讓我們:
在多個函數(shù)調(diào)用中逐步收集參數(shù),不用在一個函數(shù)調(diào)用中一次收集。
當(dāng)收集到足夠的參數(shù)時,返回函數(shù)執(zhí)行結(jié)果。
為了更好的理解它,我在網(wǎng)上找了多個實現(xiàn)示例。然而,我希望是有一個非常簡單的教程從一個基本的例子開始,就像下面這個一樣,而不是直接從最終的實現(xiàn)開始。
var fn = function() {? console.log(arguments);? return fn.bind(null, ...arguments);? // 如果沒有es6的話我們可以這樣寫:? // return Function.prototype.bind.apply(fn, [null].concat(? //?? Array.prototype.slice.call(arguments)? // ));}fb = fn(1); //[1]fb = fb(2); //[1, 2]fb = fb(3); //[1, 2, 3]fb = fb(4); //[1, 2, 3, 4]
理解 fn 函數(shù)是所有的起點?;旧?,這個函數(shù)的作用就是一個“參數(shù)收集器”。每次調(diào)用該函數(shù)時,它都會返回一個自身的綁定函數(shù)(fb),并且將該函數(shù)提供的“參數(shù)”綁定到返回函數(shù)上。該“參數(shù)”將位于之后調(diào)用返回的綁定函數(shù)時提供的任何參數(shù)之前。因此,每個調(diào)用中傳的參數(shù)將被逐漸收集到一個數(shù)組當(dāng)中。
當(dāng)然,就像 curry 函數(shù)一樣,我們不必一直收集下去?,F(xiàn)在我們可以先寫死一個終止點。
var numOfRequiredArguments = 5;var fn = function() {? if (arguments.length < numOfRequiredArguments) {? ? return fn.bind(null, ...arguments);? } else {? ? console.log('we already collect 5 arguments: ', [...arguments]);? ? return null;? }}
為了讓它表現(xiàn)得和 curry 方法一樣,需要解決兩個問題:
我們希望將收集到的參數(shù)傳遞給需要它們的目標函數(shù),而不是通過將它們傳遞給 console.log 在最后打印出來。
變量 numOfRequiredArguments 不應(yīng)該是寫死的,它應(yīng)該是目標函數(shù)所期望的參數(shù)個數(shù)。
幸運的是,JavaScript函數(shù)確實帶有一個名為 “l(fā)ength” 的屬性,它指定了函數(shù)所期望的參數(shù)個數(shù)。因此,我們就可以使用這個屬性來確定所需要的參數(shù)個數(shù),而不用再寫死了。那么第二個問題就解決了。
那第一個問題呢:保持對目標函數(shù)的引用?
網(wǎng)上有幾個例子可以解決這個問題。它們之間雖然略有不同,但是有著相同的思路:除去存儲參數(shù)以外,我們還需要在某處存儲對于目標函數(shù)的引用。
這里我把它們分為兩種不同的方法,它們之間或多或少都有相似之處,理解它們能夠幫助我們更好地理解背后的邏輯。順便說一句,這里我將這個函數(shù)叫做 magician,以代替 curry。
方法1
function magician(targetfn) {? var numOfArgs = targetfn.length;? return function fn() {? ? if (arguments.length < numOfArgs) {? ? ? return fn.bind(null, ...arguments);? ? } else {? ? ? return targetfn.apply(null, arguments);? ? }? }}
magician 函數(shù)的作用是:它接收目標函數(shù)作為參數(shù),然后返回‘參數(shù)收集器’函數(shù),與上例中 fn 函數(shù)作用相同。唯一的不同點在于,當(dāng)收集的參數(shù)數(shù)量與目標函數(shù)所必需的參數(shù)數(shù)量相等時,它將把收集到的參數(shù)通過 apply 方法給到該目標函數(shù),并返回計算的結(jié)果。這個方法通過將其存儲在 magician 創(chuàng)建的閉包當(dāng)中來解決第一個問題(引用目標函數(shù))。
方法2
這個方法更進一步,由于參數(shù)收集器函數(shù)只是一個普通函數(shù),那為什么不使用 magician 函數(shù)本身作為參數(shù)收集器呢?
function magician (targetfn) {? var numOfArgs = targetfn.length;? if (arguments.length - 1 < numOfArgs) {? ? return magician.bind(null, ...arguments);? } else {? ? return targetfn.apply(null, Array.prototype.slice.call(arguments, 1));? }}
注意方法2中的一個不同。因為 magician 接收目標函數(shù)作為它的第一個參數(shù),因此收集到的參數(shù)將始終包含該函數(shù)作為 arguments[0]。這就導(dǎo)致,我們在檢查有效參數(shù)的總數(shù)時,需要減去第一個參數(shù)。
順便說一句,因為目標函數(shù)是遞歸地傳遞給 magician 函數(shù)的,所以我們可以通過傳入第一個參數(shù)顯式地引用目標函數(shù),以代替使用閉包來存儲目標函數(shù)的引用。
正如你所見,Eric Elliott 上面使用到的 “curry” 函數(shù)和方法1功能相似,但實際上它是一個偏函數(shù)(這又是另外一說了)。
const curry = fn => (…args) => fn.bind(null, …args);
上面是一個 curry 函數(shù),它返回“參數(shù)收集器”,該收集器只收集一次參數(shù),并返回綁定的目標函數(shù)。
更進一步
上面的‘magician’函數(shù)仍然沒有l(wèi)odash.js中的‘curry’函數(shù)那樣神奇。lodash的curry允許使用‘_’作為輸入?yún)?shù)的占位符。
curried(1)(_, 3)(2); // => [1, 2, 3], 注意占位符 '_'
為了實現(xiàn)占位符功能,有一個隱含的需求:我們需要知道哪些參數(shù)被預(yù)設(shè)給了綁定函數(shù),以及哪些是在調(diào)用函數(shù)時顯示提供的附加參數(shù)(這里我們稱之為added參數(shù))。
這個功能可以通過創(chuàng)建另外一個閉包來完成:
function fn2() {? var preset = Array.prototype.slice.call(arguments);? /*? ? 原先是這樣:? ? return fn.bind(null, ...arguments);? */? return function helper() {? ? var added = Array.prototype.slice.call(arguments);? ? return fn2.apply(null, [...preset, ...added]); //簡單起見,使用es6? }}
上面的 fn2 幾乎和 fn 一樣,功能就像‘參數(shù)收集器’一樣。然而,fn2 不是直接返回綁定函數(shù),而是返回一個中間輔助函數(shù) helper。helper 函數(shù)是未綁定的,因此它可以用來分離預(yù)設(shè)的參數(shù)和后來提供的參數(shù)。
當(dāng)然,我們需要在組合時進行一些修改,而不是通過 [...preset, ...added] 將預(yù)設(shè)的參數(shù)和后來提供的參數(shù)合并起來。我們需要在preset參數(shù)中找到占位符的位置,并用有效的added參數(shù)替換它。我沒有看lodash是如何實現(xiàn)它的,但下面是一個完成類似功能的簡單實現(xiàn)。
// 定義占位符var _ = '_';function magician3 (targetfn, ...preset) {? var numOfArgs = targetfn.length;? var nextPos = 0; // 下一個有效輸入位置的索引,可以是'_',也可以是preset的結(jié)尾? // 查看是否有足夠的有效參數(shù)? if (preset.filter(arg=> arg !== _).length === numOfArgs) {? ? return targetfn.apply(null, preset);? } else {? ? // 返回'helper'函數(shù)? ? return function (...added) {? ? ? // 循環(huán)并將added參數(shù)添加到preset參數(shù)? ? ? while(added.length > 0) {? ? ? ? var a = added.shift();? ? ? ? // 獲取下一個占位符的位置,可以是'_'也可以是preset的末尾? ? ? ? while (preset[nextPos] !== _ && nextPos < preset.length) {? ? ? ? ? nextPos++? ? ? ? }? ? ? ? // 更新preset? ? ? ? preset[nextPos] = a;? ? ? ? nextPos++;? ? ? }? ? ? // 綁定更新后的preset? ? ? return magician3.call(null, targetfn, ...preset);? ? }? }}
第15到24行是用于將added參數(shù)放入preset數(shù)組中正確位置的邏輯:無論是占位符或是preset的結(jié)尾。該位置被標記為 nextPos 并初始化為索引0。
現(xiàn)在,函數(shù) magician3 幾乎已經(jīng)和lodash的curry函數(shù)功能相當(dāng)了。