朋友,柯里化(Currying)了解一哈

2,297 阅读8分钟

该文章是直接翻译国外一篇文章,关于柯里化(Currying)。
都是基于原文处理的,其他的都是直接进行翻译可能有些生硬,所以为了行文方便,就做了一些简单的本地化处理。
如果想直接根据原文学习,可以忽略此文。

如果你觉得可以,请多点赞,鼓励我写出更精彩的文章🙏。

案例引导:

现在有如下需求,将多参函数转换为n个单参函数的组合。如果没有想到合适的方式来实现,巧了不是嘛,这不是巧了嘛。这篇文章就是为了说明白这个问题的。

Talk is cheap,show you the code


//原函数
add=(first,second,third)=>first+second+third;
//函数改造
add(1)(2)(3) //6
add(1,2)(3) //6
add(1)()()(2,3) //6

我们先来看一下关于Currying的定义(该定义被计算机科学和数学都认可)

(Currying将多参函数转换为参函数)
Currying turns multi-argument functions into unary (single argument) functions.

柯里化的原函数,一次可以接收多个参数。就像下面一样:

greet = (greeting, first, last) => `${greeting}, ${first} ${last}`;

greet('Hello', '范', '北宸'); // Hello, 范北宸

对原函数(greet)进行适当的柯里化处理之后

curriedGreet = curry(greet);

curriedGreet('Hello')('范')('北宸'); // Hello, 范北宸

这个三元函数已经被改造为三个一元函数。当你提供了一个参数,一个期待下一个参数的新的函数被返回。

适当的柯里化

上面之所以说适当的柯里化是因为一些柯里化函数在使用的时候是非常灵活的。柯里化伟大之处在于理论思维,但是在JS中为构建/调用一个函数为了处理每个参数将变的很棘手。

Ramda’s 柯里化函数可以让你通过下面的方式来调用curriedGreet:

// greet 需要三个参数: (greeting, first, last)

// 这些都将返回一个函数,等待剩余参数(first, last)
curriedGreet('Hello');
curriedGreet('Hello')();
curriedGreet()('Hello')()();

// 这些都将返回一个函数,等待剩余参数(last)
curriedGreet('Hello')('范');
curriedGreet('Hello', '范');
curriedGreet('Hello')()('范')();

// 当参数个数符合最初定义的时候,将会返回最后结果,这些将返回一个字符串
curriedGreet('Hello')('范')('北宸');
curriedGreet('Hello', '范', '北宸');
curriedGreet('Hello', '范')()()('北宸');

Notice:

  1. 你可以选择一次传入多个参数。这中处理方式在开发应用中很有用。
  2. 正如上面声明,你可以在调用函数过程中,传入空参,它也会返回一个等待剩余参数的函数

手动实现一个柯里化函数

Mr. Elliot 分享了一个和Ramda类似的curry实现。

const curry = (f, arr = []) => (...args) =>
  ((a) => (a.length === f.length ? f(...a) : curry(f, a)))([...arr, ...args]);

是不是很惊奇。心中是否万马奔腾。这玩意就能实现curry

解刨代码

将ES6的箭头函数替换为更加看懂的方式,同时新增了debugger,便于在分析调用过程。

curry = (originalFunction, initialParams = []) => {
  debugger;

  return (...nextParams) => {
    debugger;

    const curriedFunction = (params) => {
      debugger;

      if (params.length === originalFunction.length) {
        return originalFunction(...params);
      }

      return curry(originalFunction, params);
    };

    return curriedFunction([...initialParams, ...nextParams]);
  };
};

打开控制台,我们来一起欣赏一下这段奇妙的代码。

准备工作

greetcurry复制到控制台。然后输入curriedGreet = curry(greet),然后开启这段奇妙之旅吧。

第一次停顿(代码第二行)

通过监听函数的两个参数,我们可以看到originalFunction就是greet并且由于我们没有提供第二个参数,所以initialParams的值还是在定义函数时候的默认值。移动到下一个断点处。

猛然发现断点直接跳出函数作用域,也就是curry(greet)返回了一个等待(N>3)的函数。在控制台判断返回值的类型,也和我们分析的一样。

并且我们继续调用返回的函数,并存于sayHello变量中。

sayHello = curriedGreet('Hello')

并且在控制台执行它。

第二次停顿(代码第四行)

在进行第二次停顿之前,我们查看了监听的originalFunctioninitialParams。但是在第一次断点之后,就返回了一个函数,为什么在新函数的作用域中,也可以访问到这些变量呢?这是因为该新函数是从父级函数中返回的,能够访问父级函数中定义变量。(或者用更加通俗的话来讲,这是闭包,关于闭包,会专门有一篇文章,进行讲解,现在在筹备过程中。敬请期待)

父子函数之间的参数继承关系

当一个父级函数调用之后,他们会将自己参数留给子孙级函数所使用。这种继承方式和现实方式中继承是一样的。

curry在定义/初始化的时候,就将originalFunctioninitialParams作为初始参数,随后返回了一个子函数(child)。因此这两个变量没有被销毁,因为子函数也对其有访问权限。

