函数式编程中局部应用(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 而已,不
过类似的应用场景还是可以想象得到的。那么这个例子和局部应用有什么关联呢?

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

  1. 我们的 map 是一个二元函数;
  2. square 是一个一元函数;
  3. 调用我们的 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),最左形式和最右形式会对应用形态的语义化表达产生不同的影
响:

  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 给
予我的帮助和指导是我难以忘记的,在此向两位以及所有帮助我的大牛们致谢!

你可能感兴趣的:(currying,函数式编程,javascript)