翻译连载 | JavaScript 轻量级函数式编程-第3章:管理函数的输入 |《你不知道的JS》姊妹篇

关于译者:这是一个流淌着沪江血液的纯粹工程:认真,是 HTML 最坚实的梁柱;分享,是 CSS 里最闪耀的一瞥;总结,是 JavaScript 中最严谨的逻辑。通过捶打磨练,成就了本书的中文版。本书包含了函数式编程之精髓,但愿能够帮助你们在学习函数式编程的道路上走的更顺畅。比心。前端

译者团队(排名不分前后):阿希bluekenbrucechamcfanlifedailkyoko-dfl3velilinsLittlePineappleMatildaJin冬青pobusamaCherry萝卜vavd317vivaxy萌萌zhouyaogit

第 3 章:管理函数的输入(Inputs)

在第 2 章的 “函数输入” 小节中,咱们聊到了函数形参(parameters)和实参(arguments)的基本知识,实际上还了解到一些能简化其使用方式的语法技巧,好比 ... 操做符和解构(destructuring)。github

在那个讨论中,我建议尽量设计单一形参的函数。但实际上你不能每次都作到,并且也不能每次都掌控你的函数签名(译者注:JS 中,函数签名通常包含函数名和形参等函数关键信息,例如 foo(a, b = 1, c))。ajax

如今,咱们把注意力放在更复杂、强大的模式上,以便讨论处在这些场景下的函数输入。正则表达式

当即传参和稍后传参

若是一个函数接收多个实参,你可能会想先指定部分实参,余下的稍后再指定。编程

来看这个函数:api

function ajax(url,data,callback) {
    // ..
}复制代码

想象一个场景,你要发起多个已知 URL 的 API 请求,但这些请求的数据和处理响应信息的回调函数要稍后才能知道。数组

固然,你能够等到这些东西都肯定后再发起 ajax(..) 请求,而且到那时再引用全局 URL 常量。但咱们还有另外一种选择,就是建立一个已经预设 url 实参的函数引用。bash

咱们将建立一个新函数,其内部仍然发起 ajax(..) 请求,此外在等待接收另外两个实参的同时,咱们手动将 ajax(..) 第一个实参设置成你关心的 API 地址。闭包

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) 函数的偏函数(partially-applied functions)。该术语表明的概念是:在函数调用现场(function call-site),将实参应用(apply) 于形参。如你所见,咱们一开始仅应用了部分实参 —— 具体是将实参应用到 url 形参 —— 剩下的实参稍后再应用。

关于该模式更正式的说法是:偏函数严格来说是一个减小函数参数个数(arity)的过程;这里的参数个数指的是但愿传入的形参的数量。咱们经过 getOrder(..) 把原函数 ajax(..) 的参数个数从 3 个减小到了 2 个。

让咱们定义一个 partial(..) 实用函数:

function partial(fn,...presetArgs) {
    return function partiallyApplied(...laterArgs){
        return fn( ...presetArgs, ...laterArgs );
    };
}复制代码

建议: 只是蜻蜓点水是不行的。请花些时间研究一下该实用函数中发生的事情。请确保你真的理解了。因为在接下来的文章里,咱们将会一次又一次地提到该模式,因此你最好如今就适应它。

partial(..) 函数接收 fn 参数,来表示被咱们偏应用实参(partially apply)的函数。接着,fn 形参以后,presetArgs 数组收集了后面传入的实参,保存起来稍后使用。

咱们建立并 return 了一个新的内部函数(为了清晰明了,咱们把它命名为partiallyApplied(..)),该函数中,laterArgs 数组收集了所有实参。

你注意到在内部函数中的 fnpresetArgs 引用了吗?他们是怎么如何工做的?在函数 partial(..) 结束运行后,内部函数为什么还能访问 fnpresetArgs 引用?你答对了,就是由于闭包!内部函数 partiallyApplied(..) 封闭(closes over)了 fnpresetArgs 变量,因此不管该函数在哪里运行,在 partial(..) 函数运行后咱们仍然能够访问这些变量。因此理解闭包是多么的重要!

partiallyApplied(..) 函数稍后在某处执行时,该函数使用被闭包做用(closed over)的 fn 引用来执行原函数,首先传入(被闭包做用的)presetArgs 数组中全部的偏应用(partial application)实参,而后再进一步传入 laterArgs 数组中的实参。

若是你对以上感到任何疑惑,请停下来再看一遍。相信我,随着咱们进一步深刻本文,你会欣然接受这个建议。

提一句,对于这类代码,函数式编程者每每喜欢使用更简短的 => 箭头函数语法(请看第 2 章的 “语法” 小节),像这样:

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(..) 函数又如何呢?

// 版本 1
var getCurrentUser = partial(
    ajax,
    "http://some.api/person",
    { user: CURRENT_USER_ID }
);

// 版本 2
var getCurrentUser = partial( getPerson, { user: CURRENT_USER_ID } );复制代码

咱们能够(版本 1)直接经过指定 urldata 两个实参来定义 getCurrentUser(..) 函数,也能够(版本 2)将 getCurrentUser(..) 函数定义成 getPerson(..) 的偏应用,该偏应用仅指定一个附加的 data 实参。

由于版本 2 重用了已经定义好的函数,因此它在表达上更清晰一些。所以我认为它更加贴合函数式编程精神。

版本 1 和 2 分别至关于下面的代码,咱们仅用这些代码来确认一下对两个函数版本内部运行机制的理解。

// 版本 1
var getCurrentUser = function partiallyApplied(...laterArgs) {
    return ajax(
        "http://some.api/person",
        { user: CURRENT_USER_ID },
        ...laterArgs
    );
};

// 版本 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(..) ,别担忧,咱们会在本书后面的部分详细介绍它。目前你只须要知道它用来循环遍历(loop over)一个数组,在遍历过程当中调用函数产出新值并存到新的数组中。

由于 add(..) 函数签名不是 map(..) 函数所预期的,因此咱们不直接把它传入 map(..) 函数里。这样一来,偏应用就有了用武之地:咱们能够调整 add(..) 函数签名,以符合 map(..) 函数的预期。

[1,2,3,4,5].map( partial( add, 3 ) );
// [4,5,6,7,8]复制代码

bind(..)

JavaScript 有一个内建的 bind(..) 实用函数,任何函数均可以使用它。该函数有两个功能:预设 this 关键字的上下文,以及偏应用实参。

我认为将这两个功能混合进一个实用函数是极其糟糕的决定。有时你不想关心 this 的绑定,而只是要偏应用实参。我本人基本上从不会同时须要这两个功能。

对于下面的方案,你一般要传 null 给用来绑定 this 的实参(第一个实参),而它是一个能够忽略的占位符。所以,这个方案很是糟糕。

请看:

var getPerson = ajax.bind( null, "http://some.api/person" );复制代码

那个 null 只会给我带来无尽的烦恼。

将实参顺序颠倒

回想咱们以前调用 Ajax 函数的方式:ajax( url, data, cb )。若是要偏应用 cb 而稍后再指定 dataurl 参数,咱们应该怎么作呢?咱们能够建立一个能够颠倒实参顺序的实用函数,用来包装原函数。

function reverseArgs(fn) {
    return function argsReversed(...args){
        return fn( ...args.reverse() );
    };
}

// ES6 箭头函数形式
var reverseArgs =
    fn =>
        (...args) =>
            fn( ...args.reverse() );复制代码

如今能够颠倒 ajax(..) 实参的顺序了,接下来,咱们再也不从左边开始,而是从右侧开始偏应用实参。为了恢复指望的实参顺序,接着咱们又将偏应用实参后的函数颠倒一下实参顺序:

var cache = {};

var cacheResult = reverseArgs(
    partial( reverseArgs( ajax ), function onResult(obj){
        cache[obj.id] = obj;
    } )
);

// 处理后:
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;
});

// 处理后:
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"]复制代码

只有在传两个实参(匹配到 xy 形参)调用 f(..) 函数时,"z:last" 这个值才能被赋给函数的形参 z。在其余的例子里,无论左边有多少个实参,"z:last" 都被传给最右的实参。

一次传一个

咱们来看一个跟偏应用相似的技术,该技术将一个指望接收多个实参的函数拆解成连续的链式函数(chained functions),每一个链式函数接收单一实参(实参个数: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){ /* .. */ } );复制代码

curriedAjax(..) 函数在每次调用中,一次只接收一个实参,而不是一次性接收全部实参(像 ajax(..) 那样),也不是先传部分实参再传剩余部分实参(借助 partial(..) 函数)。

柯里化和偏应用类似,每一个相似偏应用的连续柯里化调用都把另外一个实参应用到原函数,一直到全部实参传递完毕。

不一样之处在于,curriedAjax(..) 函数会明确地返回一个指望只接收下一个实参 data 的函数(咱们把它叫作 curriedGetPerson(..)),而不是那个能接收全部剩余实参的函数(像此前的 getPerson(..) 函数) 。

若是一个原函数指望接收 5 个实参,这个函数的柯里化形式只会接收第一个实参,而且返回一个用来接收第二个参数的函数。而这个被返回的函数又只接收第二个参数,而且返回一个接收第三个参数的函数。依此类推。

由此而知,柯里化将一个多参数(higher-arity)函数拆解为一系列的单元链式函数。

如何定义一个用来柯里化的实用函数呢?咱们将要用到第 2 章中的一些技巧。

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 的初始实参集合,而且将每次接收到的 nextArgprevArgs 链接成 args 数组。当 args.length 小于 arity(原函数 fn(..) 被定义和指望的形参数量)时,返回另外一个 curried(..) 函数(译者注:这里指代 nextCurried(..) 返回的函数)用来接收下一个 nextArg 实参,与此同时将 args 实参集合做为惟一的 prevArgs 参数传入 nextCurried(..) 函数。一旦咱们收集了足够长度的 args 数组,就用这些实参触发原函数 fn(..)

默认地,咱们的实现方案基于下面的条件:在拿到原函数指望的所有实参以前,咱们可以经过检查将要被柯里化的函数的 length 属性来得知柯里化须要迭代多少次。

假如你将该版本的 curry(..) 函数用在一个 length 属性不明确的函数上 —— 函数的形参声明包含默认形参值、形参解构,或者它是可变参数函数,用 ...args 当形参;参考第 2 章 —— 你将要传入 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)curry(add)(3) 二者有什么不一样呢?为何你会选 curry(..) 而不是偏函数呢?当你先得知 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

// 好,咱们看看用柯里化怎么作:
// (5 用来指定须要链式调用的次数)
var curriedSum = curry( sum, 5 );

curriedSum( 1 )( 2 )( 3 )( 4 )( 5 );        // 15复制代码

这里柯里化的好处是,每次函数调用传入一个实参,并生成另外一个特定性更强的函数,以后咱们能够在程序中获取并使用那个新函数。而偏应用则是预先指定全部将被偏应用的实参,产出一个等待接收剩下全部实参的函数。

若是想用偏应用来每次指定一个形参,你得在每一个函数中逐次调用 partialApply(..) 函数。而被柯里化的函数能够自动完成这个工做,这让一次单独传递一个参数变得更加符合人机工程学。

在 JavaScript 中,柯里化和偏应用都使用闭包来保存实参,直到收齐全部实参后咱们再执行原函数。

柯里化和偏应用有什么用?

不管是柯里化风格(sum(1)(2)(3))仍是偏应用风格(partial(sum,1,2)(3)),它们的签名比普通函数签名奇怪得多。那么,在适应函数式编程的时候,咱们为何要这么作呢?答案有几个方面。

首先是显而易见的理由,使用柯里化和偏应用能够将指定分离实参的时机和地方独立开来(遍布代码的每一处),而传统函数调用则须要预先肯定全部实参。若是你在代码某一处只获取了部分实参,而后在另外一处肯定另外一部分实参,这个时候柯里化和偏应用就能派上用场。

另外一个最能体现柯里化应用的的是,当函数只有一个形参时,咱们可以比较容易地组合它们。所以,若是一个函数最终须要三个实参,那么它被柯里化之后会变成须要三次调用,每次调用须要一个实参的函数。当咱们组合函数时,这种单元函数的形式会让咱们处理起来更简单。咱们将在后面继续探讨这个话题。

如何柯里化多个实参?

到目前为止,我相信我给出的是咱们能在 JavaScript 中能获得的,最精髓的柯里化定义和实现方式。

具体来讲,若是简单看下柯里化在 Haskell 语言中的应用,咱们会发现一个函数老是在一次柯里化调用中接收多个实参 —— 而不是接收一个包含多个值的元组(tuple,相似咱们的数组)实参。

在 Haskell 中的示例:

foo 1 2 3复制代码

该示例调用了 foo 函数,而且根据传入的三个值 123 获得告终果。可是在 Haskell 中,函数会自动被柯里化,这意味着咱们传入函数的值都分别传入了单独的柯里化调用。在 JS 中看起来则会是这样:foo(1)(2)(3)。这和我此前讲过的 curry(..) 风格一模一样。

注意: 在 Haskell 中,foo (1,2,3) 不是把三个值看成单独的实参一次性传入函数,而是把它们包含在一个元组(相似 JS 数组)中做为单独实参传入函数。为了正常运行,咱们须要改变 foo 函数来处理做为实参的元组。据我所知,在 Haskell 中咱们没有办法在一次函数调用中将所有三个实参独立地传入,而须要柯里化调用每一个函数。诚然,屡次调用对于 Haskell 开发者来讲是透明的,但对 JS 开发者来讲,这在语法上更加一目了然。

基于以上缘由,我认为此前展现的 curry(..) 函数是一个对 Haskell 柯里化的可靠改编,我把它叫作 “严格柯里化”。

然而,咱们须要注意,大多数流行的 JavaScript 函数式编程库都使用了一种并不严格的柯里化(loose currying)定义。

具体来讲,每每 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 箭头函数版本,咱们就留做一个小练习,有兴趣的读者能够模仿以前 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;
    };
}

// ES6 箭头函数形式
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() 函数最为常见的做用对象极可能并非人为生成的柯里化函数(例如上文所示),而是某些操做所产生的已经被柯里化了的结果函数。咱们将在本章后面关于 “无形参风格” 的讨论中阐述这种应用场景。

只要一个实参

设想你向一个实用函数传入一个函数,而这个实用函数会把多个实参传入函数,但可能你只但愿你的函数接收单一实参。若是你有个相似咱们前面提到被松散柯里化的函数,它能接收多个实参,但你却想让它接收单一实参。那么这就是我想说的状况。

咱们能够设计一个简单的实用函数,它包装一个函数调用,确保被包装的函数只接收一个实参。既然实际上咱们是强制把一个函数处理成单参数函数(unary),那咱们索性就这样命名实用函数:

function unary(fn) {
    return function onlyOneArg(arg){
        return fn( arg );
    };
}

// ES6 箭头函数形式
var unary =
    fn =>
        arg =>
            fn( arg );复制代码

咱们此前已经和 map(..) 函数打过照面了。它调用传入其中的 mapping 函数时会传入三个实参:valueindexlist。若是你但愿你传入 map(..) 的 mapping 函数只接收一个参数,好比 value,你可使用 unary(..) 函数来操做:

function unary(fn) {
    return function onlyOneArg(arg){
        return fn( arg );
    };
}

var adder = looseCurry( sum, 2 );

// 出问题了:
[1,2,3,4,5].map( adder( 3 ) );
// ["41,2,3,4,5", "61,2,3,4,5", "81,2,3,4,5", "101, ...

// 用 `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(..) 会将 index 理解为 radix 参数,这是咱们不但愿发生的。而 unary(..) 函数建立了一个只接收第一个传入实参,忽略其余实参的新函数,这就意味着传入 index 再也不会被误解为 radix 参数。

传一个返回一个

说到只传一个实参的函数,在函数式编程工具库中有另外一种通用的基础函数:该函数接收一个实参,而后什么都不作,原封不动地返回实参值。

function identity(v) {
    return v;
}

// ES6 箭头函数形式
var identity =
    v =>
        v;复制代码

看起来这个实用函数简单到了无处可用的地步。但即便是简单的函数在函数式编程的世界里也能发挥做用。就像演艺圈有句谚语:没有小角色,只有小演员。

举个例子,想象一下你要用正则表达式拆分(split up)一个字符串,但输出的数组中可能包含一些空值。咱们可使用 filter(..) 数组方法(下文会详细说到这个方法)来筛除空值,而咱们将 identity(..) 函数做为 filter(..) 的断言:

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","..."]复制代码

既然 identity(..) 会简单地返回传入的值,而 JS 会将每一个值强制转换为 truefalse,这样咱们就能在最终的数组里对每一个值进行保存或排除。

小贴士: 像这个例子同样,另一个能被用做断言的单实参函数是 JS 自有的 Boolean(..) 方法,该方法会强制把传入值转为 truefalse

另外一个使用 identity(..) 的示例就是将其做为替代一个转换函数(译者注:transformation,这里指的是对传入值进行修改或调整,返回新值的函数)的默认函数:

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(..) 函数的初始值。咱们将会在第 8 章中提到这两个实用函数。

恒定参数

Certain API 禁止直接给方法传值,而要求咱们传入一个函数,就算这个函数只是返回一个值。JS Promise 中的 then(..) 方法就是一个 Certain 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 );

// 对比:

p1.then( foo ).then( constant( p2 ) ).then( bar );复制代码

警告: 尽管使用 () => p2 箭头函数的版本比使用 constant(p2) 的版本更简短,但我建议你忍住别用前者。该箭头函数返回了一个来自外做用域的值,这和 函数式编程的理念有些矛盾。咱们将会在后面第 5 章的 “减小反作用” 小节中提到这种行为带来的陷阱。

扩展在参数中的妙用

在第 2 章中,咱们简要地讲到了形参数组解构。回顾一下该示例:

function foo( [x,y,...args] ) {
    // ..
}

foo( [1,2,3] );复制代码

foo(..) 函数的形参列表中,咱们指望接收单一数组实参,咱们要把这个数组拆解 —— 或者更贴切地说,扩展(spread out)—— 成独立的实参 xy。除了头两个位置之外的参数值咱们都会经过 ... 操做将它们收集在 args 数组中。

当函数必须接收一个数组,而你却想把数组内容当成单独形参来处理的时候,这个技巧十分有用。

然而,有的时候,你没法改变原函数的定义,但想使用形参数组解构。举个例子,请思考下面的函数:

function foo(x,y) {
    console.log( x + y );
}

function bar(fn) {
    fn( [ 3, 9 ] );
}

bar( foo );            // 失败复制代码

你注意到为何 bar(foo) 函数失败了吗?

咱们将 [3,9] 数组做为单一值传入 fn(..) 函数,但 foo(..) 指望接收单独的 xy 形参。若是咱们能够把 foo(..) 的函数声明改变成 function foo([x,y]) { .. 那就好办了。或者,咱们能够改变 bar(..) 函数的行为,把调用改为 fn(...[3,9]),这样就能将 39 分别传入 foo(..) 函数了。

假设有两个在此方法上互不兼容的函数,并且因为各类缘由你没法改变它们的声明和定义。那么你该如何一并使用它们呢?

为了调整一个函数,让它能把接收的单一数组扩展成各自独立的实参,咱们能够定义一个辅助函数:

function spreadArgs(fn) {
    return function spreadFn(argsArr) {
        return fn( ...argsArr );
    };
}

// ES6 箭头函数的形式:
var spreadArgs =
    fn =>
        argsArr =>
            fn( ...argsArr );复制代码

注意: 我把这个辅助函数叫作 spreadArgs(..),但一些库,好比 Ramda,常常把它叫作 apply(..)

如今咱们可使用 spreadArgs(..) 来调整 foo(..) 函数,使其做为一个合适的输入参数并正常地工做:

bar( spreadArgs( foo ) );            // 12复制代码

相信我,虽然我不能讲清楚这些问题出现的缘由,但它们必定会出现的。本质上,spreadArgs(..) 函数使咱们可以定义一个借助数组 return 多个值的函数,不过,它让这些值仍然能分别做为其余函数的输入参数来处理。

一个函数的输出做为另一个函数的输入被称做组合(composition),咱们将在第四章详细讨论这个话题。

尽管咱们在谈论 spreadArgs(..) 实用函数,但咱们也能够定义一下实现相反功能的实用函数:

function gatherArgs(fn) {
    return function gatheredFn(...argsArr) {
        return fn( argsArr );
    };
}

// ES6 箭头函数形式
var gatherArgs =
    fn =>
        (...argsArr) =>
            fn( argsArr );复制代码

注意: 在 Ramda 中,该实用函数被称做 unapply(..),是与 apply(..) 功能相反的函数。我认为术语 “扩展(spread)” 和 “汇集(gather)” 能够把这两个函数发生的事情解释得更好一些。

由于有时咱们可能要调整一个函数,解构其数组形参,使其成为另外一个分别接收单独实参的函数,因此咱们能够经过使用 gatherArgs(..) 实用函数来将单独的实参汇集到一个数组中。咱们将在第 8 章中细说 reduce(..) 函数,这里咱们简要说一下:它重复调用传入的 reducer 函数,其中 reducer 函数有两个形参,如今咱们能够将这两个形参汇集起来:

function combineFirstTwo([ v1, v2 ]) {
    return v1 + v2;
}

[1,2,3,4,5].reduce( gatherArgs( combineFirstTwo ) );
// 15复制代码

参数顺序的那些事儿

对于多形参函数的柯里化和偏应用,咱们不得不经过许多使人懊恼的技巧来修正这些形参的顺序。有时咱们把一个函数的形参顺序定义成柯里化需求的形参顺序,但这种顺序没有兼容性,咱们不得不绞尽脑汁来从新调整它。

让人沮丧的可不只是咱们须要使用实用函数来委曲求全,在此以外,这种作法还会致使咱们的代码被无关代码混淆。这种东西就像碎纸片,这一片那一片的,而不是一整个突出问题,但这些问题的细碎丝绝不会减小它们带来的苦恼。

难道就没有能让咱们从修正参数顺序这件事里解脱出来的方法吗!?

在第 2 章里,咱们讲到了命名实参(named-argument)解构模式。回顾一下:

function foo( {x,y} = {} ) {
    console.log( x, y );
}

foo( {
    y: 3
} );                    // undefined 3复制代码

咱们将 foo(..) 函数的第一个形参 —— 它被指望是一个对象 —— 解构成单独的形参 xy。接着在调用时传入一个对象实参,而且提供函数指望的属性,这样就能够把 “命名实参” 映射到相应形参上。

命名实参主要的好处就是不用再纠结实参传入的顺序,所以提升了可读性。咱们能够发掘一下看看是否能设计一个等效的实用函数来处理对象属性,以此提升柯里化和偏应用的可读性:

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] ) );
    };
}复制代码

注意: 该实用函数的参数解析逻辑并不是无懈可击,使用正则来解析代码这个前提就已经很不靠谱了!但处理通常状况是咱们的惟一目标,从这点来看这个实用函数仍是恰到好处的。咱们须要的只是对简单形参(包括带默认值的形参)函数的形参顺序作一个恰当的默认检测。例如,咱们的实用函数不须要把复杂的解构形参给解析出来,由于不管如何咱们不太可能对拥有这种复杂形参的函数使用 spreadArgProps() 函数。所以该逻辑能搞定 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复制代码

提个醒:本文中呈现的对象形参(object parameters)和命名实参(named arguments)模式,经过减小由调整实参顺序带来的干扰,明显地提升了代码的可读性,不过据我所知,没有哪一个主流的函数式编程库使用该方案。因此你会看到该作法与大多数 JavaScript 函数式编程很不同.

此外,使用在这种风格下定义的函数要求你知道每一个实参的名字。你必须记住:“这个函数形参叫做 ‘fn’ ”,而不是只记得:“噢,把这个函数做为第一个实参传进去”。

请当心地权衡它们。

无形参风格

在函数式编程的世界中,有一种流行的代码风格,其目的是经过移除没必要要的形参-实参映射来减小视觉上的干扰。这种风格的正式名称为 “隐性编程(tacit programming)”,通常则称做 “无形参(point-free)” 风格。术语 “point” 在这里指的是函数形参。

警告: 且慢,先说明咱们此次的讨论是一个有边界的提议,我不建议你在函数式编程的代码里不惜代价地滥用无形参风格。该技术是用于在适当状况下提高可读性。但你彻底可能像滥用软件开发里大多数东西同样滥用它。若是你因为必须迁移到无参数风格而让代码难以理解,请打住。你不会所以得到小红花,由于你用看似聪明但晦涩难懂的方式抹除形参这个点的同时,还抹除了代码的重点。

咱们从一个简单的例子开始:

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(..) 函数有相同(或相互兼容)的函数签名。形参(也就是所谓的 “point“)v 能够直接映射到 double(..) 函数调用里相应的实参上。这样,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(..) 函数会错把 index 看成 radix 来进行整数解析。该例子中咱们能够借助 unary(..) 函数:

["1","2","3"].map( unary( parseInt ) );
// [1,2,3]复制代码

使用无形参风格的关键,是找到你代码中,有哪些地方的函数直接将其形参做为内部函数调用的实参。以上提到的两个例子中,mapper(..) 函数拿到形参 v 单独传入了另外一个函数调用。咱们能够借助 unary(..) 函数将提取形参的逻辑层替换成无参数形式表达式。

警告: 你可能跟我同样,已经尝试着使用 map(partialRight(parseInt,10)) 来将 10 右偏应用为 parseInt(..)radix 实参。然而,就像咱们以前看到的那样,partialRight(..) 仅仅保证将 10 看成最后一个实参传入原函数,而不是将其指定为第二个实参。由于 map(..) 函数自己会将 3 个实参(valueindexarr)传入它的映射函数,因此 10 就会被当成第四个实参传入 parseInt(..) 函数,而这个函数只会对头两个实参做出反应。

来看另外一个例子:

// 将 `console.log` 当成一个函数使用
// 便于避免潜在的绑定问题

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复制代码

这太简单了...但如今咱们的重点来了!你看到了 str 形参是如何传递的吗?咱们可否不经过从新实现 str.length 的检查逻辑,而重构代码并使其变成无形参风格呢?

咱们定义一个 not(..) 取反辅助函数(在函数式编程库中又被称做 complement(..)):

function not(predicate) {
    return function negated(...args){
        return !predicate( ...args );
    };
}

// ES6 箭头函数形式
var not =
    predicate =>
        (...args) =>
            !predicate( ...args );复制代码

接着,咱们使用 not(..) 函数来定义无形参的 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 );
        }
    };
}

// ES6 箭头函数形式
var when =
    (predicate,fn) =>
        (...args) =>
            predicate( ...args ) ? fn( ...args ) : undefined;复制代码

咱们把本章前面讲到的另外一些辅助函数和 when(..) 函数结合起来搞定无形参风格的 printIf(..) 函数:

var printIf = uncurry( rightPartial( when, output ) );复制代码

咱们是这么作的:将 output 方法右偏应用为 when(..) 函数的第二个(fn 形参)实参,这样咱们获得了一个仍然指望接收第一个实参(predicate 形参)的函数。当该函数被调用时,会产生另外一个指望接收(译者注:须要被打印的)信息字符串的函数,看起来就是这样:fn(predicate)(str)

多个(两个)链式函数的调用看起来很挫,就像被柯里化的函数。因而咱们用 uncurry(..) 函数处理它,获得一个指望接收 strpredicate 两个实参的函数,这样该函数的签名就和 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复制代码

希望无形参风格编程的函数式编程实践逐渐变得更有意义。你仍然能够经过大量实践来训练本身,让本身接受这种风格。再次提醒,请三思然后行,掂量一下是否值得使用无形参风格编程,以及使用到什么程度会益于提升代码的可读性。

有形参仍是无形参,你怎么选?

注意: 还有什么无形参风格编程的实践呢?咱们将在第 4 章的 “回顾形参” 小节里,站在新学习的组合函数知识之上来回顾这个技术。

总结

偏应用是用来减小函数的参数数量 —— 一个函数指望接收的实参数量 —— 的技术,它减小参数数量的方式是建立一个预设了部分实参的新函数。

柯里化是偏应用的一种特殊形式,其参数数量下降为 1,这种形式包含一串连续的链式函数调用,每一个调用接收一个实参。当这些链式调用指定了全部实参时,原函数就会拿到收集好的实参并执行。你一样能够将柯里化还原。

其它相似 unary(..)identity(..) 以及 constant(..) 的重要函数操做,是函数式编程基础工具库的一部分。

无形参是一种书写代码的风格,这种风格移除了非必需的形参映射实参逻辑,其目的在于提升代码的可读性和可理解性。

【上一章】翻译连载 |《JavaScript 轻量级函数式编程》- 第 2 章:函数基础
【下一章】翻译连载 |《你不知道的JS》姊妹篇 |《JavaScript 轻量级函数式编程》- 第4章:组合函数

iKcamp原创新书《移动Web前端高效开发实战》已在亚马逊、京东、当当开售。

相关文章
相关标签/搜索
本站公众号
   欢迎关注本站公众号,获取更多信息