柯里化 (Curry)

在本章剩下的部分,我们将讨论一下关于柯里化和部分应用的话题。但是在我们开始这个话题之前,先看一下什么是函数应用。

函数应用

在一些纯粹的函数式编程语言中,对函数的描述不是被调用(called或者invoked),而是被应用(applied)。在JavaScript中也有同样的东西——我们可以使用Function.prototype.apply()来应用一个函数,因为在JavaScript中,函数实际上是对象,并且他们拥有方法。

下面是一个函数应用的例子:

  1. // 定义函数
  2. var sayHi = function (who) {
  3. return "Hello" + (who ? ", " + who : "") + "!";
  4. };
  5. // 调用函数
  6. sayHi(); // "Hello"
  7. sayHi('world'); // "Hello, world!"
  8. // 应用函数
  9. sayHi.apply(null, ["hello"]); // "Hello, hello!"

从上面的例子中可以看出来,调用一个函数和应用一个函数有相同的结果。apply()接受两个参数:第一个是在函数内部绑定到this上的对象,第二个是一个参数数组,参数数组会在函数内部变成一个类似数组的arguments对象。如果第一个参数为null,那么this将指向全局对象,这正是当你调用一个函数(且这个函数不是某个对象的方法)时发生的事情。

当一个函数是一个对象的方法时,我们不再像前面的例子一样传入null。(译注:主要是为了保证方法中的this绑定到一个有效的对象而不是全局对象。)在下面的例子中,对象被作为第一个参数传给apply()

  1. var alien = {
  2. sayHi: function (who) {
  3. return "Hello" + (who ? ", " + who : "") + "!";
  4. }
  5. };
  6. alien.sayHi('world'); // "Hello, world!"
  7. sayHi.apply(alien, ["humans"]); // "Hello, humans!"

在这个例子中,sayHi()中的this指向alien。而在上一个例子中,this是指向的全局对象。(译注:这个例子的代码有误,最后一行的sayHi并不能访问到aliensayHi方法,需要使用alien.sayHi.apply(alien, ["humans"])才可正确运行。另外,在sayHi中也没有出现this。)

正如上面两个例子所展现出来的一样,我们将所谓的函数调用当作函数应用的一种语法糖来理解也没有什么太大的问题。

需要注意的是,除了apply()之外,Function.prototype对象还有一个call()方法,但是它仍然只是apply()的一种语法糖。(译注:这两个方法的区别在于,apply()只接受两个参数,第二个参数为需要传给函数的参数数组,而call()则接受任意多个参数,从第二个开始将参数依次传给函数。)不过有种情况下使用这个语法糖会更好:当你的函数只接受一个参数的时候,你可以省去为唯一的一个元素创建数组的工作:

  1. // 第二种更高效,因为节省了一个数组
  2. sayHi.apply(alien, ["humans"]); // "Hello, humans!"
  3. sayHi.call(alien, "humans"); // "Hello, humans!"

部分应用

现在我们知道了,调用一个函数实际上就是给它应用一堆参数,那是否能够只传一部分参数而不传全部呢?这实际上跟我们手工处理数学函数非常类似。

假设已经有了一个add()函数,它的工作是把xy两个数加到一起。下面的代码片段展示了当x为5、y为4时的计算步骤:

  1. // 并不是合法的JavaScript代码,仅用于演示
  2. // 假设有一个add()函数
  3. function add(x, y) {
  4. return x + y;
  5. }
  6. // 给定参数
  7. add(5, 4);
  8. // 第一步 传入一个参数
  9. function add(5, y) {
  10. return 5 + y;
  11. }
  12. // 第二步 传入另一个参数
  13. function add(5, 4) {
  14. return 5 + 4;
  15. }

在这个代码片段中,第一步和第二步并不是有效的JavaScript代码,但是它展示了我们手工计算的过程。首先获得第一个参数的值,然后在函数中将未知的x值替换为5。然后重复这个过程,直到替换掉所有的参数。

第一步是一个所谓的部分应用的例子:我们只应用了第一个参数。当你执行一个部分应用的时候并不能获得结果(或者是解决方案),取而代之的是另一个函数。

下面的代码片段展示了一个虚拟的partialApply()方法的用法:

  1. var add = function (x, y) {
  2. return x + y;
  3. };
  4. // 完整应用
  5. add.apply(null, [5, 4]); // 9
  6. // 部分应用
  7. var newadd = add.partialApply(null, [5]);
  8. // 为新函数传入一个参数
  9. newadd.apply(null, [4]); // 9

正如你所看到的一样,部分应用给了我们另一个函数,这个函数可以在稍后调用的时候接受其它的参数。这实际上跟add(5)(4)是等价的,因为add(5)返回了一个函数,这个函数可以使用(4)来调用。我们又一次看到,熟悉的add(5, 4)也差不多是add(5)(4)的一种语法糖。

现在,让我们回到地球:并不存在这样的一个partialApply()函数,并且函数的默认表现也不会像上面的例子中那样。但是你完全可以自己去写,因为JavaScript的动态特性完全可以做到这样。

