JavaScript 分步實現柯里化函數


JavaScript 分步實現柯里化函數

簡介

首先,柯里化(Currying)是什麼呢?

簡單說,假如有一個函數,接受多個參數,那麼一般來說就是一次性傳入所有參數並執行。而對其執行柯里化後,就變成了可以分多次接收參數


實現

階段1

現在有一個加法函數:

<code>function add(x, y, z) {    return x + y + z}/<code>

調用方式是 add(1, 2, 3)。

如果執行柯里化,變成了 curriedAdd(),從效果來說,大致就是變成 curriedAdd(1)(2)(3) 這樣子。

現在先不看怎麼對原函數執行柯里化,而是根據這個調用方式重新寫一個函數。代碼可能是這樣的:

<code>function curriedAdd1(x) {    return function (y) {        return function (z) {            return x + y + z       }    }}/<code>

階段2

假如現在想要升級一下,不止可以接受三個參數。可以使用 arguments,或者使用展開運算符來處理傳入的參數。

但是有一個衍生的問題。因為之前每次只能傳遞一個,總共只能傳遞三個,才保證了調用三次之後參數個數剛好足夠,函數才能執行。

既然我們打算修改為可以接受任意個數的參數,那麼就要規定一個終點。比如說,可以規定為當不再傳入參數的時候,就執行函數。

下面是使用 arguments 的實現。

<code>function getCurriedAdd() {    // 在外部維護一個數組保存傳遞的變量    let args_arr = []    // 返回一個閉包    let closure = function () {       // 本次調用傳入的參數        let args = Array.prototype.slice.call(arguments)        // 如果傳進了新的參數       if (args.length > 0) {            // 保存參數           args_arr = args_arr.concat(args)            // 再次返回閉包,等待下次調用            // 也可以 return arguments.callee            return closure          }    // 沒有傳遞參數,執行累加    return args_arr.reduce((total, current) => total + current)  }  return closure}curriedAdd = getCurriedAdd()curriedAdd(1)(2)(3)(4)()複製代碼/<code>

階段3

這時可以發現,上面的整個函數里,與函數具體功能(在這裡就是執行加法)有關的,就只是當沒有傳遞參數時的部分,其他部分都是在實現怎樣多次接收參數。

那麼,只要讓 getCurriedAdd 接受一個函數作為參數,把沒有傳遞參數時的那一行代碼替換一下,就可以實現一個通用的柯里化函數了。

把上面的修改一下,實現一個通用柯里化函數,並把一個階乘函數柯里化:

<code>function currying(fn) {    let args_arr = []    let closure =  function (...args) {        if (args.length > 0) {            args_arr = args_arr.concat(args)            return closure       }        // 沒有新的參數,執行函數        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)()/<code>

階段4

上面的代碼裡,對於函數執行時機的判斷,是根據是否有參數傳入。但是更多時候,更合理的依據是原函數可以接受的參數的總數。

函數名的 length 屬性就是該函數接受的參數個數。比如:

<code>function test1(a, b) {}function test2(...args){}console.log(test1.length) // 2console.log(test2.length) // 0/<code>

改寫一下:

<code>function currying(fn) {    let args_arr = [],       max_length = fn.length  let closure = function (...args) {    // 先把參數加進去    args_arr = args_arr.concat(args)    // 如果參數沒滿,返回閉包等待下一次調用    if (args_arr.length < max_length) return closure    // 傳遞完成,執行    return fn(...args_arr)  }  return closure}function add(x, y, z) {  return x + y + z}curriedAdd = currying(add)console.log(curriedAdd(1, 2)(3))複製代碼/<code>

Lodash 中的柯里化

讓我們先看一下 lodash.js 的文檔,看看一個真正的 curry 方法到底是做什麼的。

<code>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]/<code>

在我理解看來,curry 能夠讓我們:

  1. 在多個函數調用中逐步收集參數,不用在一個函數調用中一次收集。
  2. 當收集到足夠的參數時,返回函數執行結果。

為了更好的理解它,我在網上找了多個實現示例。然而,我希望是有一個非常簡單的教程從一個基本的例子開始,就像下面這個一樣,而不是直接從最終的實現開始。

<code>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]/<code>

理解 fn 函數是所有的起點。基本上,這個函數的作用就是一個“參數收集器”。每次調用該函數時,它都會返回一個自身的綁定函數(fb),並且將該函數提供的“參數”綁定到返回函數上。該“參數”將位於之後調用返回的綁定函數時提供的任何參數之前。因此,每個調用中傳的參數將被逐漸收集到一個數組當中。

當然,就像 curry 函數一樣,我們不必一直收集下去。現在我們可以先寫死一個終止點。

<code>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;  }}/<code>

為了讓它表現得和 curry 方法一樣,需要解決兩個問題:

  1. 我們希望將收集到的參數傳遞給需要它們的目標函數,而不是通過將它們傳遞給 console.log 在最後打印出來。
  2. 變量 numOfRequiredArguments 不應該是寫死的,它應該是目標函數所期望的參數個數。

幸運的是,JavaScript函數確實帶有一個名為 “length” 的屬性,它指定了函數所期望的參數個數。因此,我們就可以使用這個屬性來確定所需要的參數個數,而不用再寫死了。那麼第二個問題就解決了。

那第一個問題呢:保持對目標函數的引用?

網上有幾個例子可以解決這個問題。它們之間雖然略有不同,但是有著相同的思路:除去存儲參數以外,我們還需要在某處存儲對於目標函數的引用。

這裡我把它們分為兩種不同的方法,它們之間或多或少都有相似之處,理解它們能夠幫助我們更好地理解背後的邏輯。順便說一句,這裡我將這個函數叫做 magician,以代替 curry。

方法1

<code>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);    }  }}/<code>