解析第四行代码

通过监听nextParams我们忽然发现,该值为['Hello']。但是我们在调用curriedGreet()的时候,是传入的'Hello'而不是['Hello']

谜底:我们在调用curriedGreet的时候,传入的是'Hello',但是通过rest syntax,我们将'Hello'转换为['Hello']

为什么要进行数据转换

curry 是一个可以接收N(N>1,10,100)个参数的函数,所以通过rest syntax处理之后,函数能够轻松的访问这些参数。既然每次都是传入一个参数,通过rest syntax每次都将参数捕获到数组中。

继续移动断点。

第三次停顿(代码第六行)

在运行第六行的debugger之前,是先调用12行的。我们在第五行定义了一个名为curriedFunction的函数,在12行处调用他。所以我们将断点放置在了方法体内。那调用curriedFunction的时候,传入的数据是啥呢?

[...initialParams, ...nextParams];

在第五行我们查看了参数...nextParams['Hello']。由此可见,initialParamsnextParams都为数组,所以,可以通过spread operator将两个数组进行合并处理。

关键点,就在这里。

如果params and originalFunction具有相同长度,将会直接调用greet,也就意味着柯里化过程完成了。

JS函数也具有length属性

这也是能够完成柯里化的关键步骤。此处就判断返回的函数是否继续等待剩余参数。(提前透露下,这里是结束递归的判断,如果没有这个,将直接导致死循环)

在JS中,一个函数的.length属性用于标识在函数定义的时候,有几个参数。或者说,函数期待几个参数参与函数运行。

greet.length; // 3

iTakeOneParam = (a) => {};
iTakeTwoParams = (a, b) => {};

iTakeOneParam.length; // 1
iTakeTwoParams.length; // 2

如果你提供了函数期望的参数个数,那柯里化工作直接返回原始函数并且不在进行其余操作。

但是,我们提供的示例中,parameters的长度是和函数长度不一样的。我们仅仅提供了Hello,所以parameters长度为1,但是originalFunction.length3。所以此处的if()判断是false。我们将走的是另外一个分支。重新调用主函数curry(也就是进入了递归处理了),而此时curry()接收greetHello为参数,重新走上面的流程。

curry本质上是一个无限循环的自我调用并且对参数贪婪的函数,直到函数个数===originalFunction.length才会停止。

轮回处理

这是在对greet进行柯里化处理时的参数快照curriedGreet = curry(greet)

这是在greet柯里化之后并且接受一个参数之后的参数快照sayHello = curriedGreet('Hello')

很显然,第二次运行到第二行的时候,参数变化了,也就是originalFunction还是greet,但是现在initialParams变成了['Hello']了,而不是空数组了。

然后继续跳过断点,又双叒叕返回了一个全新的函数(sayHello),而这个函数也期待这剩余函数的传入。 sayHelloToFan = sayHello('范').

继续跟踪断点,又跳到第四行,此时nextParams['范']

继续跳过断点到第六行,发现curriedFunction的参数为['Hello', '范']

为什么存放参数的数组会增加

在12行有进行数组合并的处理[...initialParams, ...nextParams],而initialParams[Hello]nextParams是通过...nextParams操作之后,将转换为['范']。所以,在12行的时候,就是针对两个数组进行合并处理[...['Hello'],...['范']

现在又到了curriedFunction抉择的时候了,此时params.length2还是没有达到预期的数值,继续递归处理。

                                        ||
                                        ||
                                        ||
                                        \/

所以我们继续基于sayHelloToFan进行处理。sayHelloToFanBeichen = sayHelloToFan('北宸')

继续开始了参数处理和参数判断之旅。但是此时有一点不同了。就是在判断参数数组长度和originalFunction.length时候。

                                    ||
                                    ||
                                    ||
                                    \/

此时的话,就和调用greet('Hello','范','北宸')的效果和结果是一样的。

大结局

greet获取了它应获取的参数,curry也停止了递归处理。并且,我们也获得了想要的结果Hello,范北宸

其实利用currygreet经过如上处理之后,现在处理之后的函数能够同时接收任意(n≥0)的参数。

curriedGreet('Hello', '范', '北宸');
curriedGreet('Hello', '范')('北宸');
curriedGreet()()('Hello')()('范')()()()()('北宸');

补丁

由于在这篇文章发文之后,有一些小伙伴问,curry的好处也好啊,还是如何应用到实际工作中啊。其实这篇文章只是单纯的介绍如何用JS实现curry

而有一点需要大家明确,curry函数式编程中的一个重要概念。如果说实际中用到这个编程方式了吗,说实话,我没有。但是经过翻阅一些资料,打算以后项目中会尝试使用。

所以,给大家列举一下我查找的相关资料(其实就是函数式编程的官网的一些介绍文章)

  1. 爱上柯里化
  2. 为什么柯里化有帮助
  3. 手动实现一个Redux Redux是React项目开发中比较常用的一个库。