原文
函數(shù)柯里化
函數(shù)柯里化以Haskell Brooks Curry命名,柯里化是指將一個函數(shù)分解為一系列函數(shù)的過程,每個函數(shù)都只接收一個參數(shù)。(譯注:這些函數(shù)不會立即求值,而是通過閉包的方式把傳入的參數(shù)保存起來,直到真正需要的時候才會求值)
柯里化例子
以下是一個簡單的柯里化例子。我們寫一個接收三個數(shù)字并返回它們總和的函數(shù)sum3。
function sum3(x, y, z) {
return x + y + z;
}
console.log(sum3(1, 2, 3)) // 6
sum3的柯里化版本的結(jié)構(gòu)不一樣。它接收一個參數(shù)并返回一個函數(shù)。返回的函數(shù)。返回的函數(shù)中又接收一個餐你輸,返回另一個仍然只接收一個參數(shù)的函數(shù)...(以此往復(fù))
直到返回的函數(shù)接收到最后一個參數(shù)時,這個循環(huán)才結(jié)束。這個最后的函數(shù)將會返回數(shù)字的總和,如下所示。
function sum(x) {
return (y) => {
return (z) => {
return x + y + z
}
}
}
console.log(sum(1)(2)(3)) // 6
以上的代碼能跑起來,是因?yàn)镴avaScript支持閉包
一個閉包是由函數(shù)和聲明這個函數(shù)的詞法環(huán)境組成的
-- MDN
注意函數(shù)鏈中的最后一個函數(shù)只接收一個z,但它同時也對外層的變量進(jìn)行操作,在這個例子中,這些外層的變量對于最后一個函數(shù)來說類似于全局變量。實(shí)際上只是相當(dāng)于不同函數(shù)下的局部變量
// 相當(dāng)于全局變量
let x = ...?
let y = ...?
// 只接收一個參數(shù) z 但也操作 x 和 y
return function(z) {
return x + y + z;
}
通用的柯里化
寫一個柯里化函數(shù)還好,但如果要編寫多個函數(shù)時,這就不夠用了,因此我們需要一種更加通用的編寫方式。
在大多數(shù)函數(shù)式編程語言中,比如haskell,我們所要做的就是定義函數(shù),它會自動地進(jìn)行柯里化。
let sum3 x y z = x + y + z
sum3 1 2 3
-- 6
:t sum3 -- print the type of sum3()
-- sum3 :: Int -> Int -> Int -> Int
(sum3) :: Int -> Int -> Int -> Int -- 函數(shù)名 括號中的部分
sum3 :: (Int -> Int -> Int) -> Int -- 定義柯里化函數(shù) 括號中的部分
sum3 :: Int -> Int -> Int -> (Int) -- 最后返回 括號中的部分
我們不能JS引擎重寫為curry-ify所有函數(shù),但是我們可以使用一個策略來實(shí)現(xiàn)。
柯里化策略
通過上述兩種sum3的形式發(fā)現(xiàn),實(shí)際上處理加法邏輯的函數(shù)被移動到閉包鏈的最后一個函數(shù)中。在到達(dá)最后一級之前,我們不會在執(zhí)行環(huán)境中獲得所有需要的參數(shù)。
這意味著我們可以創(chuàng)建一個包裝哈數(shù)來收集這些參數(shù),然后把它們傳遞給實(shí)際要執(zhí)行的函數(shù) (sum3)。所有中間嵌套的函數(shù)都稱為累加器函數(shù) - 至少我們可以這樣稱呼它們。
function _sum3(x, y, z) {
return x + y + z;
}
function sum3(x) {
return (y) => {
return (z) => {
return _sum3(x, y, z); // 把參數(shù)都傳給這個最終執(zhí)行的函數(shù)
}
}
}
sum3(1)(2)(3) // 6
用柯里化包裹之
由于我們要使用一個包裝后的函數(shù)來替代實(shí)際的函數(shù),因此我們可以創(chuàng)建另一個函數(shù)來包裹。我們將這個新生成的函數(shù)稱之為curry —— 一個更高階的函數(shù),它的作用是返回一系列嵌套的累加器函數(shù),最后調(diào)用回調(diào)函數(shù)fn
function curry(fn) { // 定義一個包裹它們的柯里化函數(shù)
return (x) => {
return (y) => {
return (z) => {
return fn(x, y, z); // 調(diào)用回調(diào)函數(shù)
};
};
};
}
const sum = curry((x, y, z) => { // 傳入回調(diào)函數(shù)
return x + y + z;
});
sum3(1)(2)(3) // 6
現(xiàn)在我們需要滿足有不同參數(shù)的柯里化函數(shù):它可能有0個參數(shù),1個參數(shù),2個參數(shù)等等....
遞歸的柯里化
實(shí)際上我們并不是真的要編寫多個滿足不同參數(shù)的柯里化函數(shù),而是應(yīng)當(dāng)編寫一個適用于多個參數(shù)的柯里化函數(shù)。
如果我們真的寫多個curry函數(shù),那將會如下所示...:
function curry0(fn) {
return fn();
}
function curry1(fn) {
return (a1) => {
return fn(a1);
};
}
function curry2(fn) {
return (a1) => {
return (a2) => {
return fn(a1, a2);
};
};
}
function curry3(fn) {
return (a1) => {
return (a2) => {
return (a3) => {
return fn(a1, a2, a3);
};
};
};
}
...
function curryN(fn){
return (a1) => {
return (a2) => {
...
return (aN) => {
// N 個嵌套函數(shù)
return fn(a1, a2, ... aN);
};
};
};
}
以上函數(shù)有以下特征:
- 第
i個累加器返回另一個函數(shù)(也就是第(i+1)個累加器),也可以稱它為第j個累加器。 - 第
i個累加器接收i個參數(shù),同時把之前的i-1個參數(shù)都保存其閉包環(huán)境中。 - 將會有
N個嵌套函數(shù),其中N是函數(shù)fn - 第
N個函數(shù)總是會調(diào)用fn函數(shù)
根據(jù)以上的特征,我們可以看到柯里化函數(shù)返回一個擁有多個相似的累加器的嵌套函數(shù)。因此我們可以使用遞歸輕松生成這樣的結(jié)構(gòu)。
function nest(fn) {
return (x) => {
// accumulator function
return nest(fn);
};
}
function curry(fn) {
return nest(fn);
}
為了避免無限嵌套下去,需要一個讓嵌套中斷的情況。我們將當(dāng)前嵌套深度存儲在變量i中,那么此條件是i === N
function nest(fn, i) {
return (x) => {
if (i === fn.length) { // 當(dāng)執(zhí)行到第 i 個時返回 fn
return fn(...);
}
return nest(fn, i + 1);
};
}
function curry(fn) {
return nest(fn, 1);
}
接下來,我們需要存儲所有參數(shù),并把它們傳遞給fn()。最簡單的解決方案就是在curry中年創(chuàng)建一個數(shù)組args并將其傳遞給nest
function nest(fn, i, args) {
return (x) => {
args.push(x); // 存儲每一個參數(shù)
if (i === fn.length) {
return fn(...args); // 最后把參數(shù)都傳遞給 fn()
}
return nest(fn, i + 1, args);
};
}
function curry(fn) {
const args = []; // 需要傳入的參數(shù)列表
return nest(fn, 1, args);
}
然后再添加一個沒有參數(shù)時的臨界處理:
function curry(fn) {
if (fn.length === 0) { // 當(dāng)沒有參數(shù)時直接返回
return fn;
}
const args = [];
return nest(fn, 1, args);
}
此時來測試一下我們的代碼:
const log1 = curry((x) => console.log(x));
log1(10); // 10
const log2 = curry((x, y) => console.log(x, y));
log2(10)(20); // 10 20
你可以在codepen上運(yùn)行測試
優(yōu)化
對于初學(xué)者,我們可以在把nest放到curry中,從而可以通過在閉包中讀取fn和args來,以此減少傳給nest的參數(shù)數(shù)量。
function curry(fn) {
if (fn.length === 0) {
return fn;
}
const args = [];
function nest(i) { // 相比于之前,不用傳遞 fn 和 args
return (x) => {
args.push(x);
if (i === fn.length) {
return fn(...args);
}
return nest(i + 1);
};
}
return nest(1);
}
讓我們把這個新的curry變得更加函數(shù)式,而不是依賴于閉包變量。我們通過提供args和fn.length作為參數(shù)嵌套來實(shí)現(xiàn)。此外,我們把剩余的遞歸深度(譯注:也就是除最后一層的函數(shù)),而不是傳遞目標(biāo)深度(fn.length)進(jìn)行比較。
function curry(fn) {
if (fn.length === 0) {
return fn;
}
function nest(N, args) {
return (x) => {
if (N - 1 === 0) {
return fn(...args, x);
}
return nest(N - 1, [...args, x]); // 根據(jù)fn.length - 1 遞歸那些嵌套的中間函數(shù)
};
}
return nest(fn.length, []); // 傳入 fn 的參數(shù)個數(shù)
}
可變的柯里化
讓我們來比較sum3和sum5
const sum3 = curry((x, y, z) => {
return x + y + z;
});
const sum5 = curry((a, b, c, d, e) => {
return a + b + c + d + e;
});
sum3(1)(2)(3) // 6 <-- It works!
sum5(1)(2)(3)(4)(5) // 15 <-- It works!
毫無意外,它是正確的,但這個代碼是有點(diǎn)惡心。
在haskell和許多其他函數(shù)式語言中,它們的設(shè)計更為簡潔,和上面惡心的相比,我們來看看haskell是如何處理它的:
let sum3 x y z = x + y + z
let sum5 a b c d e = a + b + c + d + e
sum3 1 2 3
> 6
sum5 1 2 3 4 5
> 15
sum5 1 2 3 (sum3 1 2 3) 5
> 17
如果你問我,JavaScript以下面的使用方式來調(diào)用會更好:
sum5(1, 2, 3, 4, 5) // 15
但這并不意味著我們不得不放棄currying。我們能做到的是找到一個兩全其美的方式。一個即是“柯里化”又不是“柯里化”的調(diào)用方式。
sum3(1, 2, 3) // 清晰的
sum3(1, 2)(3)
sum3(1)(2, 3)
sum3(1)(2)(3) // 柯里化的
因此我們需要做一個簡單的修改——用可變函數(shù)替換累加器函數(shù)。
當(dāng)?shù)?code>i個累加器接收k個參數(shù)時,下一個累加器將不是N-1的深度,而是N-k``的深度。使用N-1```是由于所有的累加器都只接收一個參數(shù),這也意味著我們不再需要判斷參數(shù)為0的情況(Why?)。
由于我們現(xiàn)在每個層級都收集多個參數(shù),我們需要檢查參數(shù)的數(shù)量來判斷是否超過fn的參數(shù)個數(shù),然后再調(diào)用它。
function curry(fn) {
function nest(N, args) {
return (...xs) => {
if (N - xs.length <= 0) {
return fn(...args, ...xs);
}
return nest(N - xs.length, [...args, ...xs]);
};
}
return nest(fn.length, []);
}
接下來是測試時間,你可以在codepen上運(yùn)行測試。
function curry(){...}
const sum3 = curry((x, y, z) => x + y + z);
console.log(
sum3(1, 2, 3),
sum3(1, 2)(3),
sum3(1)(2, 3),
sum3(1)(2)(3),
);
// 6 6 6 6
調(diào)用空的累加器
當(dāng)使用可變參數(shù)的柯里化時,我們可以不向它傳遞任何參數(shù)來調(diào)用累加器函數(shù)。這將返回另一個與前一個累加器相同的累加器。
const sum3 = curry((x, y, z) => x + y + z);
sum3(1,2,3) // 6
sum3()()()(1,2,3) // 6
sum3(1)(2,3) // 6
sum3()()()(1)()()(2,3) // 6
這種調(diào)用十分惡心,有一系列的空括號。雖然技術(shù)上沒有問題,但這個寫法是很糟糕的,因此需要有一個避免這種糟糕寫法的方式。
function curry(fn) {
function nest(N, args) {
return (...xs) => {
if (xs.length === 0) { // 避免空括號
throw Error('EMPTY INVOCATION');
}
// ...
};
}
return nest(fn.length, []);
}
另一種柯里化的方式
我們成功了!我們創(chuàng)造了一個curry函數(shù),它接收多個函數(shù)參數(shù)并返回帶有可變參數(shù)的柯里化函數(shù)。但我想展示JavaScript中的另一種柯里化方法
在JavaScript中,我們可以將參數(shù)bind(綁定)到函數(shù)并創(chuàng)建綁定副本。返回的函數(shù)是只是“部分應(yīng)用”,因?yàn)楹瘮?shù)已經(jīng)擁有它所需的一些參數(shù),但在調(diào)用之前需要更多。
到目前為止,curry將返回一個函數(shù),該函數(shù)在收到所有參數(shù)之前在不停地累積參數(shù),然后使用這些參數(shù)來調(diào)用fn。通過將參數(shù)綁(譯注:bind方法)定到函數(shù),我們可以消除對多個嵌套累加器函數(shù)。
因此可以得到:
function curry(fn) {
return (...xs) => {
if (xs.length === 0) {
throw Error('EMPTY INVOCATION');
}
if (xs.length >= fn.length) {
return fn(...xs);
}
return curry(fn.bind(null, ...xs));
};
}
以上是它的工作原理。curry采用多個參數(shù)的函數(shù)并返回累加器函數(shù)。當(dāng)用k個參數(shù)調(diào)用累加器時,我們檢查k>=N,即判斷是否滿足函數(shù)所需的參數(shù)個數(shù)。
如果滿足,我們傳入?yún)?shù)并調(diào)用fn,如果沒滿足,則創(chuàng)建一個fn的副本,它具有綁定調(diào)用fn前的那些累加器的k個參數(shù),并將其作為下一個fn傳遞給curry,以達(dá)到減少N-k的目的。
最后
我們通過累加器的方式編寫了通用的柯里化方法。這種方法適用于函數(shù)是一等公民的語言。我們看到了嚴(yán)格的柯里化和可變參數(shù)的柯里化之間的區(qū)別。感謝JavaScript中提供了bind方法,用bind方法實(shí)現(xiàn)柯里化是非常容易的。
如果您對源代碼感興趣,請戳codepen。
后記
給柯里化添加靜態(tài)類型檢查
在2018年,人們喜歡JavaScript中的靜態(tài)類型。而且我認(rèn)為現(xiàn)在是時候添加一些類型約束以保證類型安全了。
讓我們從基礎(chǔ)開始:curry()接收一個函數(shù)并返回一個值或另一個函數(shù)。我們可以這樣寫:
type Curry = <T>(Function) => T | Function;
const curry: Curry = (fn) => {
...
}
// function declaration
function curry<T>(fn: Function): T | Function {
...
}
好了。但是這并沒有什么用。但這是能做到最好的程度了,F(xiàn)low只增加了靜態(tài)類型的安全性,而實(shí)際上我們有很多運(yùn)行時的依賴性。此外,F(xiàn)low不支持Haskell具有的跟更高階類型。這意味著沒有為這種通用的柯里化添加更緊密的類型檢查。
If you still want a typed curry, here’s a gist by zerobias that show a 2-level and a 3-level curry function with static types: zerobias/92a48e1.
If you want to read more about curry and JS, here’s an article on 2ality.
嚴(yán)格意義上的柯里化
可變參數(shù)的柯里化是一個很好的東西,因?yàn)樗鼮槲覀兲峁┝艘恍┛臻g。但是,我們不要忘記,嚴(yán)格意義上的柯里化應(yīng)該只接收一個參數(shù)。
... 柯里化是將函數(shù)分解為一系列函數(shù)的過程,每個函數(shù)都接收一個參數(shù)
讓我們編寫一個嚴(yán)格的柯里化函數(shù)——一種只允許單個參數(shù)傳遞個柯里化函數(shù)。
function strictCurry(fn) {
return (x) => {
if (fn.length <= 1) {
return fn(x);
}
return strictCurry(fn.bind(null, x));
};
}
const ten = () => 10;
const times10 = (x) => 10 * x;
const multiply = (x, y) => x * y;
console.log(strictCurry(ten)()) // 10
console.log(strictCurry(times10)(123)) // 1230
console.log(strictCurry(multiply)(123)(10)) // 1230