局部应用(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]
是主体,而之后传入的square
或double
才是客体;
所以说,根据应用的场景来选择最合适的形式吧,不必拘泥于特定的某种形式。
回到现实
至此,我们已经把局部应用和局部套用的微妙差别分析的透彻了,但这更多的是理
论性质的研究罢了,现实中这两者的界限则非常模糊——所以很多人习惯混为一谈
也就不很意外了。
就拿 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 给
予我的帮助和指导是我难以忘记的,在此向两位以及所有帮助我的大牛们致谢!