title: 管理函数入口 category: JS轻量级函数式编程 date: 2017-05-01 tag: [JavaScript,函数式编程,翻译] layout: post toc: true
《JS轻量级函数式编程》系列的第三章。如果你觉得前两章的难度实在是不屑一顾,那么从这一章开始你就要小心了,从这一章我们将会正式进入函数式编程的学习。也不要太害怕啦,不要被这一章中各种乱七八糟的高阶函数吓到了……其实想明白了之后也并不复杂。
在第二章的“函数输入”这一节中,我们讨论了函数形参和实参的基础。我们还看到了一些语法上的技巧,来缓解它们使用上的一些问题,比如...
运算符和解构赋值。
在当时的讨论中,我建议尽可能的尝试设计只有一个形参的函数。但事实上要做到这一点很不容易,你并不总是控制着你需要使用的函数签名。
现在,我们将会把我们的目光转向更为复杂更为强大的模式,它们都能用于处理在这些场景中的函数输入。
如果一个函数拥有多个实参,你可能马上就能指定一些参数,同时其余的参数将会被留下来稍后再指定。
思考这个函数:
function ajax(url,data,callback) {
// ..
}
让我们想象一下吧,你现在要设置几个API的调用,其中URL是预先就知道的,但是处理的响应数据和回调要等会儿才会知道。
当然,你也可以等到当所有的数据都已经知道的时候再调用ajax(..)
,此时再去引用全局常量URL。但是还有另外一种方法,那就是创建一个已经带有url
实参的函数引用。
我们要做的是创建一个仍然在底层调用ajax(..)
的新函数,并且手动将API URL设置为第一个实参,然后等待接受另外两个实参。
function getPerson(data,cb) {
ajax( "http://some.api/person", data, cb );
}
function getOrder(data,cb) {
ajax( "http://some.api/order", data, cb );
}
手动指定这些函数调用的封装当然是可以的,但它会变得非常冗长,特别是在有不同参数预设的变化时,比如:
function getCurrentUser(cb) {
getPerson( { user: CURRENT_USER_ID }, cb );
}
在实践中,函数式的编程者们总是习惯于寻找经常重复运用的操作模式,并尝试把这些行为转变为通用的可复用的实用程序。事实上,我相信这已经是许多读者的本能了。所以这并不是只有在函数式编程中才会出现的事情,但是这毫无疑问对于函数式来说非常重要。
为了构思上述这种用于预置实参的工具函数,我们不能仅看上面手动的实现,还需要从概念上来审视它,看看到底发生了什么。
我们一般会这么描述这种模式,getOrder(data,cb)
函数是ajax(url,data,cb)
函数的局部应用(partial application)。这个术语的概念就是来源于函数调用时实参被应用到了形参。正如你所看到的,我们只使用了前面的一些参数——特别给url
形参提供了实参——而剩下的则会在稍后被应用。
对于这种模式稍微正式点的描述是这样的,局部应用能够严格的降低函数的计数值;计数值,是指函数预期形参的输入数量。我们的计数值从原始函数ajax(..)
的3
降低到了getOrder(..)
函数的2
。
我们再来定义一个工具函数partial(..)
:
function partial(fn,...presetArgs) {
return function partiallyApplied(...laterArgs){
return fn( ...presetArgs, ...laterArgs );
};
}
上面这个片段可不要看过就完了,稍微花点时间来消化这个程序到底发生了什么,以确保你真的了解了它。这里的这个代码模式实际上会在本书的其它部分一遍又一遍的出现,所以现在就马上掌握它吧!
partial(..)
函数接受到了一个局部应用的函数fn
。然后传入的任何后续实参都会被聚合到presetArgs
数组中,以备后续使用。
这个函数创建并返回了一个新的内部函数(为了清楚起见,我们称之为partiallyApplied(..)
),其自身的实参被聚合到了名为laterArgs
的数组中。
注意到这个内部函数对fn
和presetArgs
的引用了吗?这部分是如何工作的呢?在partial(..)
运行之后,内部函数是如何能够保持对fn
和presetArgs
的访问的呢?如果你的答案是闭包,恭喜!你答对了!内部函数partiallyApplied(..)
闭合了fn
和presetArgs
变量,所以无论它在哪里运行,它都可以随时访问这两个变量。看到了吗,理解闭包实在是太重要了。
当partiallyApplied(..)
函数在你的程序的其他地方运行的时候,它将调用闭包中的fn
来运行原始的函数,拿出一开始局部应用中输入的实参presetArgs
(在闭包中),然后再使用之后输入的laterArgs
实参。
如果你觉得有点晕,请停下来重新阅读这里。相信我,在接下来的文本中你会很高兴现在的你这么做了。
作为附注,函数式编程者们通常会喜欢这种代码较短的=>
箭头函数语法(请参考第一章 “语法”),例如:
var partial =
(fn, ...presetArgs) =>
(...laterArgs) =>
fn( ...presetArgs, ...laterArgs );
这么写是没有问题的,而且毫无疑问要更简洁更稀疏。但是我个人觉得无论这里的数学符号再怎么对称,它在整体可读性方面失去的更多,所有这些函数都是匿名的,并且由于这里模糊的函数边界,导致想要辨别这里的闭包变得更加困难。
无论使用哪种语法,你都能看出,我们使用partial(..)
函数实现了提前局部应用的函数:
var getPerson = partial( ajax, "http://some.api/person" );
var getOrder = partial( ajax, "http://some.api/order" );
在这里暂停一下,然后好好想想getPerson(..)
函数的形态/内部。它应看起来应该是这样:
var getPerson = function partiallyApplied(...laterArgs) {
return ajax( "http://some.api/person", ...laterArgs );
};
getOrder(..)
也是如此,那么getCurrentUser(..)
又是怎样的呢?
// version 1
var getCurrentUser = partial(
ajax,
"http://some.api/person",
{ user: CURRENT_USER_ID }
);
// version 2
var getCurrentUser = partial( getPerson, { user: CURRENT_USER_ID } );
我们可以直接指定url
和数据的实参来定义getCurrentUser(..)
(版本一),也可以把 getCurrentUser(..)
定义为getPerson(..)
的局部应用,同时仅指定附加的数据实参。
版本二是个更为简洁的表达,因为它重用了一些已经定义的东西,因此我认为它更为符合函数式的精神。
为了确保我们理解了这两个版本的函数是如何工作的,下面分别是它们此时的完整代码:
// version 1
var getCurrentUser = function partiallyApplied(...laterArgs) {
return ajax(
"http://some.api/person",
{ user: CURRENT_USER_ID },
...laterArgs
);
};
// version 2
var getCurrentUser = function outerPartiallyApplied(...outerLaterArgs) {
var getPerson = function innerPartiallyApplied(...innerLaterArgs){
return ajax( "http://some.api/person", ...innerLaterArgs );
};
return getPerson( { user: CURRENT_USER_ID }, ...outerLaterArgs );
}
同样的,在这里暂停,并重新阅读这里的代码片段,以确保你理解了这里发生了什么。
第二个版本有一个额外的函数包装层,这看起来可能有点奇怪以及多余,但是这也是你在函数式编程中必须要习惯的事情。随着文章的进行,我们将会把许多函数不断堆叠在一起。记住,这是*函数式编程*!
我们来看看局部应用有用性的另一个例子。考虑这样一个add(..)
函数,它接受两个实参,并把它们加了起来:
function add(x,y) {
return x + y;
}
现在想象一下,这里有一个数字列表,我们想要给这个列表中每个数字都加上一个数字。我们将使用JS数组中内置的map(..)
方法。
[1,2,3,4,5].map( function adder(val){
return add( 3, val );
} );
// [4,5,6,7,8]
不要担心你之前从没见过`map(..)`函数,我们将会在本书的后面对它进行更为详细的介绍。现在你只需要知道它将枚举一个数组的所有元素,并通过调用一个函数来产生新的值,这些新的值将会组成一个新的数组。
我们无法直接传递add(..)
给map(..)
是因为add(..)
的签名与map(..)
函数的映射并不匹配。此时局部应用就能够帮助我们了:我们可以把add(..)
函数的签名改写成可以匹配的东西。
[1,2,3,4,5].map( partial( add, 3 ) );
// [4,5,6,7,8]
JavaScript有一个名为bind(..)
的内建方法,它对所有的函数都有效。它有两个能力:预设this
上下文并应用部分实参。
我认为将这两个功能合并在一个方法中是非常不幸的。有时候你会想要显式的绑定this
上下文,而不是部分的应用实参。有时候你又会想要应用部分实参,但并不关心this
绑定。我个人几乎从来没有碰到这两者同时进行的情景。
后一种情况更是尴尬,因为你必须传递一个可忽略的占位符,这个绑定的实参(第一个)通常是null
。
像是这样:
var getPerson = ajax.bind( null, "http://some.api/person" );
这个null
真的让我发狂。
回想一下,我们的Ajax
函数的签名是ajax( url, data, cb )
。如果我们想先局部应用cb
但是又想等会再应用data
和url
?我们可以创建这样一个方法,这个方法将会把原函数包装起来,并且反转其参数顺序:
function reverseArgs(fn) {
return function argsReversed(...args){
return fn( ...args.reverse() );
};
}
// or the ES6 => arrow form
var reverseArgs =
fn =>
(...args) =>
fn( ...args.reverse() );
现在我们可以反转ajax(..)
实参的顺序了,这样我们从右边的参数开始局部应用,而不是从左边开始。想要恢复预期的顺序,我们可以继续反转局部应用的函数:
var cache = {};
var cacheResult = reverseArgs(
partial( reverseArgs( ajax ), function onResult(obj){
cache[obj.id] = obj;
} )
);
// later:
cacheResult( "http://some.api/person", { user: CURRENT_USER_ID } );
现在,我们可以使用相同的反转局部应用的技巧,来定义一个partialRight(..)
方法,它将从右边开始局部应用:
function partialRight( fn, ...presetArgs ) {
return reverseArgs(
partial( reverseArgs( fn ), ...presetArgs.reverse() )
);
}
var cacheResult = partialRight( ajax, function onResult(obj){
cache[obj.id] = obj;
});
// later:
cacheResult( "http://some.api/person", { user: CURRENT_USER_ID } );
partialRight(..)
这种实现不能保证特定的形参接收到特定的局部应用的值,它只能确保局部应用的右半部分的值是传递给原始函数最右边的实参。
比如:
function foo(x,y,z) {
var rest = [].slice.call( arguments, 3 );
console.log( x, y, z, rest );
}
var f = partialRight( foo, "z:last" );
f( 1, 2 ); // 1 2 "z:last" []
f( 1 ); // 1 "z:last" undefined []
f( 1, 2, 3 ); // 1 2 3 ["z:last"]
f( 1, 2, 3, 4 ); // 1 2 3 [4,"z:last"]
"z:last"
这个值被确实应用到z
这个形参中,只有当f(..)
函数时恰好只传递了两个实参的情况(匹配x
和y
形参)。在其余情况下,不管你在前面传入多少个实参,"z:last"
都将仅仅匹配最右边的实参。
我们来看一个类似于局部应用的技术,一个期望输入多个实参的函数被分解成连续的链式函数,每个函数都将只接收一个实参(计数值:1),并且将会返回另一个函数来接受下一个实参。
这个技术被称之为柯里化 currying^注^。
也译作:局部套用
首先,我们先来想想之前已经创建好的ajax(..)
函数被柯里化之后的样子吧。按照定义,我们应该这么使用它:
curriedAjax( "http://some.api/person" )
( { user: CURRENT_USER_ID } )
( function foundUser(user){ /* .. */ } );
也许把它拆成三个独立的调用有助于我们更好的理解情况:
var personFetcher = curriedAjax( "http://some.api/person" );
var getCurrentUser = personFetcher( { user: CURRENT_USER_ID } );
getCurrentUser( function foundUser(user){ /* .. */ } );
在这里既没有立刻使用所有参数(比如ajax(..)
),也没有先应用部分然后再应用剩下的(比如partial(..)
),这里的curriedAjax(..)
函数在每个独立的函数调用中都只接受一个实参。
柯里化和局部应用在某种意义上是比较类似的,因为每个连续的柯里调用都可以看作是把另一个实参局部应用到原始函数中,直到所有实参都被传递了进去。
它们之间的最主要的区别是,curriedAjax(..)
将会显式的返回一个函数(我们叫它curriedGetPerson(..)
),它需要仅输入下一个实参数据,而不是所有剩下的实参(就像之前的getPerson(..)
)。
假如原始函数预期输入5个实参,那么该函数的柯里形式只需要第一个实参,然后返回一个函数来接受第二个实参,这个函数只需要接受第二个实参,并返回一个函数来接受第三个实参……以此类推。
所以,柯里化将多计数值的函数转化为一个系列函数的链式调用。
我们如何定义一个方法来实现柯里化呢?我们将会使用第二章中的一些技巧:
function curry(fn,arity = fn.length) {
return (function nextCurried(prevArgs){
return function curried(nextArg){
var args = prevArgs.concat( [nextArg] );
if (args.length >= arity) {
return fn( ...args );
}
else {
return nextCurried( args );
}
};
})( [] );
}
给ES6=>
符号的粉丝们:
var curry =
(fn, arity = fn.length, nextCurried) =>
(nextCurried = prevArgs =>
nextArg => {
var args = prevArgs.concat( [nextArg] );
if (args.length >= arity) {
return fn( ...args );
}
else {
return nextCurried( args );
}
}
)( [] );
这个方法将会从实参集合prevArgs
为空[]
数组的时候开始,并将每个接收到的nextArg
添加其中,然后调用串联好的args
数组。当args.length
小于arity
(原始函数fn(..)
声明/期望的的形参数量)时,将会返回另一个curried(..)
函数来继续收集接下来的nextArg
实参,传递运行的args
集合作为prevArgs
。一旦我们有了足够的实参,就可以用它们来执行原始函数fn(..)
函数了。
默认情况下,这样的实现依赖于能够检查待柯里化函数的length
属性,以确定在收集所有预期的实参之前需要迭代多少次柯里化。
如果你对有不准确length
属性的函数使用了这里的curry(..)
实现——如何函数的形参签名包含了默认形参值,形参解构赋值,又或者是...args
运算,请参考第二章——你需要手动的将arity
(curry(..)
的第二个形参)传递进去,以确保curry(..)
正确工作。
这里是我们如何使用curry(..)
来改写我们之前的ajax(..)
的例子:
var curriedAjax = curry( ajax );
var personFetcher = curriedAjax( "http://some.api/person" );
var getCurrentUser = personFetcher( { user: CURRENT_USER_ID } );
getCurrentUser( function foundUser(user){ /* .. */ } );
每次调用都给原始函数ajax(..)
的调用增加一个实参,直到提供了所有的三个参数,此时ajax(..)
就被执行了。
还记得我们之前给列表中每个值加3
的例子吗?我们之前说过,柯里化和局部应用是很相似的,所以我们可以用几乎相同的方法来执行这个任务:
[1,2,3,4,5].map( curry( add )( 3 ) );
// [4,5,6,7,8]
这两者之间有什么区别?partial(add,3)
vs curry(add)(3)
。为什么你会选择curry(..)
而不是partial(..)
?虽然你提前知道add(..)
是用来调整的函数,但此时你并不知道用来调整的值是3
:
var adder = curry( add );
// later
[1,2,3,4,5].map( adder( 3 ) );
// [4,5,6,7,8]
另一个数字的例子会是怎么样呢,把它们同时排列出来就行啦:
function sum(...args) {
var sum = 0;
for (let i = 0; i < args.length; i++) {
sum += args[i];
}
return sum;
}
sum( 1, 2, 3, 4, 5 ); // 15
// now with currying:
// (5 to indicate how many we should wait for)
var curriedSum = curry( sum, 5 );
curriedSum( 1 )( 2 )( 3 )( 4 )( 5 ); // 15
在这里柯里化的好处是,每次调用传递实参都会产生另一个更为专业的函数,我们可以在程序中捕获并使用该新函数。局部应用则是先指定部分实参,然后生成一个等待其余实参的函数。
如果要使用局部应用来实现依次指定一个参数,则必须在每个连续的函数上持续调用partialApply(..)
。柯里化则能自动进行,这样一次一个的独立参数调用更加符合人体工程学。
在JavaScript中,柯里化和局部应用都是用了闭包来记录实参,直到所有实参都被接收到了,然后就能执行原始运算了。
不管是柯里化的风格(sum(1)(2)(3)
)又或者是局部应用的风格(partial(sum,1,2)(3)
),相比于更为普通的函数调用而言(比如sum(1, 2, 3)
)这俩毫无疑问都看起来非常奇怪。所以,为什么我们要采用函数式的呢?这个问题有多个层面的回答。
第一个也是最为明显的原因,柯里化和局部应用都允许你在时间/空间(在整个代码中)上分离各自指定的参数,而传统的函数调用必须要事先知道所有的参数才能实现。如果你在你的代码中某个地方知道了一些参数,然后又在另外的地方确定了其他的参数,柯里化或者局部应用在这种情况下会非常有用。 从另一个层次来讲,当组合只有一个实参的函数更为容易的时候,此时是最适合用柯里化的。所以对于最终需要3个实参的函数而言,假如它被柯里化了,将会编程只需要一个实参函数的三次调用。当我们开始编写这种函数的时候,这种一元函数将会更容易使用。我们稍后会继续讨论这个话题。
到目前为止,这就是我给出的关于柯里化的定义和实现。我相信,我们同样可以在JavaScript中借鉴到这样的精神。
具体来说,如果我们简要的看一下柯里化在Haskell中的工作原理,我们可以看到,多个实参总是一次一个地输入到一个函数中,每一次都是柯里化的调用,而不是使用元组^注^在单个实参中传输多个值。
*Haskell*中的一种数据结构,结构上类似于数组,不过有些微妙的区别。当函数返回多个值时,常常用它来对多个值做封装。
比如,在Haskell:
foo 1 2 3
这里的foo
函数调用,并且具有传递了三个值1
、2
和3
的结果。但是在Haskell中的函数都是会自动的被柯里化的,这意味着每个值将会作为单独的柯里化调用被传入。对于JS而言,基本就等同于foo(1)(2)(3)
,它与上面提到的curry(..)
的风格是一样的。
在*Haskell*中,`foo(1, 2, 3)`并不是将这三个值作为独立参数一次性的传递进去,而是使用一个元组来作为单个实参。为了能工作,需要改变`foo`来处理这个元组实参。据我所知,*Haskell*没有办法通过一个函数调用来传递所有的三个实参,每个实参都有自己的柯里化调用。当然,多个调用的存在对于*Haskell*而言是透明的,但是对于JS开发者而言,在语法上来看却是非常明显的。
由于这些原因,我认为我之前所展示的curry(..)
非常忠实的展现了柯里化,所以我把它称为“严格柯里化”。
然而请注意,在大多数流行的JavaScript函数式编程库中,它们使用了更为宽松的定义。
具体来说,JS柯里化方法通常允许你为每个柯里化调用指定多个实参,重新考察我们前面的sum(..)
的例子,它看起来就像是这样:
var curriedSum = looseCurry( sum, 5 );
curriedSum( 1 )( 2, 3 )( 4, 5 ); // 15
从语法上来说,这里的( )
无疑要更少点,并且这里只有三个函数调用,比起之前的五个而言,性能更具优势。但除此之外,使用looseCurry(..)
与前面更为狭隘的curry(..)
而言,它们的最终结果都是相同的。我猜想便利性/性能因素可能是为什么这些框架允许多个实参的原因。这似乎只是一个风格问题。
这个宽松的柯里化*确实*给予了你传递比`arity`(检测到或者指定的)多的实参的能力。如果你选择了可选/可变的函数设计,这也是有益处的。例如,如果需要柯里化5个实参,宽松的柯里化仍然允许存在多余5个实参(`curriedSum(1)(2,3,4)(5,6)`),但是严格柯里化则不会支持`curriedSum(1)(2)(3)(4)(5)(6)`。
我们可以将之前的柯里化的实现更改为下面这种更为常见的宽松定义:
function looseCurry(fn,arity = fn.length) {
return (function nextCurried(prevArgs){
return function curried(...nextArgs){
var args = prevArgs.concat( nextArgs );
if (args.length >= arity) {
return fn( ...args );
}
else {
return nextCurried( args );
}
};
})( [] );
}
现在每个柯里化调用都可以接受一个或者多个(nextArgs
)实参,我们将把它作为一个练习,感兴趣的读者可以使用ES6的=>
符号来定义looseCurry(..)
,就像我们之前对curry(..)
所做的那样。
有时候也会有这样一种情况,你有一个已经柯里化的函数,但是你想把它去柯里化,转变为普通函数——基本上就像是把函数f(1)(2)(3)
变化为g(1, 2,3)
函数。
这个令人(并不)震惊的标准方法通常被称作uncurry(..)
。这里是一个简单的原生实现:
function uncurry(fn) {
return function uncurried(...args){
var ret = fn;
for (let i = 0; i < args.length; i++) {
ret = ret( args[i] );
}
return ret;
};
}
// or the ES6 => arrow form
var uncurry =
fn =>
(...args) => {
var ret = fn;
for (let i = 0; i < args.length; i++) {
ret = ret( args[i] );
}
return ret;
};
不要假设`uncurry(curry(f))`相比`f`而言会有相同的行为。在一些库中,去柯里化得到的函数和原始函数是挺相似的,但并不完全相等。当然,我们在这里的例子也是一样的。假如你传递了和原始函数相同数量的实参给去柯里化所得的函数,那么它的行为(绝大部分)是和原始函数相同的。然而,假如你只传递了较少的实参,你仍然得到一个部分柯里化的函数,它将会继续等待其他参数的输入。
下面这个片段将会说明这个古怪的行为:
function sum(...args) {
var sum = 0;
for (let i = 0; i < args.length; i++) {
sum += args[i];
}
return sum;
}
var curriedSum = curry( sum, 5 );
var uncurriedSum = uncurry( curriedSum );
curriedSum( 1 )( 2 )( 3 )( 4 )( 5 ); // 15
uncurriedSum( 1, 2, 3, 4, 5 ); // 15
uncurriedSum( 1, 2, 3 )( 4 )( 5 ); // 15
在使用uncurry(..)
函数的时候,可能更为常见的情况并不是用它来处理像刚才显示的那样手动柯里化的函数,而是由于某些其他操作而生成的柯里化函数。我们将会在本章后面的 "No Points" 中来说明这种情况。
想象一下,你把一个函数传递给了一个方法,这个方法将会传递多个实参给你的函数,但是你可能只想接受一个单独的参数。尤其是当你有我们在上文中讨论过的宽松柯里化函数时,此时你的函数就会接受更多你不想要的参数。 我们可以设计一个封装了函数调用的方法,来确保只有一个实参被传递进来。因为这里将会强制将函数当做是一元函数,所以我们这么命名它:
function unary(fn) {
return function onlyOneArg(arg){
return fn( arg );
};
}
// or the ES6 => arrow form
var unary =
fn =>
arg =>
fn( arg );
我们之前看到了map(..)
方法,它给需要映射的函数提供了三个实参,value
、index
和list
。如果您希望映射的函数仅接收其中的一个,比如value
,那就可以使用unary(..)
操作:
function unary(fn) {
return function onlyOneArg(arg){
return fn( arg );
};
}
var adder = looseCurry( sum, 2 );
// oops:
[1,2,3,4,5].map( adder( 3 ) );
// ["41,2,3,4,5", "61,2,3,4,5", "81,2,3,4,5", "101, ...
// fixed with `unary(..)`:
[1,2,3,4,5].map( unary( adder( 3 ) ) );
// [4,5,6,7,8]
另一个常用的使用unary(..)
的例子:
["1","2","3"].map( parseFloat );
// [1,2,3]
["1","2","3"].map( parseInt );
// [1,NaN,NaN]
["1","2","3"].map( unary( parseInt ) );
// [1,2,3]
对于签名parseInt(str,radix)
,很明显map(..)
将会在实参的第二个位置传递index
,而这个实参将会被parseInt(..)
解析为radix
,这并不是我们希望看到的情况。unary(..)
将会创建一个忽略除开第一个实参意外所有实参的函数,这意味着传递进去的index
将不会被错误的当做是radix
。
说道只有一个实参的函数,函数式编程的工具中还有另一个很常见的基本操作,它接受一个实参,但是不对它做任何操作就直接返回它本身:
function identity(v) {
return v;
}
// or the ES6 => arrow form
var identity =
v =>
v;
这个方法看起来实在是太简单了,以至于好像没什么用。但即使是非常简单的函数,也可以在函数式的世界中有所帮助。就像他们在戏里说过的一样:角色没有主配之分,演员才有好坏之别。
例如,假设你希望使用正则表达式来切割字符串,但是结果数组中可能存在一些空值,为了丢弃这些空值,我们可以把identity(..)
当做是谓词^注^,对它使用JS的fitler(..)
数组操作(我们将在之后的内容中做详细讲解):
var words = " Now is the time for all... ".split( /\s|\b/ );
words;
// ["","Now","is","the","time","for","all","...",""]
words.filter( identity );
// ["Now","is","the","time","for","all","..."]
谓词,原文为*Predicates*。在计算机领域内是指返回真、假或是未确定值的条件表达式。
在上面的例子中,还有另一个一元函数可以在这里当作谓词,那就是JS自己的`Boolean(..)`函数,它能够显式的将值强制转换为`true`和`false`。
identity(..)
另一个用处就是可以作为默认函数来代替转换:
function output(msg,formatFn = identity) {
msg = formatFn( msg );
console.log( msg );
}
function upper(txt) {
return txt.toUpperCase();
}
output( "Hello World", upper ); // HELLO WORLD
output( "Hello World" ); // Hello World
假如output(..)
没有formatFn
的默认值,我们可以把我们之前的朋友partialRight(..)
拿过来:
var specialOutput = partialRight( output, upper );
var simpleOutput = partialRight( output, identity );
specialOutput( "Hello World" ); // HELLO WORLD
simpleOutput( "Hello World" ); // Hello World
你也能看到identity(..)
被用来作为map(..)
调用的默认转换函数,或者是列表的reduce(..)
函数的初始值,这些方法我们我们将会在第八章中详细讲解。
某些API不允许你将值直接传递到方法中,所以你必须传递函数,即使该函数只是直接返回值。 在JS Promises中一的then(..)
方法就是这样的API。许多人声称ES6的=>
箭头函数是这种情况下的“解决方案”,但是有一个函数式的方法非常适合这个需求:
function constant(v) {
return function value(){
return v;
};
}
// or the ES6 => form
var constant =
v =>
() =>
v;
有了这个整洁的小工具,我们就可以解决then(..)
的烦恼啦:
p1.then( foo ).then( () => p2 ).then( bar );
// vs
p1.then( foo ).then( constant( p2 ) ).then( bar );
虽然`() => p2`箭头函数版本虽然比`constant(p2)`要短,但我仍然希望你能克制使用它的诱惑。箭头函数将会返回从外部而来的一个值,这从函数式的角度来说要更差一点,第五章“减少副作用”的内容将会详细介绍这类行为陷阱。
在第二章我们简单的介绍了下形参数组的解构赋值,我们回顾一下这个例子:
function foo( [x,y,...args] ) {
// ..
}
foo( [1,2,3] );
在foo(..)
的形参列表中,我们进行了这样的声明,我们希望将一个单独的数组实参分解——或者从实际效果上来讲,应该叫展开——并将其赋值给独立的命名形参x
和y
。数组中除了头两个位置之外的其他值都会被...
运算符聚合到args
数组中。
如果必须传入一个数组,但是你想把它的内容当做是独立的参数来处理,此时这个技巧会非常方便。
有时候你想使用形参数组的解构赋值,但是却没有能力去改变函数声明,想想这样的函数:
function foo(x,y) {
console.log( x + y );
}
function bar(fn) {
fn( [ 3, 9 ] );
}
bar( foo ); // fails
你能指出,为什么bar(foo)
失败了吗?
数组[3, 9]
被当做是一个单独的值被传输给了fn(..)
,但foo(..)
的正确输入应该是分立参数x
和y
。假如我们能够把函数foo(..)
的声明改变为function foo([x,y]) {..
,那就最好不过了。又或者是我们可以改变bar(..)
的行为,让它这样来进行函数调用fn(...[3, 9])
,这两个值3
和9
都将会被独立传递进去。
有时候你就是有两个这样不兼容的函数,并且由于各种外部原因,你又无法更改其声明/定义。那么问题来了,你要如何使用它们呢? 我们可以定义一个辅助函数来调整它们,以便于它将一个接收到的数组拆分开来:
function spreadArgs(fn) {
return function spreadFn(argsArr) {
return fn( ...argsArr );
};
}
// or the ES6 => arrow form
var spreadArgs =
fn =>
argsArr =>
fn( ...argsArr );
我这里的辅助函数名为`spreadArgs(..)`,但是在像*Ramda*这样的库中,它通常被称为`apply(..)`。
现在我们可以使用spreadArgs(..)
来调整foo(..)
了,这样它就能给bar(..)
正确的输入了。
bar( spreadArgs( foo ) ); // 12
这里到底发生了什么,似乎有点难以理解,但是请相信我,它们能够正常工作。从本质上来说,spreadArgs(..)
将会允许我们定义一个通过数组来return
多个值的函数,并且这多个值对于另一个函数的输入而言,也将会被看做是独立的值。
当一个函数的输出成为了另一个函数的输入,这样的行为被称作函数组合 composition,我们将会在第四章详细的介绍它们。
当我们在讨论spreadArgs(..)
方法的时候,我们再来定义一个与之相对的方法吧:
function gatherArgs(fn) {
return function gatheredFn(...argsArr) {
return fn( argsArr );
};
}
// or the ES6 => arrow form
var gatherArgs =
fn =>
(...argsArr) =>
fn( argsArr );
在*Ramda*中,这个方法被称作`unapply(..)`,它与`apply(..)`刚好相反,我觉得*扩展 spread*、*聚合 gather*这样的术语对于发生了什么更具描述性。
我们可以使用这个方法把独立的实参聚合成为一个单独的数组,因为我们可能会面临这样的情况,我们需要调整一个数组形参解构赋值的函数,让它去接受另一个函数的输出,而这些输出则是一些独立的实参。我们将会在第8章更详细的介绍reduce(..)
,但是简单的来说,它将会不断调用他那有两个独立形参的回调函数,现在我们能够把它们聚合起来了:
function combineFirstTwo([ v1, v2 ]) {
return v1 + v2;
}
[1,2,3,4,5].reduce( gatherArgs( combineFirstTwo ) );
// 15
对于有多个形参的柯里化和局部应用来说,有一个麻烦是始终无法避免的,那就是我们必须要按照一定的顺序来操作实参。有时候为了对某函数进行柯里化,我们定义了一个按照一定顺序且带形参的函数,但是在很多情况下,这个顺序是不兼容的,为了能重新排序,我们不得不做很多额外的并且很没有必要的事情。 这事儿的麻烦之处不仅仅在于我们需要一些方法来处理这些属性,还因为这些东西将会给我们的代码带来一些额外的噪音,并使我们的代码复杂化。这些东西就像是小纸屑,当它们还比较少的时候,并不是什么问题,但痛苦会随着它们的增长而累加。 面对这由实参顺序带来的暴政,我们有什么能做的吗!?
在第二章,我们介绍了命名实参的解构赋值,回想一下:
function foo( {x,y} = {} ) {
console.log( x, y );
}
foo( {
y: 3
} ); // undefined 3
我们将foo(..)
函数的第一个形参进行了解构赋值——它本来的期望输入是一个对象——现在它被赋值给了两个单独的形参x
和y
。然后,在调用的时候,我们传入了一个单独的对象实参,并且这个对象实参提供了所需属性的“命名实参”来映射到具体的形参之中。
命名实参最主要的优势就是在于,它不需要处理参数的排序,从而提高代码的可读性。如果我们正在开发与对象属性相关的方法,那就可以利用这一点来改进柯里化/局部应用。
function partialProps(fn,presetArgsObj) {
return function partiallyApplied(laterArgsObj){
return fn( Object.assign( {}, presetArgsObj, laterArgsObj ) );
};
}
function curryProps(fn,arity = 1) {
return (function nextCurried(prevArgsObj){
return function curried(nextArgObj = {}){
var [key] = Object.keys( nextArgObj );
var allArgsObj = Object.assign( {}, prevArgsObj, { [key]: nextArgObj[key] } );
if (Object.keys( allArgsObj ).length >= arity) {
return fn( allArgsObj );
}
else {
return nextCurried( allArgsObj );
}
};
})( {} );
}
我们甚至不需要partialPropsRight(..)
方法,因为我们实际上并不关心属性映射的顺序,名称映射让参数顺序变得无足轻重。
下面的代码演示了我们如何使用这些方法:
function foo({ x, y, z } = {}) {
console.log( `x:${x} y:${y} z:${z}` );
}
var f1 = curryProps( foo, 3 );
var f2 = partialProps( foo, { y: 2 } );
f1( {y: 2} )( {x: 1} )( {z: 3} );
// x:1 y:2 z:3
f2( { z: 3, x: 1 } );
// x:1 y:2 z:3
顺序已经变得完全不重要了!现在,我们可以在任何有意义的序列中指定所需的实参,不会再有reverseArgs(..)
或者其他什么恼人的事情了!太棒了!
不幸的是,只有当我们控制了foo(..)
的签名,并且将其第一个形参定义为解构赋值的时候,这项技术才能起作用。假设我们遇到了这样一个情况,这个函数具有着独立的形参列表(没有形参解构赋值),并且我们不能改变它的签名,这种时候我们又想使用这个技术,应该怎么办呢?
function bar(x,y,z) {
console.log( `x:${x} y:${y} z:${z}` );
}
和之前的spreadArgs(..)
方法很相似,我们可以定义一个spreadArgProps(..)
方法来辅助我们,这个方法将key: value
对从对象实参中拿出,并把这些“展开”的值作为独立实参传出。
不过,这里有些坑需要注意。spreadArgs(..)
我们用它来处理数组,这也就意味着它内部的顺序是非常明确的确定了的。然而,对于对象而言,属性的顺序并不是那么清楚明了,或者说不一定是可靠的。对于创建方式或者说属性设置不同的对象而言,我们并不能百分之百确定在枚举属性的时候会出现什么。
这个方法需要一种方式来定义所涉及函数其期望的形参顺序(如属性枚举顺序)。我们可以通过像["x", "y", "z"]
这样的数组来告诉方法,使用这个顺序来从实参中拉出属性值。
这种方法是王道的,但也是遗憾的……因为不管这个函数有多么简单,我们都必须为它添加属性名称数组。那么,至少是在常见的简单情况下,我们有什么技巧可以用来检测函数形参列表的顺序呢?幸运的是,答案是肯定的!
JavaScript函数有一个.toString()
方法,它将会把函数代码用字符串表示出来,这其中当然也就包含了函数声明的签名。拿出我们封存许久的正则表达式技能,我们就能够解析代表着函数的这堆字符串,从中找出独立的命名形参。这段代码看起来有点粗糙,但是它已经足够完成它的工作了:
function spreadArgProps(
fn,
propOrder =
fn.toString()
.replace( /^(?:(?:function.*\(([^]*?)\))|(?:([^\(\)]+?)\s*=>)|(?:\(([^]*?)\)\s*=>))[^]+$/, "$1$2$3" )
.split( /\s*,\s*/ )
.map( v => v.replace( /[=\s].*$/, "" ) )
) {
return function spreadFn(argsObj) {
return fn( ...propOrder.map( k => argsObj[k] ) );
};
}
这个方法的形参解析逻辑离无懈可击还远的很,当我们决定使用正则表达式来解析代码的时候,这就已经错了。但是,我们唯一的目的是为了处理相同的情况,这个理由就已经足够了。我们需要的只是一个用于检测含有简单形参(当然也包含它的默认值)的函数的形参的顺序的函数。例如,我们并不需要它能够解析复杂的解构赋值的形参,因为无论如何,我们都不大可能使用这个方法。因此,这一逻辑能够完成差不多80%的工作,它允许我们重写`propOrder`数组,用于解析其他更复杂的函数签名,否则它们将不会得到正确的解析。这就是这本书试图寻找的一种在实践中的平衡。
让我们来说明一下如何使用spreadArgProps(..)
方法:
function bar(x,y,z) {
console.log( `x:${x} y:${y} z:${z}` );
}
var f3 = curryProps( spreadArgProps( bar ), 3 );
var f4 = partialProps( spreadArgProps( bar ), { y: 2 } );
f3( {y: 2} )( {x: 1} )( {z: 3} );
// x:1 y:2 z:3
f4( { z: 3, x: 1 } );
// x:1 y:2 z:3
我在这里所展现的“对象参数/命名实参”的模式,通过减少因为参数顺序而引入的杂耍式的技巧,从而达到明显提高代码的可读性的目的。但是据我所知,并没有主流的函数式的库使用这种方法。这样做的代价就是,与大多数的JavaScript函数式编程的做法相比,它更不为人所熟悉。
另外,用这种方式定义的函数,它在使用的时候要求你必须了解每个参数的名称。你不能只记住,“哦,函数将会作为第一个实参”,相反,你必须记住,“这个函数形参的名字是'fn'”。 你必须仔细权衡这些因素。
函数式编程的世界中流行的编码风格是这样的,通过删除不必要的形参-实参映射来一定程度上减少视觉混乱。这种风格正式的名字是叫Tacit programming,或者普通点的叫法是:point-free style^注^。point在这里指代的是函数的形参。
我并未找到正式的中文翻译,只看到阮一峰老师将之译为*无值风格*,本文之后也将沿用这个名称。 [Pointfree 编程风格指南](http://www.ruanyifeng.com/blog/2017/03/pointfree.html)
暂停一下。我想要强调一点,我们在这里的讨论并不是个无限制的建议,我们并不建议你用无值风格来编写你所有的函数式代码。当你适度的使用它的时候,这的确是一种可以提高可读性的技术。但就像软件开发中的大多数事情一样,也存在着滥用它的可能性。如果因为这无值风格的代码让你做了很多无用功,并且你的代码也因此变得更加难以理解,那么请停止这么做吧。用一些聪明但却深奥的办法来删除代码中的另一些亮点,你并不会因此而获得什么荣誉。
让我们从一个简单的例子开始:
function double(x) {
return x * 2;
}
[1,2,3,4,5].map( function mapper(v){
return double( v );
} );
// [2,4,6,8,10]
看到了吗,mapper(..)
和double(..)
有着相同(或者叫兼容的)的签名。在double(..)
调用的时候,形参(point)v
能够直接映射到对应的实参。因此,mapper(..)
函数的封装是完全没有必要的。让我们来试试更简单的无值风格:
function double(x) {
return x * 2;
}
[1,2,3,4,5].map( double );
// [2,4,6,8,10]
我们来复习下之前的另一个例子:
["1","2","3"].map( function mapper(v){
return parseInt( v );
} );
// [1,2,3]
在这个例子中,mapper(..)
的存在实际上是服务于一个非常重要的目的,我们需要舍弃掉从map(..)
传入的index
实参,因为parseInt(..)
将会错误的将该值当作是解析值的基数。下面是unary(..)
帮助处理这种情况的例子:
["1","2","3"].map( unary( parseInt ) );
// [1,2,3]
你需要注意的关键在于,假如你有一个带有形参的函数,而它将会直接传递给内部的函数调用。在上面的两个例子中,mapper(..)
都有形参v
,而且它都被径直传递给了另外的函数调用。我们可以使用unary(..)
的无值表达式来代替这层抽象。
你可能已经像我一样被吸引住了,然后开始尝试`map(partialRight(parseInt,10))`,想要部分应用`10`这个值,作为`radix`。然而,正如我之前所提到的,`partialRight(..)`只能保证`10`将会是传入的最后一个实参,而不是特定的第二个实参。由于`map(..)`将会三个实参`(value, index, arr)`传递给它的映射函数,所以`10`将会成为`parseInt(..)`的第四个参数,而`parseInt(..)`只会注意前两个实参。
这里有另外一个例子:
// convenience to avoid any potential binding issue
// with trying to use `console.log` as a function
function output(txt) {
console.log( txt );
}
function printIf( predicate, msg ) {
if (predicate( msg )) {
output( msg );
}
}
function isShortEnough(str) {
return str.length <= 5;
}
var msg1 = "Hello";
var msg2 = msg1 + " World";
printIf( isShortEnough, msg1 ); // Hello
printIf( isShortEnough, msg2 );
现在我们假设你想打印一个足够长的消息,换句话说,它是!isShortEnough(..)
的。你在一开始可能会这么想:
function isLongEnough(str) {
return !isShortEnough( str );
}
printIf( isLongEnough, msg1 );
printIf( isLongEnough, msg2 ); // Hello World
太简单了……但是你使用了points!看到str
是怎么被传递进去了的吗?在不重新实现对str.length
检查的情况下,我们可以将这个代码重构为无值风格的吗?
我们来定义一个not(..)
否定辅助器(在函数式的库中经常被引用作complement(..)
):
function not(predicate) {
return function negated(...args){
return !predicate( ...args );
};
}
// or the ES6 => arrow form
var not =
predicate =>
(...args) =>
!predicate( ...args );
然后,我们使用not(..)
来替换定义没有points的isLongEnough(..)
:
var isLongEnough = not( isShortEnough );
printIf( isLongEnough, msg2 ); // Hello World
看起来好多了,是吧?但我们还能够更进一步。printIf(..)
函数自身实际上就可以被重构为无值形式的了。
我们可以用when
方法来表达if
条件部分:
function when(predicate,fn) {
return function conditional(...args){
if (predicate( ...args )) {
return fn( ...args );
}
};
}
// or the ES6 => form
var when =
(predicate,fn) =>
(...args) =>
predicate( ...args ) ? fn( ...args ) : undefined;
让我们用几个在前面章节中看到过的其他辅助方法混合when(..)
,来实现无值的printIf(..)
:
var printIf = uncurry( rightPartial( when, output ) );
我来解释一下我们做了什么:我们先把output
函数使用右向部分应用,把它作为了第二个实参(fn
)传递给了when(..)
。这样的话,对我们而言就还剩下预期输入第一个参数(predicate
)的函数,这个函数调用的时候会产生另一个函数,这个新函数的预期输入是消息字符串;它看起来像是这样:fn(predicate)(str)
。
多个(2个)函数的链式调用看起来就像是个可怕的柯里化函数一样,所以我们对这个结果使用uncurry(..)
,把它变成一个单函数,这个单函数的预期输入是str
和predicate
这两个实参,它们和原始printIf(predicate,str)
的签名相匹配。
下面我们将整个示例放在了一起:
function output(msg) {
console.log( msg );
}
function isShortEnough(str) {
return str.length <= 5;
}
var isLongEnough = not( isShortEnough );
var printIf = uncurry( partialRight( when, output ) );
var msg1 = "Hello";
var msg2 = msg1 + " World";
printIf( isShortEnough, msg1 ); // Hello
printIf( isShortEnough, msg2 );
printIf( isLongEnough, msg1 );
printIf( isLongEnough, msg2 ); // Hello World
希望无值风格的函数式编程练习能够变得更有意义。为了能让自己自然而然的思考这个问题,仍然是需要大量的练习。而且你仍然必须对无值风格是否值得做出判断,它在多大程度上有助于你的代码的可读性。
你怎么看?有值或者是无值?
想要更过的无值风格代码的练习?基于函数组合的新知识,我们将会在第四章的*再谈 Points*一节中重新探讨这个技术。
部分应用是一种通过创建新函数(其中,某些实参被预置)来减少函数期望输入的实参数量的技术。
柯里化是一种特殊形式的部分应用,其中计数值被减少到1,具有连续的函数调用链,每个调用都只会接受一个实参。一旦这些函数调用指定了所有实参,所有的输入实参会被收集起来,并执行原始函数。你也可以撤销一个柯里化。
其他的像是unary(..)
,identity(..)
以及constant(..)
这些重要操作,都是函数式编程中基本工具的一部分。
无值Point-free是一种编程风格,可以消除不必要的形参(points)对实参的映射,目的是读者更容易阅读/理解代码。