让函数理解并且处理部分应用的过程,叫柯里化(Currying)。

柯里化(Currying)

柯里化这个名字来自数学家Haskell Curry。(Haskell编程语言也是因他而得名。)柯里化是一个变换函数的过程。柯里化的另外一个名字也叫schönfinkelisation,来自另一位数学家——Moses Schönfinkelisation——这种变换的最初发明者。

所以我们怎样对一个函数进行柯里化呢?其它的函数式编程语言也许已经原生提供了支持并且所有的函数已经默认柯里化了。在JavaScript中我们可以修改一下add()函数使它柯里化,然后支持部分应用。

来看一个例子:

  1. // 柯里化过的add()方法,可以接受部分参数
  2. function add(x, y) {
  3. var oldx = x, oldy = y;
  4. if (typeof oldy === "undefined") { // 部分应用
  5. return function (newy) {
  6. return oldx + newy;
  7. };
  8. }
  9. // 完整应用
  10. return x + y;
  11. }
  12. // 测试
  13. typeof add(5); // "function"
  14. add(3)(4); // 7
  15. // 创建并保存函数
  16. var add2000 = add(2000);
  17. add2000(10); // 2010

在这段代码中,第一次调用add()时,在返回的内层函数那里创建了一个闭包。这个闭包将原来的xy的值存储到了oldxoldy中。当内层函数执行的时候,oldx会被使用。如果没有部分应用,即xy都传了值,那么这个函数会简单地将他们相加。这个add()函数的实现显得有些冗余,仅仅是为了更好地说明问题。下面的代码片段中展示了一个更简洁的版本,没有oldxoldy,因为原始的x已经被存储到了闭包中,此外我们复用了y作为本地变量,而不用像之前那样新定义一个变量newy

  1. // 柯里化过的add()方法,可以接受部分参数
  2. function add(x, y) {
  3. if (typeof y === "undefined") { // 部分应用
  4. return function (y) {
  5. return x + y;
  6. };
  7. }
  8. // 完整应用
  9. return x + y;
  10. }

在这些例子中,add()函数自己处理了部分应用。有没有可能用一种更为通用的方式来做同样的事情呢?换句话说,我们能不能对任意一个函数进行处理,得到一个新函数,使它可以处理部分参数?下面的代码片段展示了一个通用函数的例子,我们叫它schonfinkelize(),它正是用来做这个的。我们使用schonfinkelize()这个名字,一部分原因是它比较难发音,另一部分原因是它听起来比较像动词(使用“curry”则不是那么明确),而我们刚好需要一个动词来表明这是一个函数转换的过程。

这是一个通用的柯里化函数:

  1. function schonfinkelize(fn) {
  2. var slice = Array.prototype.slice,
  3. stored_args = slice.call(arguments, 1);
  4. return function () {
  5. var new_args = slice.call(arguments),
  6. args = stored_args.concat(new_args);
  7. return fn.apply(null, args);
  8. };
  9. }

这个schonfinkelize()可能显得比较复杂了,只是因为在JavaScript中arguments不是一个真的数组。从Array.prototype中借用slice()方法帮助我们将arguments转换成数组,以便能更好地对它进行操作。当schonfinkelize()第一次被调用的时候,它使用slice变量存储了对slice()方法的引用,同时也存储了调用时的除去第一个之外的参数(stored_args),因为第一个参数是要被柯里化的函数。schonfinkelize()返回了一个函数,当这个返回的函数被调用的时候,它可以(通过闭包)访问到已经存储的参数stored_argsslice。新的函数只需要合并老的部分应用的参数(stored_args)和新的参数(new_args),然后将它们应用到原来的函数fn(也可以在闭包中访问到)即可。

现在有了通用的柯里化函数,就可以做一些测试了:

  1. // 普通函数
  2. function add(x, y) {
  3. return x + y;
  4. }
  5. // 柯里化得到新函数
  6. var newadd = schonfinkelize(add, 5);
  7. newadd(4); // 9
  8. // 另一种选择 直接调用新函数
  9. schonfinkelize(add, 6)(7); // 13

用来做函数转换的schonfinkelize()并不局限于单个参数或者单步的柯里化。这里有些更多用法的例子:

  1. // 普通函数
  2. function add(a, b, c, d, e) {
  3. return a + b + c + d + e;
  4. }
  5. // 参数个数可以随意分割
  6. schonfinkelize(add, 1, 2, 3)(5, 5); // 16
  7. // 两步柯里化
  8. var addOne = schonfinkelize(add, 1);
  9. addOne(10, 10, 10, 10); // 41
  10. var addSix = schonfinkelize(addOne, 2, 3);
  11. addSix(5, 5); // 16

什么时候使用柯里化

当你发现自己在调用同样的函数并且传入的参数大部分都相同的时候,就是考虑柯里化的理想场景了。你可以通过传入一部分的参数动态地创建一个新的函数。这个新函数会存储那些重复的参数(所以你不需要再每次都传入),然后再在调用原始函数的时候将整个参数列表补全。