[译]JavaScript中的函数柯里化

原文

函数柯里化

函数柯里化以Haskell Brooks Curry命名,柯里化是指将一个函数分解为一系列函数的过程,每个函数都只接收一个参数。(译注:这些函数不会立即求值,而是通过闭包的方式把传入的参数保存起来,直到真正需要的时候才会求值)


柯里化例子

以下是一个简单的柯里化例子。我们写一个接收三个数字并返回它们总和的函数sum3

function sum3(x, y, z) {
  return x + y + z;
}

console.log(sum3(1, 2, 3))  // 6

sum3的柯里化版本的结构不一样。它接收一个参数并返回一个函数。返回的函数。返回的函数中又接收一个餐你输,返回另一个仍然只接收一个参数的函数...(以此往复)

直到返回的函数接收到最后一个参数时,这个循环才结束。这个最后的函数将会返回数字的总和,如下所示。

function sum(x) {
  return (y) => {
    return (z) => {
      return x + y + z
    }
  } 
}

console.log(sum(1)(2)(3)) // 6

以上的代码能跑起来,是因为JavaScript支持闭包

一个闭包是由函数和声明这个函数的词法环境组成的
-- MDN

注意函数链中的最后一个函数只接收一个z,但它同时也对外层的变量进行操作,在这个例子中,这些外层的变量对于最后一个函数来说类似于全局变量。实际上只是相当于不同函数下的局部变量

// 相当于全局变量
let x = ...?
let y = ...?

// 只接收一个参数 z 但也操作 x 和 y
return function(z) {
  return x + y + z;
}

通用的柯里化

写一个柯里化函数还好,但如果要编写多个函数时,这就不够用了,因此我们需要一种更加通用的编写方式。

在大多数函数式编程语言中,比如haskell,我们所要做的就是定义函数,它会自动地进行柯里化。

let sum3 x y z = x + y + z

sum3 1 2 3
-- 6

:t sum3 -- print the type of sum3()
-- sum3 :: Int -> Int -> Int -> Int

(sum3) :: Int -> Int -> Int -> Int -- 函数名 括号中的部分
sum3 :: (Int -> Int -> Int) -> Int -- 定义柯里化函数 括号中的部分
sum3 :: Int -> Int -> Int -> (Int) -- 最后返回 括号中的部分

我们不能JS引擎重写为curry-ify所有函数,但是我们可以使用一个策略来实现。


柯里化策略

通过上述两种sum3的形式发现,实际上处理加法逻辑的函数被移动到闭包链的最后一个函数中。在到达最后一级之前,我们不会在执行环境中获得所有需要的参数。

这意味着我们可以创建一个包装哈数来收集这些参数,然后把它们传递给实际要执行的函数 (sum3)。所有中间嵌套的函数都称为累加器函数 - 至少我们可以这样称呼它们。

function _sum3(x, y, z) {
  return x + y + z;
}

function sum3(x) {
  return (y) => {
    return (z) => {
      return _sum3(x, y, z);  // 把参数都传给这个最终执行的函数
    }
  }
}

sum3(1)(2)(3)  // 6

用柯里化包裹之

由于我们要使用一个包装后的函数来替代实际的函数,因此我们可以创建另一个函数来包裹。我们将这个新生成的函数称之为curry —— 一个更高阶的函数,它的作用是返回一系列嵌套的累加器函数,最后调用回调函数fn

function curry(fn) {     // 定义一个包裹它们的柯里化函数
  return (x) => { 
    return (y) => { 
      return (z) => { 
        return fn(x, y, z);  // 调用回调函数
      };
    };
  };
}

const sum = curry((x, y, z) => {   // 传入回调函数
  return x + y + z;
});

sum3(1)(2)(3) // 6

现在我们需要满足有不同参数的柯里化函数:它可能有0个参数,1个参数,2个参数等等....


递归的柯里化

实际上我们并不是真的要编写多个满足不同参数的柯里化函数,而是应当编写一个适用于多个参数的柯里化函数。

如果我们真的写多个curry函数,那将会如下所示...:

function curry0(fn) {
  return fn();
}
function curry1(fn) {
  return (a1) => {
    return fn(a1);
  };
}
function curry2(fn) {
  return (a1) => {
    return (a2) => {
      return fn(a1, a2);
    };
  };
}
function curry3(fn) {
  return (a1) => {
    return (a2) => {
      return (a3) => {
        return fn(a1, a2, a3);
      };
    };
  };
}
...
function curryN(fn){
  return (a1) => {
    return (a2) => {
      ...
      return (aN) => {
        // N 个嵌套函数
        return fn(a1, a2, ... aN);
      };
    };
  };
}

