從函數柯里化聊到add(1)(2)(3) add(1, 2)(3),以及柯里化無限調用


壹 ❀ 引

很久之前看到過的一道面試題,最近復習又遇到了,這里簡單做個整理,本題考點主要是函數柯里化,所以在實現前還是簡單介紹什么是柯里化。

貳 ❀ 函數柯里化(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)調用為例:

  1. 初次調用curry(add),由於除了函數之外沒別的參數,因此a長度是0,三元判斷后addCurry就是(...b) => curry(fn, ...a, ...b)
  2. 第一次調用addCurry(1),此時等同於(1) => curry(fn, [], [1]),注意,接下來神奇的事情發生了,function (fn, ...a)這一段中的...a直接把[][1]進行了合並,於是執行完畢繼續遞歸時,下一次執行函數時的..a就是[1],即便函數執行完畢,自由變量依舊不會釋放,這就是閉包的特性。
  3. 繼續調用(2),那么此時就等同於(2) => curry(fn, [1], [2]),長度依舊不滿足,繼續返回遞歸,...a再次合並。
  4. 調用(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)這一步緩存起來,再以此拓展到不同的其它場景中,那這樣是不是達到了復用的目的了呢?

關於函數柯里化以及這道題,就先說到這里了,假設以后運氣好遇到了原題,那直接原地起飛,如果沒遇到,我想通過本文,對於閉包的理解應該也有所加深,那么到這里本文結束。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM