JavaScript柯里化 —— 实现lodash的curry方法[译]

当我读到 Eric ElliottMedium 上写的关于组合函数的文章时,我对于他 curry 函数的实现感到大惑不解,这看起来像是对 lodash.js 中 curry 方法的一个简单模仿,并且他是用 ES6 写的。

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

为了帮助其他开发人员理解这行代码背后究竟发生了什么,我决定来写这篇文章,尽力使用一些非常简单和直观,甚至稍显稚拙的例子来一步步地阐明 curry 函数的基本实现。

让我们先看一下 lodash.js 的文档,看看一个真正的 curry 方法到底是做什么的。

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]

在我理解看来,curry 能够让我们:

  1. 在多个函数调用中逐步收集参数,不用在一个函数调用中一次收集。

  2. 当收集到足够的参数时,返回函数执行结果。

为了更好的理解它,我在网上找了多个实现示例。然而,我希望是有一个非常简单的教程从一个基本的例子开始,就像下面这个一样,而不是直接从最终的实现开始。

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]

理解 fn 函数是所有的起点。基本上,这个函数的作用就是一个“参数收集器”。每次调用该函数时,它都会返回一个自身的绑定函数fb),并且将该函数提供的“参数”绑定到返回函数上。该“参数”将位于之后调用返回的绑定函数时提供的任何参数之前。因此,每个调用中传的参数将被逐渐收集到一个数组当中。

当然,就像 curry 函数一样,我们不必一直收集下去。现在我们可以先写死一个终止点。

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

为了让它表现得和 curry 方法一样,需要解决两个问题:

  1. 我们希望将收集到的参数传递给需要它们的目标函数,而不是通过将它们传递给 console.log 在最后打印出来。

  2. 变量 numOfRequiredArguments 不应该是写死的,它应该是目标函数所期望的参数个数。

幸运的是,JavaScript函数确实带有一个名为 “length” 的属性,它指定了函数所期望的参数个数。因此,我们就可以使用这个属性来确定所需要的参数个数,而不用再写死了。那么第二个问题就解决了。

那第一个问题呢:保持对目标函数的引用?

网上有几个例子可以解决这个问题。它们之间虽然略有不同,但是有着相同的思路:除去存储参数以外,我们还需要在某处存储对于目标函数的引用。

这里我把它们分为两种不同的方法,它们之间或多或少都有相似之处,理解它们能够帮助我们更好地理解背后的逻辑。顺便说一句,这里我将这个函数叫做 magician,以代替 curry。

方法1

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

magician 函数的作用是:它接收目标函数作为参数,然后返回‘参数收集器’函数,与上例中 fn 函数作用相同。唯一的不同点在于,当收集的参数数量与目标函数所必需的参数数量相等时,它将把收集到的参数通过 apply 方法给到该目标函数,并返回计算的结果。这个方法通过将其存储在 magician 创建的闭包当中来解决第一个问题(引用目标函数)。

方法2

这个方法更进一步,由于参数收集器函数只是一个普通函数,那为什么不使用 magician 函数本身作为参数收集器呢?

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

注意方法2中的一个不同。因为 magician 接收目标函数作为它的第一个参数,因此收集到的参数将始终包含该函数作为 arguments[0]。这就导致,我们在检查有效参数的总数时,需要减去第一个参数。

顺便说一句,因为目标函数是递归地传递给 magician 函数的,所以我们可以通过传入第一个参数显式地引用目标函数,以代替使用闭包来存储目标函数的引用。

正如你所见,Eric Elliott 上面使用到的 “curry” 函数和方法1功能相似,但实际上它是一个偏函数(这又是另外一说了)。

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

上面是一个 curry 函数,它返回“参数收集器”,该收集器只收集一次参数,并返回绑定的目标函数。

更进一步

上面的‘magician’函数仍然没有lodash.js中的‘curry’函数那样神奇。lodash的curry允许使用‘_’作为输入参数的占位符。

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

为了实现占位符功能,有一个隐含的需求:我们需要知道哪些参数被预设给了绑定函数,以及哪些是在调用函数时显示提供的附加参数(这里我们称之为added参数)。

这个功能可以通过创建另外一个闭包来完成:

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

上面的 fn2 几乎和 fn 一样,功能就像‘参数收集器’一样。然而,fn2 不是直接返回绑定函数,而是返回一个中间辅助函数 helperhelper 函数是未绑定的,因此它可以用来分离预设的参数和后来提供的参数。

当然,我们需要在组合时进行一些修改,而不是通过 [...preset, ...added] 将预设的参数和后来提供的参数合并起来。我们需要在preset参数中找到占位符的位置,并用有效的added参数替换它。我没有看lodash是如何实现它的,但下面是一个完成类似功能的简单实现。

// 定义占位符
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);
    }
  }
}

第15到24行是用于将added参数放入preset数组中正确位置的逻辑:无论是占位符或是preset的结尾。该位置被标记为 nextPos 并初始化为索引0。

现在,函数 magician3 几乎已经和lodash的curry函数功能相当了。

译自:Implementation of lodash ‘curry’ function(如有不准确之处,敬请指正,不胜感激)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,445评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,889评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,047评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,760评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,745评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,638评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,011评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,669评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,923评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,655评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,740评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,406评论 4 320
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,995评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,961评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,023评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,483评论 2 342

推荐阅读更多精彩内容