资讯专栏INFORMATION COLUMN

函数式编程中局部应用(Partial Application)和局部套用(Currying)的区别

AaronYuan / 775人阅读

摘要:真正留给我们要实现的仅仅是返回另外一部分用于局部应用的一元函数罢了。总结各用一句话做个小结吧局部应用是一种转换技巧,通过预先传入一个或多个参数来把多元函数转变为更少一些元的函数甚或是一元函数。

  

局部应用(Partial Application,也译作“偏应用”或“部分应用”)和局部
套用( Currying, 也译作“柯里化”),是函数式编程范式中很常用的技巧。
本文着重于阐述它们的特点和(更重要的是)差异。

元(arity)

在后续的代码示例中,会频繁出现 unary(一元),binary(二元),
ternary(三元)或 polyadic(多元,即多于一元)以及 variadic(可变
元)等数学用语。在本文所表述的范围内,它们都是用来描述函数的参数数量的。

局部应用

先来一个“无聊”的例子,实现一个 map 函数:

function map(list, unaryFn) {
  return [].map.call(list, unaryFn);
}

function square(n) {
  return n * n;
}

map([2, 3, 5], square);   // => [4, 9, 25]

这个例子当然缺乏实用价值,我们仅仅是仿造了数组的原型方法 map 而已,不
过类似的应用场景还是可以想象得到的。那么这个例子和局部应用有什么关联呢?

以下是一些客观陈述的事实(但是很重要,确保你看明白了):

我们的 map 是一个二元函数;

square 是一个一元函数;

调用我们的 map 时,我们传入了两个参数([2, 3, 5]square),
这两个参数都应用在 map 函数里,并返回给我们最终的结果。

简单明了吧?由于 map 要两个参数,我们也给了两个参数,于是我们可以说:

  

map 函数 完全应用 了我们传入的参数。

而所谓局部应用就像它的字面意思一样,函数调用的时候只提供部分参数供其应用
——比方说上例,调用 map 的时候只传给它一个参数。

可是这要怎么实现呢?

首先,我们把 map 包装一下:

function mapWith(list, unaryFn) {
  return map(list, unaryFn);
}

然后,我们把二元的包装函数变成两个层叠的一元函数:

function mapWith(unaryFn) {
  return function (list) {
    return map(list, unaryFn);
  };
}

于是,这个包装函数就变成了:先接收一个参数,然后返回给我们一个函数来接受
第二个参数,最终再返回结果。也就是这样:

mapWith(square)([2, 3, 5]);  // => [4, 9, 25]

到目前为止,局部应用似乎没有体现出什么特别的价值,然而如果我们把应用场景
稍微扩展一下的话……

var squareAll = mapWith(square);
squareAll([2, 3, 5]);     // => [4, 9, 25]
squareAll([1, 4, 7, 6]);  // => [1, 16, 49, 36]

我们把对象 square(函数即对象)作为部分参数应用在 map 函数中,得到一
个一元函数,即 squareAll,于是我们可以想怎么用就怎么用。这就是局部应用
,恰当的使用这个技巧会非常有用。

局部套用

我们可以在局部应用的例子的基础上继续探索局部套用,首先把前面的 mapWith
稍微修整修整:

function wrapper(unaryFn) {
  return function(list) {
    return binaryFn(list, unaryFn);
  };
}
function wrapper(secondArg) {
  return function(firstArg) {
    return binaryFn(firstArg, secondArg);
  };
}

如上,我刻意把修整分作两步来写。第一步,我们把 map 用一个更抽象的
binaryFn 取代,暗示我们不局限于做数组映射,可以是任何一种二元函数的处
理;同时,最外层的 mapWith 也就没有必要了,使用更抽象的 wrapper 取代
。第二步,既然用作处理的函数都抽象化了,传入的参数自然也没有必要限定其类
型,于是就得到了最终的形态。

接下来的思考非常关键,请跟紧咯!

考虑一下未修整前的形态,最里层的 map 是哪里来的?——那是我们在最开始
的时候自己定义的。然而到了修整后的形态,binaryFn 是个抽象的概念,此时
此刻我们并没有对应的函数可以直接调用它,那么我们要如何提供这一步?

再包装一层,把 binaryFn 作为参数传进来——

1 function rightmostCurry(binaryFn) {
2   return function (secondArg) {
3     return function (firstArg) {
4       return binaryFn(firstArg, secondArg);
5     };
6   };
7 }

你是否意识到这其实就是函数式编程的本质(的体现形式之一)?

那么,局部套用是如何体现出来的呢?我们把一开始写的那个 map 函数套用进
来玩玩:

var rightmostCurriedMap = rightmostCurry(map);

var squareAll = rightmostCurriedMap(square);
squareAll([2, 3, 5]);     // => [4, 9, 25]
squareAll([1, 4, 7, 6]);  // => [1, 16, 49, 36]

最后三句和之前讲局部应用的例子是一样的,局部套用的体现就在第一句上。乍一
看,这貌似就是又多了一层局部应用而已啊?不,它们是有差别的!

对比一下两个例子:

// 局部应用
function mapWith(unaryFn) {
  return function (list) {
    return map(list, unaryFn);
  };
}

// 局部套用
1 function rightmostCurry(binaryFn) {
2   return function (secondArg) {
3     return function (firstArg) {
4       return binaryFn(firstArg, secondArg);
5     };
6   };
7 }

在局部应用的例子里,最内层的处理函数是确定的;换言之,我们对最终的处理方
式是有预期的。我们只是把传入参数分批完成,以获得:一)较大的应用灵活性;
二)更单纯的函数调用形态。

而在局部套用的例子里,第 2~6 行还是局部应用——这没差别;但是可以看出
最内层的处理在定义的时候其实是未知的,而第 1 行的目的是为了传入用于最
终处理的函数。因此我们需要先传入进行最终处理的函数,然后再给它分批传入参
数(局部应用),以获得更大的应用灵活性。

回过头来解读一下这两个名词:

局部应用: 返回最终结果的处理方式是限定的,每一层的函数调用所传入
的参数都将逐次参与最终处理过程中去;

局部套用: 返回最终结果的处理方式是未知的,需要我们在使用的时候将
其作为参数传入。

最左形式(leftmost)与最右形式(rightmost)的局部套用

在前面的例子中,为什么要把局部套用函数命名为 rightmostCurry?另外,是
否还有与之对应的 leftmostCurry 呢?

请回头再看一眼上例的第 2~6 行,会发现层叠的两个一元函数先传入
secondArg,再传入 firstArg,而最内层的处理函数则是反过来的。如此一来
,我们先接受最右边的,再接受最左边的,这就叫最右形式的局部套用;反之则是
最左形式的局部套用。

  

即使在本文的例子里都使用二元参数,但其实多元也是一样的,无非就是增加局
部应用的层叠数量;而可变元的应用也不难,完全可以用某种数据结构来封装多
个元参数(如数组)然后再进行解构处理——ES6 的改进会让这一点变得更加简
单。

但是这又有什么实际意义呢?仔细对比下面两个代码示例:

function rightmostCurry(binaryFn) {
  return function (secondArg) {
    return function (firstArg) {
      return binaryFn(firstArg, secondArg);
    };
  };
}

var rightmostCurriedMap = rightmostCurry(map);

function square(n) { return n * n; }

var squareAll = rightmostCurriedMap(square);
squareAll([2, 3, 5]);     // => [4, 9, 25]
squareAll([1, 4, 7, 6]);  // => [1, 16, 49, 36]
function leftmostCurry(binaryFn) {
  return function (firstArg) {
    return function (secondArg) {
      return binaryFn(firstArg, secondArg);
    };
  };
}

var leftmostCurriedMap = leftmostCurry(map);

function square(n) { return n * n; }
function double(n) { return n + n; }

var oneToThreeEach = leftmostCurriedMap([1, 2, 3]);
oneToThreeEach(square);   // => [1, 4, 9]
oneToThreeEach(double);   // => [2, 4, 6]

这两个例子很容易理解,我想就无须赘述了。值得注意的是,由于“从左向右”的
处理更合逻辑一些,所以现实中最左形式的局部套用比较常见,而且习惯上直接把
最左形式的局部套用就叫做 curry,所以如果没有显式的 rightmost 出现,
那么就可以按照惯例认为它是最左形式的。

最后,何时用最左形式何时用最右形式?嗯……这个其实没有规定的,完全取决于
你的应用场景更适合用哪种形式来表达。从上面的对比中可以发现同样的局部套用
(都套用 map),最左形式和最右形式会对应用形态的语义化表达产生不同的影
响:

对于最右形式的应用,如 squareAll([...]),它的潜台词是:不管传入
的是什么,把它们挨个都平方咯。从语义角度来看,square 是主体,而
传入的数组是客体;

对于最左形式的应用,如 oneToThreeEach(...),不必说,自然是之前传入
[1, 2, 3] 是主体,而之后传入的 squaredouble 才是客体;

所以说,根据应用的场景来选择最合适的形式吧,不必拘泥于特定的某种形式。

回到现实

至此,我们已经把局部应用和局部套用的微妙差别分析的透彻了,但这更多的是理
论性质的研究罢了,现实中这两者的界限则非常模糊——所以很多人习惯混为一谈
也就不很意外了。

就拿 rightmostCurry 那个例子来说吧:

function rightmostCurry(binaryFn) {
  return function (secondArg) {
    return function (firstArg) {
      return binaryFn(firstArg, secondArg);
    };
  };
}

像这样局部套用掺杂着局部应用的代码在现实中只能算是“半成品”,为什么呢?
因为你很快会发现这样的尴尬:

var squareAll = rightmostCurry(map)(square);
var doubleAll = rightmostCurry(map)(double);

像这样的“先局部套用然后紧接着局部应用”的模式是非常普遍的,我们为什么不
进一步抽象化它呢?

对于普遍化的模式,人们习惯于给它一个命名。对于上面的例子,可分解描述为:

最右形式的局部套用

针对 map

一元

局部应用

理一理语序可以组合成:针对 map 的最右形式(局部套用)的一元局部应用。

真尼玛的啰嗦!

实际上我们真正想做的是:先给 map 函数局部应用一个参数,返回的结果可以
继续应用 map 需要的另外一个参数(当然,你可以把 map 替换成其他的函
数,这就是局部套用的职责表现了)。真正留给我们要实现的仅仅是返回另外一部
分用于局部应用的一元函数罢了。

因此按照函数式编程的习惯,rightmostCurry 可以简化成:

function rightmostUnaryPartialApplication(binaryFn, secondArg) {
  return rightmostCurry(binaryFn, secondArg);
}

先别管冗长的命名,接着我们套用局部应用的技巧,进一步改写成更简明易懂的形
式:

function rightmostUnaryPartialApplication(binaryFn, secondArg) {
  return function (firstArg) {
    return binaryFn(firstArg, secondArg);
  };
}

这才是你在现实中随处可见的“完全形态”!至于冗长的命名,小问题啦:

var applyLast = rightmostUnaryPartialApplication;

var squareAll = applyLast(map, square);
var doubleAll = applyLast(map, double);

如此一来,最左形式的相似实现就可以无脑出炉了:

function applyFirst(binaryFn, firstArg) {
  return function (secondArg) {
    return binaryFn(firstArg, secondArg);
  };
}

其实这样的代码很多开发者都已经写过无数次了,可是如果你请教这是什么写法,
回答你“局部应用”或“局部套用”的都会有。对于初学者来说就容易闹不清楚到
底有什么区别,久而久之就干脆认为是一回事儿了。不过现在你应该明白过来了,
这个完全体其实是“局部应用”和“局部套用”的综合应用。

总结

各用一句话做个小结吧:

局部应用(Partial Application):是一种转换技巧,通过预先传入一个或多
个参数来把多元函数转变为更少一些元的函数甚或是一元函数。

局部套用(Currying):是一种解构技巧,用于把多元函数分解为多个可链式调
用的层叠式的一元函数,这种解构可以允许你在其中局部应用一个或多个参数,但
是局部套用本身不提供任何参数——它提供的是调用链里的最终处理函数。

  

后记:撰写本文的时间跨度较长,期间参考的资料和代码无法一一计数。但是
Raganwald 的书和博客 以及 Michael Fogue
的 Functional JavaScript 给
予我的帮助和指导是我难以忘记的,在此向两位以及所有帮助我的大牛们致谢!

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/85380.html

相关文章

  • SegmentFault 技术周刊 Vol.16 - 浅入浅出 JavaScript 函数编程

    摘要:函数式编程,一看这个词,简直就是学院派的典范。所以这期周刊,我们就重点引入的函数式编程,浅入浅出,一窥函数式编程的思想,可能让你对编程语言的理解更加融会贯通一些。但从根本上来说,函数式编程就是关于如使用通用的可复用函数进行组合编程。 showImg(https://segmentfault.com/img/bVGQuc); 函数式编程(Functional Programming),一...

    csRyan 评论0 收藏0
  • Java 8怎么了:局部套用vs闭包

    摘要:本文主要介绍了中的闭包与局部套用功能,由国内管理平台编译呈现。譬如,认为给带来了闭包特性就是其中之一。但是首先,我们将考虑如何利用闭包进行实现。很显然,闭包打破了这一准则。这就是局部调用,它总是比闭包更为稳妥。 【编者按】本文作者为专注于自然语言处理多年的 Pierre-Yves Saumont,Pierre-Yves 著有30多本主讲 Java 软件开发的书籍,自2008开始供职于 ...

    3fuyu 评论0 收藏0
  • 基于JavaScript一些函数编程概念讲解

    摘要:以此类推,不定参数的方程也就被称为可变参数函数。一般来说,函数式编程中的值都被认为是不可变值。实现了函数的对象,即可以与其他对象进行对比判断是否属于同一类型,被称为。半群一个拥有,即将另一个对象转化为相同类型的函数,函数的对象称为。 原文地址译者的Github 系列文章地址本文原作者尚未全部完成,有兴趣的可以到原文或者译文地址关注更新 Functional Programming Ja...

    scola666 评论0 收藏0
  • 在下函数编程有何贵干

    摘要:尾声除了以上特性,函数式编程中还有,等比较难以理解的概念,本文暂时不牵扯那么深,留待有兴趣的人自行调查。 本文简单介绍了一下函数式编程的各种基本特性,希望能够对于准备使用函数式编程的人起到一定入门作用。 showImg(/img/bVyUGu); 函数式编程,一个一直以来都酷,很酷,非常酷的名词。虽然诞生很早也炒了很多年但是一直都没有造成很大的水花,不过近几年来随着多核,分布式,大数据...

    April 评论0 收藏0
  • JavaScript || 函数

    摘要:每个函数表达式包括函数对象括号和传入的实参组成。和作用都是动态改变函数体内指向,只是接受参数形式不太一样。在定义函数时,形参指定为一个对象调用函数时,将整个对象传入函数,无需关心每个属性的顺序。 函数 JavaScript中,函数指只定义一次,但可以多次被多次执行或调用的一段JavaScript代码。与数组类似,JavaScript中函数是特殊的对象,拥有自身属性和方法 每个函数对象...

    learn_shifeng 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<