
壹 ❀ 引
很久之前看到過的一道面試題,最近復習又遇到了,這里簡單做個整理,本題考點主要是函數柯里化,所以在實現前還是簡單介紹什么是柯里化。
貳 ❀ 函數柯里化(Currying)
所謂函數柯里化,其實就是把一個接受多個參數的函數,轉變成接受一個單一參數,且返回接受剩余參數並能返回結果的新函數的技術。舉個最簡單的例子:
const add = (a, b) => a + b;
add(1, 2);
add
是一個求和的函數,它接受2個參數,但假設我們將其變為柯里化函數,它應該接受一個參數,並返回一個接受剩余參數,且依舊能求出相同結果的函數:
const add = () => {//...};
// 接受第一個參數且返回了一個新函數
const add_ = add(1);
// 新函數接受剩余參數,最終得出最終結果
add_(2);
// 簡約來寫就是
add(1)(2);
說到底,函數柯里化的概念其實也離不開閉包,函數A接受一個參數(閉包中的自由變量)且返回一個新函數B(閉包),而函數A明明已執行並釋放,當函數B執行時依舊能訪問A函數當時所接參數。
叄 ❀ 實現add(1)(2)(3)
只要將閉包的概念帶入進來,我們將上面的add
改寫為柯里化就非常簡單了,如下:
const addCurry = (a) => (b) => a + b;
// 等同於
const addCurry = function (a) {
return function (b) {
return a + b;
}
};
addCurry(1)(2);

可以看到在執行到內部函數時,作用域很明確的標明了這是一個閉包,且訪問了自由變量a
;
那么回到題目add(1)(2)(3)
怎么實現呢?還是一樣的,既然你能調用3次,說明函數內部等嵌套返回2次函數,比如:
const addCurry = (a) => (b) => (c) => a + b + c;
console.log(addCurry(1)(2)(3));// 6
// 等同於
const addCurry = function (a) {
return function (b) {
return function (c) {
return a + b + c;
}
}
}
肆 ❀ 固定形參的任意實參
上面關於add(1)(2)(3)
實現中,我們其實是固定了一次傳遞一個參數,那么現在我們將問題升級,需要定義一個柯里化函數,它能接受任意數量的參數,比如:
addCurry(1, 2, 3);
addCurry(1)(2)(3);
addCurry(1, 2)(3);
addCurry(1)(2, 3);
怎么做?對於addCurry
函數自身而言,我們確定它最多同時接受3個參數,如果是三個參數就應該直接返回結果,但如果不足3個參數應該返回一個新函數,而返回新函數又有addCurry(1)(2, 3)
與addCurry(1)(2)(3)
兩種形式,對於這種不確定調用幾次的,內部一定得存在一個遞歸。那么尷尬的又來了,addCurry
要返回新函數調用,那計算的結果誰來返回?所以這里一定得存在一個限制,它是跳出遞歸以及返回最終結果的核心因素。
const curry = function (fn, ...a) {
// 實參數量大於等於形參數量嗎?
return a.length >= fn.length ?
// 如果大於返回執行結果
fn(...a) :
// 反之繼續柯里化,遞歸,並將上一次的參數以及下次的參數繼續傳遞下去
(...b) => curry(fn, ...a, ...b);
};
const add = (a, b, c) => a + b + c;
// 將add加工成柯里化函數
const addCurry = curry(add);
console.log(addCurry(1, 2, 3));// 6
console.log(addCurry(1)(2)(3));// 6
console.log(addCurry(1, 2)(3));// 6
console.log(addCurry(1)(2, 3));// 6
可能有同學初看這段代碼有些不理解,這里我小白式解釋下,我們以addCurry(1)(2)(3)
調用為例:
- 初次調用
curry(add)
,由於除了函數之外沒別的參數,因此a
長度是0,三元判斷后addCurry
就是(...b) => curry(fn, ...a, ...b)
。 - 第一次調用
addCurry(1)
,此時等同於(1) => curry(fn, [], [1])
,注意,接下來神奇的事情發生了,function (fn, ...a)
這一段中的...a
直接把[]
和[1]
進行了合並,於是執行完畢繼續遞歸時,下一次執行函數時的..a
就是[1]
,即便函數執行完畢,自由變量依舊不會釋放,這就是閉包的特性。 - 繼續調用
(2)
,那么此時就等同於(2) => curry(fn, [1], [2])
,長度依舊不滿足,繼續返回遞歸,...a
再次合並。 - 調用
(3)
,此時等用於(3) => curry(fn, [1,2], [3])
,...a
再次合並,巧了,此時a.length >= fn.length
滿足條件,於是執行fn(...a)
,也就是add(1, 2, 3)
。
同理,不管我們調用addCurry(1, 2, 3)
還是addCurry(1,2)(3)
,都是相同的過程,實參長度大於等於形參長度嗎?滿足就返回執行結果,不滿足就繼續柯里化(遞歸),同時巧妙的把新舊參數進行合並。
伍 ❀ 實現無限調用
我們將問題再次升級,現在要求實現一個可以無限調用的函數,且每次調用都能得到最終結果,比如:
addCurry(1);
addCurry(1)(2);
addCurry(1)(2)(3, 4);
addCurry(1)(2)(3, 4)(5)(6, 7);
實現前,我們首先想到的是,由於沒了形參數量的限制,此時就不可能存在在某種條件下跳出遞歸的條件了。但如果沒條件,我們怎么知道什么時候返回函數,什么時候返回結果呢?
在說這個之前,我們先實現一個無限調用的函數,每次調用,它都會返回自己,且接受上次計算的結果,以及下次的參數,比如:
const add = (...a) => {
// 保留上一次的計算,同理也是最后一次的計算
let res = a.reduce((pre, cur) => pre + cur);
// 將上次的結果以及下次接受的參數都傳下去
return (...b) => add(res, ...b);
};
現在尷尬的是,我們每次調用函數內部其實都做了求和,只是因為不斷遞歸,我們拿不到結果,每次都是拿到一個新函數,怎么拿到結果?其實有一些做法,比如將結果綁在add
上,或者借用toString
方法,我們先實現:
const add = (...a) => {
let res = a.reduce((pre, cur) => pre + cur);
const add_ = (...b) => add(res, ...b);
// 因為每次返回的都是add_,因此要給它綁toString方法
add_.toString = () => {
return res;
};
return add_;
};
// 注意,方法前都有一個+
console.log(+add(1)(2));// 3
console.log(+add(1)(2, 3));// 6
console.log(+add(1)(2, 3)(4));// 10
我們用了一些奇技淫巧,在調用前添加了+
,這樣函數執行完畢后,因為+
會自動調用我們定義的toString
方法,從而返回了我們期望的結果。
在關於Object.prototype.toString()方法介紹中,我們可以得知:
每個對象都有一個
toString()
方法,當該對象被表示為一個文本值時,或者一個對象以預期的字符串方式引用時自動調用。
我們函數內部總是返回一個新函數,這也是為什么要將toString
綁在新函數上的緣故,相當於我們覆蓋了原型鏈上的toString
方法,讓它來幫我返回值,大概如此了。
陸 ❀ 總
那么到這里,我們從函數柯里化聊到了add(1)(2)(3)
,以此又拓展到了add(1, 2, 3)(4)
以及無限調用的場景,本質上幫大家復習了一波閉包的小技巧。那么回到函數柯里化,花里胡哨說這么多,這東西有什么用呢?其實從傳參上就能感覺到,它能做到參數緩存,沒一次參數的傳遞,都會返回一個與該參數綁定的新函數。
我們假定add(1)(2)
與add(1)(3)
是兩個場景,而add(1)
這一步會進行非常復雜的計算,那么通過柯里化,我們能直接將add(1)
這一步緩存起來,再以此拓展到不同的其它場景中,那這樣是不是達到了復用的目的了呢?
關於函數柯里化以及這道題,就先說到這里了,假設以后運氣好遇到了原題,那直接原地起飛,如果沒遇到,我想通過本文,對於閉包的理解應該也有所加深,那么到這里本文結束。