原文:http://raganwald.com/2013/03/07/currying-and-partial-application.html
上周末我参加了wroc_love.rb大会,其间Steve Klabnik的一张PPT中提到了偏应用(Partial Application)和柯里化(Currying),并说这两者之间的区别如今已经不重要了。但是我不这么认为。
在这周发布的博文中,我用五种方式对this
和闭包做了解释,但只有三到四种提到了柯里化。所以这篇博文就重点来谈谈这个。
函数参数的个数
在讲解之前,我们先明确一些术语。函数定义时会写明它所接收的参数个数(Arity)。“一元函数”(Unary)接收一个参数,“多元函数”(Polyadic)接收多个参数。还有一些特殊的名称,如“二元函数”(Binary)接收两个参数,“三元函数”(Ternary)接收三个参数等。你可以对照希腊语或拉丁语词汇来创造这些特殊的名称。
有些函数能够接收不定数量的参数,我们称之为“可变参数函数”(Variadic)。不过这类函数、以及不接收参数的函数并不是本文讨论的重点。
偏应用
偏应用的概念很容易理解,我们可以使用加法函数来做简单的演示,但如果你不介意的话,我想引用allong.es这一JavaScript类库中的代码来做演示,而且它也是会在日常开发中用到的代码。
作为铺垫,我们首先实现一个map
函数,用来将某个函数应用到数组的每个元素上:
1 | var __map = [].map; |
显然,map
是二元函数,square
是一元函数。当我们使用[1, 2, 3]
和square
作为参数来调用map
时,我们是将这两个参数 应用(Apply) 到map
函数,并获得结果。
由于map
函数接收两个参数,我们也提供了两个参数,所以说这是一次 完整应用 。那何谓偏应用(或部分应用)呢?其实就是提供少于指定数量的参数。如,仅提供一个参数来调用map
。
如果我们只提供一个参数来调用map
会怎么样?我们无法得到所要的结果,只能得到一个新的一元函数,通过调用这个函数并传递缺失的参数后,才能获得结果。
假设现在我们只提供一个参数给map
,这个参数是unaryFn
。我们从后往前来逐步实现,首先为map
函数创建一个包装函数:
1 | function mapWrapper (list, unaryFn) { |
然后,我们将这个二元函数分割成两个嵌套的一元函数:
1 | function mapWrapper (unaryFn) { |
这样一来,我们就能每次仅传递一个参数来进行调用了:
1 | mapWrapper(square)([1, 2, 3]); |
和之前的map
函数相较,新的函数mapWrapper
是一元函数,它的返回值是另一个一元函数,需要再次调用它才能获得返回值。那么偏应用要从何体现?让我们从第二个一元函数着手:
1 | var squareAll = mapWrapper(square); |
我们首先将square
这个参数部分应用到了map
函数,并获得一个一元函数squareAll
,它能实现我们需要的功能。偏应用后的map
函数十分便捷,而allong.es库中提供的splat
函数做的也是相同的事情。
如果每次想要使用偏应用都需要手动编写这样一个包装函数,程序员显然会想到要自动化实现它。这就是下一节的内容:柯里化。
柯里化
首先,我们可以编写一个函数来返回包装器。我们仍然以二元函数为例:
1 | function wrapper (unaryFn) { |
将函数map
和参数名称替换掉:
1 | function wrapper (secondArg) { |
最后,我们再包装一层:
1 | function rightmostCurry (binaryFn) { |
这样一来,我们之前使用的“模式”就抽象出来了。这个函数的用法是:
1 | var rightmostCurriedMap = rightmostCurry(map); |
将一个多元函数转换成一系列一元函数的嵌套调用,这种转换称之为 柯里化 。它的名称取自其发明者Haskell Curry,他也重新定义了由Moses Schönfinkel提出的组合子逻辑(Combinatory Logic)。(注1)
rightmostCurry
函数可以将任意二元函数转换为一组一元函数,从传递第二个参数开始,因此才称其为“右起柯里化”。
和它相反的自然是“左起柯里化”,大多数逻辑学家使用“左起柯里化”,所以人们常说的柯里化指的也是左起柯里化:
1 | function curry (binaryFn) { |
那这两种柯里化方式应该如何选择呢?这就要看你的用途了。在上述二元函数的示例中,我们模拟的是一种“主体-客体”(Subject-Object)的语法。第一个参数表示主体,第二个参数表示客体。
当我们使用“右起柯里化”的map
函数时,我们即假定主体是那个将被调用多次的函数(unaryFn)。
看到squareAll([1, 2, 3])
时,我们会理解为“将数组[1, 2, 3]中的每个元素做平方运算”。使用“右起柯里化”,我们使平方运算成为主体,数组成为客体。而当使用一般的柯里化时,则是让数组作为主体,平方运算作为客体。
另一种理解的方式是看你需要重用哪一部分。通过不同的柯里化方式,你可以选择重用函数还是重用列表。
再谈偏应用
上文谈了那么多柯里化,那偏应用呢?事实上,当你有了柯里化,你就不需要偏应用了。同样地,当你使用了偏应用,也不会需要柯里化。所以当你需要为此撰写一篇文章时,最便捷的做法是先描述其中的一个,然后建立在其基础之上来描述另外一个。
首先让我们回顾一下右起柯里化:
1 | function rightmostCurry (binaryFn) { |
你会发现在实际使用过程中会一直出现以下代码:
1 | var squareAll = rightmostCurry(map)(square), |
这种创建了柯里化函数后立刻调用的情况很常见,因此好事的人们就为它起了一个名字,称之为 map函数的右起一元偏应用 。
名字很长,我们分解开来看:
- 右起:从最右边的参数开始;
- 一元:一个参数;
- 偏应用:只应用部分函数;
- map:即
map
函数。
所以我们实际上是想为map
函数预先指定一个参数。它是一个二元函数,指定参数后便成了一元函数。在函数式编程语言或类库中,都提供了相应的方式来支持这种用法。
我们可以用柯里化来实现这样的功能:
1 | function rightmostUnaryPartialApplication (binaryFn, secondArg) { |
但更多时候我们会使用更为直接的方式:(注2)
1 | function rightmostUnaryPartialApplication (binaryFn, secondArg) { |
rightmostUnaryPartialApplication
有些过长了,我们将其称为applyLast
:
1 | var applyLast = rightmostUnaryPartialApplication; |
这样,我们的squareAll
和doubleAll
函数就可以写为:
1 | var squareAll = applyLast(map, square), |
你同样可以实现一个applyFirst
函数(我们就不提leftmostUnaryPartialApplication
这种叫法了):
1 | function applyFirst (binaryFn, firstArg) { |
和“左起/右起柯里化”一样,你应该在工具箱中保留这两种偏应用的方式,以便在实际使用过程中选择。
柯里化和偏应用的区别
“柯里化是将一个多元函数分解为一系列嵌套调用的一元函数。分解后,你可以部分应用一个或多个参数(注3)。柯里化的过程不会向函数传递参数。”
“偏应用是为一个多元函数预先提供部分参数,从而在调用时可以省略这些参数。”
这就是全部吗?
是,但又不是。以下这些还请读者自行探索和实现:
- 上文中,我们用柯里化实现了偏应用,那偏应用可以实现柯里化吗?为什么?(注4)
- 所有的示例都是将二元函数转换为一元函数,尝试写出一个更为通用的
applyFirst
和applyLast
函数,能够为任意元的函数提供一个参数。如,假设有一个函数接收四个参数,那在使用了applyFirst
后会返回一个接收三个参数的函数。 - 第2步完成后,再实现一组
applyLeft
和applyRight
函数,它能为任意元的函数预先指定任意数量的参数,如,假设向applyLeft
传递了一个三元函数和两个参数,那就会返回一个一元函数。 - 重写
curry
和rightmostCurry
这两个函数,使其能够接收任意元的函数。一个三元函数柯里化后会产生三个嵌套调用的一元函数。 - 阅读allong.es的代码,这是一个从JavaScript Allongé中提取的函数式编程类库。重点阅读它的partial_application.js文件。
感谢你的阅读,如果你在代码中发现了Bug,请克隆这个镜像,提交合并申请,或者在Github上提交一个事务。
PS:你可能会对另一篇文章也感兴趣:Practical Applicaitons for Partial Application。
(讨论)