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

局部应用(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 而已,不
过相似的应用场景仍是能够想象获得的。那么这个例子和局部应用有什么关联呢?app

如下是一些客观陈述的事实(可是很重要,确保你看明白了):函数式编程

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

简单明了吧?因为 map 要两个参数,咱们也给了两个参数,因而咱们能够说:函数

map 函数 彻底应用 了咱们传入的参数。code

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

但是这要怎么实现呢?对象

首先,咱们把 map 包装一下:ip

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),最左形式和最右形式会对应用形态的语义化表达产生不一样的影
响:

  1. 对于最右形式的应用,如 squareAll([...]),它的潜台词是:无论传入
    的是什么,把它们挨个都平方咯。从语义角度来看,square 是主体,而
    传入的数组是客体;
  2. 对于最左形式的应用,如 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);

像这样的“先局部套用而后紧接着局部应用”的模式是很是广泛的,咱们为何不
进一步抽象化它呢?

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

  1. 最右形式的局部套用
  2. 针对 map
  3. 一元
  4. 局部应用

理一理语序能够组合成:针对 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 给 予个人帮助和指导是我难以忘记的,在此向两位以及全部帮助个人大牛们致谢!

相关文章
相关标签/搜索