magician 函數的作用是:它接收目標函數作為參數,然後返回‘參數收集器’函數,與上例中 fn 函數作用相同。唯一的不同點在於,當收集的參數數量與目標函數

所必需的參數數量相等時,它將把收集到的參數通過 apply 方法給到該目標函數,並返回計算的結果。這個方法通過將其存儲在 magician 創建的閉包當中來解決第一個問題(引用目標函數)。

方法2

這個方法更進一步,由於參數收集器函數只是一個普通函數,那為什麼不使用 magician 函數本身作為參數收集器呢?

<code>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));  }}/<code>

注意方法2中的一個不同。因為 magician 接收目標函數作為它的第一個參數,因此收集到的參數將始終包含該函數作為 arguments[0]。這就導致,我們在檢查有效參數的總數時,需要減去第一個參數。

順便說一句,因為目標函數是遞歸地傳遞給 magician 函數的,所以我們可以通過傳入第一個參數顯式地引用目標函數,以代替使用閉包來存儲目標函數的引用。

正如你所見,Eric Elliott 上面使用到的 “curry” 函數和方法1功能相似,但實際上它是一個偏函數(這又是另外一說了)。

<code>const curry = fn => (…args) => fn.bind(null, …args);/<code>

上面是一個 curry 函數,它返回“參數收集器”,該收集器只收集一次參數,並返回綁定的目標函數。

更進一步

上面的‘magician’函數仍然沒有lodash.js中的‘curry’函數那樣神奇。lodash的curry允許使用‘_’作為輸入參數的佔位符。

<code>curried(1)(_, 3)(2); // => [1, 2, 3], 注意佔位符 '_'/<code>

為了實現佔位符功能,有一個隱含的需求:我們需要知道哪些參數被預設給了綁定函數,以及哪些是在調用函數時顯示提供的附加參數(這裡我們稱之為added參數)。

這個功能可以通過創建另外一個閉包來完成:

<code>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  }}/<code>

上面的 fn2 幾乎和 fn 一樣,功能就像‘參數收集器’一樣。然而,fn2 不是直接返回綁定函數,而是返回一箇中間輔助函數 helper。helper 函數是未綁定的,因此它可以用來分離預設的參數和後來提供的參數。

當然,我們需要在組合時進行一些修改,而不是通過 [...preset, ...added] 將預設的參數和後來提供的參數合併起來。我們需要在preset參數中找到佔位符的位置,並用有效的added參數替換它。我沒有看lodash是如何實現它的,但下面是一個完成類似功能的簡單實現。

<code>// 定義佔位符var _ = '_';function magician3 (targetfn, ...preset) {  var numOfArgs = targetfn.length;  var nextPos = 0; // 下一個有效輸入位置的索引,可以是'_',也可以是preset的結尾  // 查看是否有足夠的有效參數  if (preset.filter(arg=> arg !== _).length === numOfArgs) {    return targetfn.apply(null, preset);  } else {    // 返回'helper'函數    return function (...added) {      // 循環並將added參數添加到preset參數      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);    }  }}/<code> 

第15到24行是用於將added參數放入preset數組中正確位置的邏輯:無論是佔位符或是preset的結尾。該位置被標記為 nextPos 並初始化為索引0。

現在,函數 magician3 幾乎已經和lodash的curry函數功能相當了。


鏈接文章:

https://blog.csdn.net/weixin_34329187/article/details/91396617

https://www.jianshu.com/p/822c4bfeb8a9


分享到:


相關文章: