JavaScript柯里化 —— 写个更好的curry方法

昨天翻译的文章中,原作者对于柯里化方法的最终实现是这样:

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

这个实现存在一点问题:

var abc = function(a, b, c) { return [a, b, c];};
var curried = magician3(abc);

curried(1)(2)(3) // [1, 2, 3]
curried(1)('_', 2)(3) // Uncaught TypeError: curried(...)(...) is not a function

curried 第一次运行功能正常,但是第二次运行就报错了。而且这个错误和使用占位符没有关系,第二次执行换作 curried(2)(3)(4) 也还是会报同样的错误。

一时看不出错误出在哪里,我先理一下上面的代码在运行时到底发生了什么:

  1. 初始化变量 _ ,值为 '_'

  2. 初始化变量 abc,指向一个函数 —— function(a, b, c) { return [a, b, c];},该函数用来将传入的参数转化为数组并返回。

  3. 初始化变量 curried,实际上获得的是 magician3(abc) 执行之后的结果。那接着看 magician3(abc) 的执行过程:

    • targetfn 初始化为传进来的 abc,参数 preset 的位置什么也没传,那么, preset 就被初始化为了一个空数组 []

    • numOfArgs 初始化为 targetfn.length,即3。

    • nextPos 初始化为0。

    • 进入条件判断语句,判断条件 preset.filter(arg=> arg !== _).length === numOfArgs 实际上是在说:检查一下数组 preset 中不为 _ 的元素数量是不是等于 numOfArgs,也就是 targetfn.length,目标函数所期望的参数数量。这里没有传 preset,显然不符合判断条件,进入 else

    • else 里面 return 出去了一个 function。这时,magician3(abc) 就执行完了,那 curried 拿到的就是 return 出来的这个 function 的引用。这个 function 通过闭包引用了一个空数组 preset,以及一个 numOfArgs,即所期望的参数数量 —— 3,一个 nextPos —— 0,还有一个 targetfn,即 abc

  4. 代码继续运行,到了 curried(1)(2)(3) 这句。这句代码会先执行 curried(1),再调用 curried(1) 返回的结果并传入 2,继续调用返回的结果并传入 3

  5. curried(1)执行,代码进入 curried 所引用的函数,也就是之前 magician3(abc) 执行完之后返回的匿名函数,该函数内,added 被初始化为 [1]

  6. 继续执行,到了 while 循环。这个循环内部还有一个 while 循环,先看里面这个循环。

这个循环的终止条件是 preset[nextPos] !== _ && nextPos < preset.length,其实就是在说:帮我检查一下 preset 数组 nextPos 位置的元素是不是不等于 '_' ,并且 nextPos 要小于 preset 的长度,不满足这个条件的话 nextPos 就一直累加。

那能看出来这个 while 循环其实就是在数组 preset 中找一个位置,这个位置要么是 '_' ,要么是数组的末尾,一旦找到,循环就终止。

结合这些再来看外层的 while 循环,这个循环的判断条件是 added.length > 0,循环体内首先执行 added.shift() 的操作,并将结果给到变量 a,其实就是 added 中的第一个元素,然后是用来找位置(nextPos)的循环,找到之后把 a 放到 presetnextPos 位置上,放完之后再把 nextPos 向后移一位,也就是 nextPos++,以便需要时继续往下找。

那可以看出,这个大的 while 循环作用其实就是将 added 中的元素从头到尾按顺序逐个给到 preset,并放到正确的位置上。什么是正确的位置呢,就是此时 added 里的元素逐个地,要么放到 preset'_' 的位置(替换),如果没有 '_' 的话,就放到 preset 末尾(追加)。

那么,经过了外层这段 while 循环之后,preset 就变成了 [1],而 added 变成了 []

  1. 代码继续执行,到了 return magician3.call(null, targetfn, ...preset); 。这句又执行了 magician3 函数,并将 targetfn...preset(也就是 1)作为 magician3 执行时传递的参数。那接着看具体都发生了什么:

    • 进入 magician3 方法,targetfn 还是那个 targetfn,指向 abcpreset 已经变成了 [1]

    • numOfArgs 初始化为 targetfn.length,即3。

    • nextPos 初始化为0。

    • 进入条件判断语句,这时 preset 里只有 1 一个元素,显然还是会进入 else

    • else 里还是 return 出去了一个 function,但这时这个 function 通过闭包引用到的 preset 已经是 [1] 了。

  2. 继续执行,curried(1)(2)(3),这次调用 curried(1) 执行完返回的函数时传入的参数就是 2 了。

  3. 那么同样地,进入上一步返回的匿名函数,while 循环执行完之后,preset 变成了 [1, 2]

  4. 继续 return magician3.call(null, targetfn, ...preset);,同样执行 magician3 函数,这次传入的 preset[1, 2]。继续看 magician3 的执行过程:

    • 进入 magician3 方法,targetfn 还是 abcpreset 变成了 [1, 2]

    • numOfArgs 初始化为 targetfn.length,即3。

    • nextPos 初始化为0。

    • 进入条件判断语句,这时 preset 里只有 12 两个元素,还是会进入 else

    • else 里还是 return 出去一个 function,这时通过闭包引用到的 preset[1, 2]

  5. 继续执行,curried(1)(2)(3),这次调用返回的函数时传入的参数是 3

  6. 同样地,进入返回的匿名函数,while 循环执行完之后,preset 变为 [1, 2, 3]

  7. 继续 return magician3.call(null, targetfn, ...preset);,同样执行 magician3 函数,这次传入的 preset 变成了 [1, 2, 3]。继续看 magician3 的执行过程:

    • 进入 magician3 方法,targetfn 还是指向 abcpreset 变成了 [1, 2, 3]

    • numOfArgs 初始化为 targetfn.length,即3。

    • nextPos 初始化为0。

    • 进入条件判断语句,这次 preset[1, 2, 3],满足判断条件 preset.filter(arg=> arg !== _).length === numOfArgs,那么进入 if 语句块,return targetfn.apply(null, preset);,也就是执行 targetfn,并传入 preset,使用 apply 时可以传入参数数组,其实就相当于 targetfn(1, 2, 3),也就是 abc(1, 2, 3),这时进入到 function(a, b, c) { return [a, b, c];},返回了结果 [1, 2, 3]

一直到这里看起来都是正常的,但是,再执行 curried(1)(_, 2)(3) 的话就出现了问题。我在想,curried 已经是指向一个匿名函数的引用了,那第一次执行这个函数没有问题,第二次执行时怎么就有问题了呢?

想了很久没想清楚这个 bug 的来龙去脉,后来在春岩和唐老师的指导下,逐渐明白了其中道理。

curried(1)(2)(3) 执行时,先执行 curried(1)

执行 curried(1) 之前, curried 指向的是 magician3(abc) 执行完之后 return 出来的那个匿名函数,该匿名函数通过闭包引用着 preset —— 一个空数组 []

那等 curried(1) 执行时,该匿名函数通过闭包引用的 preset 就变成了 [1](我把这个 preset 称为初始 preset),最后 return magician3.call(null, targetfn, ...preset); ,执行 magician3 并传入解构之后的 preset

代码接着走,又返回了另外一个匿名函数,这个匿名函数和之前 curried 持有的那个匿名函数之间互不影响,唯一的联系是,curried 持有的匿名函数所引用的 preset 浅复制之后的值被后来返回的这个匿名函数引用着。

后来的这个匿名函数执行时传入了 2,那么这个匿名函数执行完之后 preset 就变成了 [1, 2],但是这个 preset 和“初始 preset”是“存在不同地方的”,所以两者之间互不影响。

继续调用后来返回的这个匿名函数并传入 3 时,同样的,preset变成了 [1, 2, 3],但是这个 preset 又是存在另外一个地方的 preset

所以直到 curried(1)(2)(3) 执行完,curried 指向的匿名函数通过闭包引用的 preset 还是那个被 curried(1) 改变后的 preset,也就是 [1]

所以,问题就在这里。

preset 中已经有一个元素了,这会导致 curried(1)(_, 2)(3) 在还没有执行完时(执行了 curried(1)(_, 2) )就返回了一个数组,返回数组之后调用该数组并传入参数 3,显然数组是不能当作函数执行的,所以就报出了 Uncaught TypeError: curried(...)(...) is not a function 的错误。

curried(1)(_, 2)(3) 执行时,preset 应该是空数组才对。

那么可以这么改:

var _ = '_';

function magician3 (targetfn, preset = []) {
  var numOfArgs = targetfn.length;

  if (preset.filter(arg=> arg !== _).length === numOfArgs) {
    return targetfn.apply(null, preset);
  } else {
    return function (...added) {
      var newPreset = [...preset]
      var nextPos = 0;
      while(added.length > 0) {
        var a = added.shift();
        while (newPreset[nextPos] !== _ && nextPos < newPreset.length) {
          nextPos++
        }
        newPreset[nextPos] = a;
        nextPos++;
      }
      return magician3.call(null, targetfn, newPreset);
    }
  }
}

var abc = function(a, b, c) { return [a, b, c];};
var curried = magician3(abc);

curried(1)(2)(3) // [1, 2, 3]
curried(1)('_', 2)(3) // [1, 3, 2]

preset 浅复制一份后给 newPreset,然后把 newPresetnextPos 都放在返回的匿名函数内部,每个返回的匿名函数都维护着自己的数据,各自间彻底互不影响,不再像修改之前一样通过闭包引用保存 presetnextPos

最后在匿名函数内部把 newPreset 传给 magician3magician3 用参数 preset 来接收 newPreset,如果没传的话( magician3(abc) ),默认值为空数组 []

这样,问题就解决了。

回头再看修改之前的代码,这时会觉得 magician3 返回的匿名函数不是一个好函数。因为好函数不应该有副作用,而且应该是幂等的。好函数任意多次执行所产生的影响均与一次执行的影响相同。好函数可以使用相同参数重复执行,并能获得相同的执行结果。也就是说,固定输入,固定输出。很显然,它是一个坏函数。

唐老师看到这个坏函数时说,写出这样的代码不如回家养猪。可就是这样的代码,春岩讲得姿仪万方,我却听得跌跌撞撞。唉,我真的好菜...

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

推荐阅读更多精彩内容