以上函数有以下特征:

  1. i 个累加器返回另一个函数(也就是第(i+1)个累加器),也可以称它为第j个累加器。
  2. i个累加器接收i个参数,同时把之前的i-1个参数都保存其闭包环境中。
  3. 将会有N个嵌套函数,其中N是函数fn
  4. N个函数总是会调用fn函数

根据以上的特征,我们可以看到柯里化函数返回一个拥有多个相似的累加器的嵌套函数。因此我们可以使用递归轻松生成这样的结构。

function nest(fn) {
  return (x) => {
    // accumulator function
    return nest(fn);
  };
}

function curry(fn) {
  return nest(fn);
}

为了避免无限嵌套下去,需要一个让嵌套中断的情况。我们将当前嵌套深度存储在变量i中,那么此条件是i === N

function nest(fn, i) {
  return (x) => {
    if (i === fn.length) {    // 当执行到第 i 个时返回 fn
      return fn(...);
    }
    return nest(fn, i + 1);
  };
}
function curry(fn) {
  return nest(fn, 1);
}

接下来,我们需要存储所有参数,并把它们传递给fn()。最简单的解决方案就是在curry中年创建一个数组args并将其传递给nest

function nest(fn, i, args) {
  return (x) => {
    args.push(x);      // 存储每一个参数
    if (i === fn.length) {
      return fn(...args);      // 最后把参数都传递给 fn()
    }
    return nest(fn, i + 1, args);
  };
}
function curry(fn) {
  const args = [];      // 需要传入的参数列表

  return nest(fn, 1, args);
}

然后再添加一个没有参数时的临界处理:

function curry(fn) {
  if (fn.length === 0) {  // 当没有参数时直接返回
    return fn;
  }
  const args = [];

  return nest(fn, 1, args);
}

此时来测试一下我们的代码:

const log1 = curry((x) => console.log(x));
log1(10); // 10
const log2 = curry((x, y) => console.log(x, y));
log2(10)(20); // 10 20

你可以在codepen上运行测试


优化

对于初学者,我们可以在把nest放到curry中,从而可以通过在闭包中读取fnargs来,以此减少传给nest的参数数量。

function curry(fn) {
  if (fn.length === 0) {
    return fn;
  }
  const args = [];
  function nest(i) {        // 相比于之前,不用传递 fn 和 args
    return (x) => {
      args.push(x);
      if (i === fn.length) {
        return fn(...args);
      }
      return nest(i + 1);
    };
  }
  return nest(1);
}

让我们把这个新的curry变得更加函数式,而不是依赖于闭包变量。我们通过提供argsfn.length作为参数嵌套来实现。此外,我们把剩余的递归深度(译注:也就是除最后一层的函数),而不是传递目标深度(fn.length)进行比较。

function curry(fn) {
  if (fn.length === 0) {
    return fn;
  }
  function nest(N, args) {
    return (x) => {
      if (N - 1 === 0) {
        return fn(...args, x);
      }
      return nest(N - 1, [...args, x]);    // 根据fn.length - 1 递归那些嵌套的中间函数
    };
  }
  return nest(fn.length, []);  // 传入 fn 的参数个数
}

可变的柯里化

让我们来比较sum3sum5

const sum3 = curry((x, y, z) => {
  return x + y + z;
});
const sum5 = curry((a, b, c, d, e) => {
  return a + b + c + d + e;
});
sum3(1)(2)(3)       // 6   <--  It works!
sum5(1)(2)(3)(4)(5) // 15  <--  It works!

毫无意外,它是正确的,但这个代码是有点恶心。

在haskell和许多其他函数式语言中,它们的设计更为简洁,和上面恶心的相比,我们来看看haskell是如何处理它的:

let sum3 x y z = x + y + z
let sum5 a b c d e = a + b + c + d + e
sum3 1 2 3
> 6
sum5 1 2 3 4 5
> 15
sum5 1 2 3 (sum3 1 2 3) 5
> 17

如果你问我,JavaScript以下面的使用方式来调用会更好:

sum5(1, 2, 3, 4, 5) // 15

但这并不意味着我们不得不放弃currying。我们能做到的是找到一个两全其美的方式。一个即是“柯里化”又不是“柯里化”的调用方式。

sum3(1, 2, 3) // 清晰的
sum3(1, 2)(3)
sum3(1)(2, 3)
sum3(1)(2)(3) // 柯里化的

因此我们需要做一个简单的修改——用可变函数替换累加器函数。

