原文
函數(shù)柯里化
函數(shù)柯里化以Haskell Brooks Curry命名萧豆,柯里化是指將一個(gè)函數(shù)分解為一系列函數(shù)的過程,每個(gè)函數(shù)都只接收一個(gè)參數(shù)。(譯注:這些函數(shù)不會(huì)立即求值沼头,而是通過閉包的方式把傳入的參數(shù)保存起來羹令,直到真正需要的時(shí)候才會(huì)求值)
柯里化例子
以下是一個(gè)簡(jiǎn)單的柯里化例子。我們寫一個(gè)接收三個(gè)數(shù)字并返回它們總和的函數(shù)sum3
孝治。
function sum3(x, y, z) {
return x + y + z;
}
console.log(sum3(1, 2, 3)) // 6
sum3
的柯里化版本的結(jié)構(gòu)不一樣列粪。它接收一個(gè)參數(shù)并返回一個(gè)函數(shù)审磁。返回的函數(shù)。返回的函數(shù)中又接收一個(gè)餐你輸岂座,返回另一個(gè)仍然只接收一個(gè)參數(shù)的函數(shù)...(以此往復(fù))
直到返回的函數(shù)接收到最后一個(gè)參數(shù)時(shí)态蒂,這個(gè)循環(huán)才結(jié)束。這個(gè)最后的函數(shù)將會(huì)返回?cái)?shù)字的總和费什,如下所示钾恢。
function sum(x) {
return (y) => {
return (z) => {
return x + y + z
}
}
}
console.log(sum(1)(2)(3)) // 6
以上的代碼能跑起來,是因?yàn)镴avaScript支持閉包
一個(gè)閉包是由函數(shù)和聲明這個(gè)函數(shù)的詞法環(huán)境組成的
-- MDN
注意函數(shù)鏈中的最后一個(gè)函數(shù)只接收一個(gè)z
鸳址,但它同時(shí)也對(duì)外層的變量進(jìn)行操作瘩蚪,在這個(gè)例子中,這些外層的變量對(duì)于最后一個(gè)函數(shù)來說類似于全局變量稿黍。實(shí)際上只是相當(dāng)于不同函數(shù)下的局部變量
// 相當(dāng)于全局變量
let x = ...?
let y = ...?
// 只接收一個(gè)參數(shù) z 但也操作 x 和 y
return function(z) {
return x + y + z;
}
通用的柯里化
寫一個(gè)柯里化函數(shù)還好疹瘦,但如果要編寫多個(gè)函數(shù)時(shí),這就不夠用了巡球,因此我們需要一種更加通用的編寫方式言沐。
在大多數(shù)函數(shù)式編程語言中,比如haskell酣栈,我們所要做的就是定義函數(shù)险胰,它會(huì)自動(dòng)地進(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ù)名 括號(hào)中的部分
sum3 :: (Int -> Int -> Int) -> Int -- 定義柯里化函數(shù) 括號(hào)中的部分
sum3 :: Int -> Int -> Int -> (Int) -- 最后返回 括號(hào)中的部分
我們不能JS引擎重寫為curry-ify所有函數(shù)钉嘹,但是我們可以使用一個(gè)策略來實(shí)現(xiàn)鸯乃。
柯里化策略
通過上述兩種sum3
的形式發(fā)現(xiàn),實(shí)際上處理加法邏輯的函數(shù)被移動(dòng)到閉包鏈的最后一個(gè)函數(shù)中跋涣。在到達(dá)最后一級(jí)之前缨睡,我們不會(huì)在執(zhí)行環(huán)境中獲得所有需要的參數(shù)。
這意味著我們可以創(chuàng)建一個(gè)包裝哈數(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ù)都傳給這個(gè)最終執(zhí)行的函數(shù)
}
}
}
sum3(1)(2)(3) // 6
用柯里化包裹之
由于我們要使用一個(gè)包裝后的函數(shù)來替代實(shí)際的函數(shù)沛贪,因此我們可以創(chuàng)建另一個(gè)函數(shù)來包裹陋守。我們將這個(gè)新生成的函數(shù)稱之為curry —— 一個(gè)更高階的函數(shù),它的作用是返回一系列嵌套的累加器函數(shù)利赋,最后調(diào)用回調(diào)函數(shù)fn
function curry(fn) { // 定義一個(gè)包裹它們的柯里化函數(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個(gè)參數(shù)水评,1個(gè)參數(shù),2個(gè)參數(shù)等等....
遞歸的柯里化
實(shí)際上我們并不是真的要編寫多個(gè)滿足不同參數(shù)的柯里化函數(shù)媚送,而是應(yīng)當(dāng)編寫一個(gè)適用于多個(gè)參數(shù)的柯里化函數(shù)中燥。
如果我們真的寫多個(gè)curry
函數(shù),那將會(huì)如下所示...:
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 個(gè)嵌套函數(shù)
return fn(a1, a2, ... aN);
};
};
};
}
以上函數(shù)有以下特征:
- 第
i
個(gè)累加器返回另一個(gè)函數(shù)(也就是第(i+1
)個(gè)累加器)塘偎,也可以稱它為第j
個(gè)累加器疗涉。 - 第
i
個(gè)累加器接收i
個(gè)參數(shù)拿霉,同時(shí)把之前的i-1
個(gè)參數(shù)都保存其閉包環(huán)境中。 - 將會(huì)有
N
個(gè)嵌套函數(shù)咱扣,其中N
是函數(shù)fn
- 第
N
個(gè)函數(shù)總是會(huì)調(diào)用fn
函數(shù)
根據(jù)以上的特征绽淘,我們可以看到柯里化函數(shù)返回一個(gè)擁有多個(gè)相似的累加器的嵌套函數(shù)。因此我們可以使用遞歸輕松生成這樣的結(jié)構(gòu)闹伪。
function nest(fn) {
return (x) => {
// accumulator function
return nest(fn);
};
}
function curry(fn) {
return nest(fn);
}
為了避免無限嵌套下去沪铭,需要一個(gè)讓嵌套中斷的情況。我們將當(dāng)前嵌套深度存儲(chǔ)在變量i
中偏瓤,那么此條件是i === N
function nest(fn, i) {
return (x) => {
if (i === fn.length) { // 當(dāng)執(zhí)行到第 i 個(gè)時(shí)返回 fn
return fn(...);
}
return nest(fn, i + 1);
};
}
function curry(fn) {
return nest(fn, 1);
}
接下來伦意,我們需要存儲(chǔ)所有參數(shù),并把它們傳遞給fn()
硼补。最簡(jiǎn)單的解決方案就是在curry
中年創(chuàng)建一個(gè)數(shù)組args
并將其傳遞給nest
function nest(fn, i, args) {
return (x) => {
args.push(x); // 存儲(chǔ)每一個(gè)參數(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);
}
然后再添加一個(gè)沒有參數(shù)時(shí)的臨界處理:
function curry(fn) {
if (fn.length === 0) { // 當(dāng)沒有參數(shù)時(shí)直接返回
return fn;
}
const args = [];
return nest(fn, 1, args);
}
此時(shí)來測(cè)試一下我們的代碼:
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)行測(cè)試
優(yōu)化
對(duì)于初學(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);
}
讓我們把這個(gè)新的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ù)個(gè)數(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!
毫無意外辛藻,它是正確的,但這個(gè)代碼是有點(diǎn)惡心互订。
在haskell和許多其他函數(shù)式語言中吱肌,它們的設(shè)計(jì)更為簡(jiǎn)潔,和上面惡心的相比仰禽,我們來看看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)用會(huì)更好:
sum5(1, 2, 3, 4, 5) // 15
但這并不意味著我們不得不放棄currying。我們能做到的是找到一個(gè)兩全其美的方式吐葵。一個(gè)即是“柯里化”又不是“柯里化”的調(diào)用方式规揪。
sum3(1, 2, 3) // 清晰的
sum3(1, 2)(3)
sum3(1)(2, 3)
sum3(1)(2)(3) // 柯里化的
因此我們需要做一個(gè)簡(jiǎn)單的修改——用可變函數(shù)替換累加器函數(shù)。
當(dāng)?shù)?code>i個(gè)累加器接收k
個(gè)參數(shù)時(shí)温峭,下一個(gè)累加器將不是N-1
的深度猛铅,而是N-k``的深度。使用
N-1```是由于所有的累加器都只接收一個(gè)參數(shù)诚镰,這也意味著我們不再需要判斷參數(shù)為0的情況(Why?)奕坟。
由于我們現(xiàn)在每個(gè)層級(jí)都收集多個(gè)參數(shù)祥款,我們需要檢查參數(shù)的數(shù)量來判斷是否超過fn
的參數(shù)個(gè)數(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, []);
}
接下來是測(cè)試時(shí)間刃跛,你可以在codepen上運(yùn)行測(cè)試。
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í)苛萎,我們可以不向它傳遞任何參數(shù)來調(diào)用累加器函數(shù)桨昙。這將返回另一個(gè)與前一個(gè)累加器相同的累加器。
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)用十分惡心腌歉,有一系列的空括號(hào)蛙酪。雖然技術(shù)上沒有問題,但這個(gè)寫法是很糟糕的翘盖,因此需要有一個(gè)避免這種糟糕寫法的方式桂塞。
function curry(fn) {
function nest(N, args) {
return (...xs) => {
if (xs.length === 0) { // 避免空括號(hào)
throw Error('EMPTY INVOCATION');
}
// ...
};
}
return nest(fn.length, []);
}
另一種柯里化的方式
我們成功了!我們創(chuàng)造了一個(gè)curry
函數(shù)馍驯,它接收多個(gè)函數(shù)參數(shù)并返回帶有可變參數(shù)的柯里化函數(shù)阁危。但我想展示JavaScript中的另一種柯里化方法
在JavaScript中,我們可以將參數(shù)bind(綁定)到函數(shù)并創(chuàng)建綁定副本汰瘫。返回的函數(shù)是只是“部分應(yīng)用”狂打,因?yàn)楹瘮?shù)已經(jīng)擁有它所需的一些參數(shù),但在調(diào)用之前需要更多混弥。
到目前為止趴乡,curry
將返回一個(gè)函數(shù),該函數(shù)在收到所有參數(shù)之前在不停地累積參數(shù)蝗拿,然后使用這些參數(shù)來調(diào)用fn
晾捏。通過將參數(shù)綁(譯注:bind方法)定到函數(shù),我們可以消除對(duì)多個(gè)嵌套累加器函數(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
采用多個(gè)參數(shù)的函數(shù)并返回累加器函數(shù)。當(dāng)用k
個(gè)參數(shù)調(diào)用累加器時(shí)萤捆,我們檢查k>=N
裙品,即判斷是否滿足函數(shù)所需的參數(shù)個(gè)數(shù)。
如果滿足俗或,我們傳入?yún)?shù)并調(diào)用fn市怎,如果沒滿足,則創(chuàng)建一個(gè)fn的副本辛慰,它具有綁定調(diào)用fn前的那些累加器的k
個(gè)參數(shù)区匠,并將其作為下一個(gè)fn
傳遞給curry
,以達(dá)到減少N-k
的目的。
最后
我們通過累加器的方式編寫了通用的柯里化方法驰弄。這種方法適用于函數(shù)是一等公民的語言麻汰。我們看到了嚴(yán)格的柯里化和可變參數(shù)的柯里化之間的區(qū)別。感謝JavaScript中提供了bind方法戚篙,用bind方法實(shí)現(xiàn)柯里化是非常容易的五鲫。
如果您對(duì)源代碼感興趣,請(qǐng)戳codepen岔擂。
后記
給柯里化添加靜態(tài)類型檢查
在2018年位喂,人們喜歡JavaScript中的靜態(tài)類型。而且我認(rèn)為現(xiàn)在是時(shí)候添加一些類型約束以保證類型安全了乱灵。
讓我們從基礎(chǔ)開始:curry()
接收一個(gè)函數(shù)并返回一個(gè)值或另一個(gè)函數(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)行時(shí)的依賴性蝉稳。此外聋呢,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ù)的柯里化是一個(gè)很好的東西,因?yàn)樗鼮槲覀兲峁┝艘恍┛臻g通铲。但是毕莱,我們不要忘記,嚴(yán)格意義上的柯里化應(yīng)該只接收一個(gè)參數(shù)颅夺。
... 柯里化是將函數(shù)分解為一系列函數(shù)的過程朋截,每個(gè)函數(shù)都接收一個(gè)參數(shù)
讓我們編寫一個(gè)嚴(yán)格的柯里化函數(shù)——一種只允許單個(gè)參數(shù)傳遞個(gè)柯里化函數(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