当第i个累加器接收k个参数时,下一个累加器将不是N-1的深度,而是N-k``的深度。使用N-1```是由于所有的累加器都只接收一个参数,这也意味着我们不再需要判断参数为0的情况(Why?)。

由于我们现在每个层级都收集多个参数,我们需要检查参数的数量来判断是否超过fn的参数个数,然后再调用它。

function curry(fn) {
  function nest(N, args) {
    return (...xs) => {
      if (N - xs.length <= 0) {
        return fn(...args, ...xs);
      }
      return nest(N - xs.length, [...args, ...xs]);
    };
  }
  return nest(fn.length, []);
}

接下来是测试时间,你可以在codepen上运行测试。

function curry(){...}
const sum3 = curry((x, y, z) => x + y + z);
console.log(
  sum3(1, 2, 3),
  sum3(1, 2)(3),
  sum3(1)(2, 3),
  sum3(1)(2)(3),
);
// 6 6 6 6

调用空的累加器

当使用可变参数的柯里化时,我们可以不向它传递任何参数来调用累加器函数。这将返回另一个与前一个累加器相同的累加器。

const sum3 = curry((x, y, z) => x + y + z);
sum3(1,2,3) // 6
sum3()()()(1,2,3) // 6
sum3(1)(2,3) // 6
sum3()()()(1)()()(2,3) // 6

这种调用十分恶心,有一系列的空括号。虽然技术上没有问题,但这个写法是很糟糕的,因此需要有一个避免这种糟糕写法的方式。

function curry(fn) {
  function nest(N, args) {
    return (...xs) => {
      if (xs.length === 0) {    // 避免空括号
        throw Error('EMPTY INVOCATION');
      }
      // ...
    };
  }
  return nest(fn.length, []);
}

另一种柯里化的方式

我们成功了!我们创造了一个curry函数,它接收多个函数参数并返回带有可变参数的柯里化函数。但我想展示JavaScript中的另一种柯里化方法

在JavaScript中,我们可以将参数bind(绑定)到函数并创建绑定副本。返回的函数是只是“部分应用”,因为函数已经拥有它所需的一些参数,但在调用之前需要更多。

到目前为止,curry将返回一个函数,该函数在收到所有参数之前在不停地累积参数,然后使用这些参数来调用fn。通过将参数绑(译注:bind方法)定到函数,我们可以消除对多个嵌套累加器函数。

因此可以得到:

function curry(fn) {
  return (...xs) => {
    if (xs.length === 0) {
      throw Error('EMPTY INVOCATION');
    }
    if (xs.length >= fn.length) {
      return fn(...xs);
    }
    return curry(fn.bind(null, ...xs));
  };
}

以上是它的工作原理。curry采用多个参数的函数并返回累加器函数。当用k个参数调用累加器时,我们检查k>=N,即判断是否满足函数所需的参数个数。

如果满足,我们传入参数并调用fn,如果没满足,则创建一个fn的副本,它具有绑定调用fn前的那些累加器的k个参数,并将其作为下一个fn传递给curry,以达到减少N-k的目的。


最后

我们通过累加器的方式编写了通用的柯里化方法。这种方法适用于函数是一等公民的语言。我们看到了严格的柯里化和可变参数的柯里化之间的区别。感谢JavaScript中提供了bind方法,用bind方法实现柯里化是非常容易的。

如果您对源代码感兴趣,请戳codepen


后记

给柯里化添加静态类型检查

在2018年,人们喜欢JavaScript中的静态类型。而且我认为现在是时候添加一些类型约束以保证类型安全了。

让我们从基础开始:curry()接收一个函数并返回一个值或另一个函数。我们可以这样写:

type Curry = <T>(Function) => T | Function;
const curry: Curry = (fn) => {
  ...
}
// function declaration
function curry<T>(fn: Function): T | Function {
  ...
}

好了。但是这并没有什么用。但这是能做到最好的程度了,Flow只增加了静态类型的安全性,而实际上我们有很多运行时的依赖性。此外,Flow不支持Haskell具有的跟更高阶类型。这意味着没有为这种通用的柯里化添加更紧密的类型检查。

If you still want a typed curry, here’s a gist by zerobias that show a 2-level and a 3-level curry function with static types: zerobias/92a48e1.

If you want to read more about curry and JS, here’s an article on 2ality.


严格意义上的柯里化

可变参数的柯里化是一个很好的东西,因为它为我们提供了一些空间。但是,我们不要忘记,严格意义上的柯里化应该只接收一个参数。

... 柯里化是将函数分解为一系列函数的过程,每个函数都接收一个参数

让我们编写一个严格的柯里化函数——一种只允许单个参数传递个柯里化函数。

function strictCurry(fn) {
  return (x) => {
    if (fn.length <= 1) {
      return fn(x);
    }
  return strictCurry(fn.bind(null, x));
  };
}

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

推荐阅读更多精彩内容