函数组合

我们在使用纯函数和柯里化时很容易写出洋葱代码,h(g(f(x))),也就是一层包一层的代码,比如我们要获取数组的最后一个元素,然后在转换成大写字母。

我们可以先去调用数组对象的reverse方法反转数组,然后调用first方法获取数组第一个元素,再调用toUpper方法将获取的第一个元素转为大写。

const _ from 'lodash';

const array = ['a', 'b', 'c', 'd'];
_.toUpper(_.first(_.reverse(array)));

可以发现这些方法的调用就是一层包一层的,这就是洋葱代码,我们使用函数的组合可以避免这样的代码出现。

使用函数的组合可以把细粒度的函数重新组合生成一个新的函数。也就是将多个函数组合成一个新的函数。

比如上面的例子需要调用reverse,first,toUpper三个函数,我们可以通过组合,将这三个函数合并成一个,调用的时候仍旧传入array数组,处理的结果是不变的。函数组合其实就相当于隐藏掉了多个函数调用的中间结果,比如reverse传递给first,first传递给toUpper。

我们来看下函数组合的概念: 如果一个函数要经过多个函数处理才能得到最终的值,这个时候我们可以把中间这些过程函数合并成一个新的函数。

函数就像是数据的管道,函数组合就是把这些管道连接起来,让数据穿过多个管道形成最终结果。

函数组合默认情况是从右到左执行的,比如下面的代码,我们将f1,f2,f3组合,当调用fn的时候,会先执行f3,再执行f2,最后执行f1,也就是把f3的执行结果交给f2,再把f2的执行结果交给f1。

const fn = compose(f1, f2, f3);
const b = fn(a);

接下来我们来演示一下函数的组合如何去使用,我们想要函数的组合的话,首先我们要有一个可以把多个函数组合成一个函数的函数。我们来定义一下这个函数。

首先这个函数需要接收多个函数类型的参数,我们可以通过剩余参数来写,也就是ES6的reset(…args),这个函数还要返回一个新的函数,并且我们返回的这个函数要能接收一个参数, 这个参数就是输入参数,我们叫做value。

function compose (...args) {
    return function (value) {
    }
}

注意当我们调用这个返回的函数时,会获取到我们最终的结果。所以这个函数内部应该是依次调用我们传递进来的函数,并且是从右向左执行的。

args中就是传递进来的函数,我们要对它进行一个反转,反转之后我们要依次调用里面的函数,并且前一个函数的返回值需要是下一个函数的参数。

这里我们选用数组的reduce方法, 这个方法接收一个函数作为参数,在函数中会接收两个参数,一个是前一次执行的返回值我们叫做acc,第二个数组当前的遍历值我们叫做fn。

function compose (...args) {
    return function (value) {
        return args.reverse().reduce(function (acc, fn) => {

        })
    }
}

这里我们acc接收的是前一次执行的返回值,那第一次执行的时候这个值是不存在的,我们可以在reduce的第二个参数位置设置这个初始值,我们这里设置为传入的value。

function compose (...args) {
    return function (value) {
        return args.reverse().reduce(function (acc, fn) => {

        }, value)
    }
}

如果不了解reduce的用法这里可能会有点绕,我们来简单介绍一下,reduce也是数组方法,他接收一个函数作为参数,类似于forEach的写法,他也会去遍历数组,与forEach不同的是,传入的函数会接收两个参数,第一个参数是前一次循环中的返回值,第二个参数是当前遍历到的数组中的值。

因为我们这里的函数组合正好是前一个函数的执行结果传递给后一个函数,所以选用reduce,当第一个函数执行的时候,我们给函数传入value作为参数,然后将执行结果返回,第二个函数执行的时候,我们可以拿到第一个函数的执行结果acc,然后当做第二个函数的参数传入进去,以此类推。

function compose (...args) {
    return function (value) {
        return args.reverse().reduce(function (acc, fn) => {
            return fn(acc);
        }, value)
    }
}

到这我们的组合函数就写完了,我们对这个代码进行一个改造,因为他看起来太乱了,有三个return搭在一起,我们用剪头函数从新整理一下。

const compose = (...args) => value => args.reverse().reduce((acc, fn) => fn(acc), value)

这样看起来就简洁多了。

函数组合要满足的特点

函数组合要满足结合律,这个结合律就是数学中的结合律。

假设我们要把三个函数组合成一个函数,我们可以先去组合后两个函数,也可以先去组合前两个函数,他的结果都是一样的。这就是结合律。

比如我们在组合f,g,h这三个函数的时候,我们可以先把f和g组合成一个函数,然后再和h去组合,我们也可以把g和h组合成一个函数,然后再和f进行组合。下面这3种方式都是等效的。

let t = compose(f, g, h);
compose(compose(f, g), h) === compose(f, compose(g, h)); // true

我们通过一个案例来演示一下, 我们使用lodash的flowRight组合函数,将toUpper,first和reverse进行组合。功能是获取数组最后一个元素,并且大写。

const _ = require('lodash');
const f = _.flowRight(_.toUpper, _.first, _.reverse);
console.log(f(['a', 'b', 'c'])); // C

当我们把这三个函数组合的时候,我们可以先去组合前两个,然后再去组合第三个函数。通过flowRight

const _ = require('lodash');
const f = _.flowRight(_.flowRight(_.toUpper, _.first), _.reverse);
console.log(f(['a', 'b', 'c'])); // C

我们再去结合后两个函数,同样通过flowRight。

const _ = require('lodash');
const f = _.flowRight(_.toUpper,_.flowRight(_.first, _.reverse));
console.log(f(['a', 'b', 'c'])); // C

可以发现我们无论先结合前两个还是先结合后两个,得到的结果都是相同的,这就是结合律,和数学中的结合律是一样的。

调试

当我们使用函数组合的时候,如果我们执行的结果跟我们预期的不一致,这个时候我们应该如何调试呢?

比如说下面的代码,当我们想知道reverse执行的结果是什么时候。我们可以在reverse函数前面追加一个log函数,把他打印出来看一下。

const _ = require('lodash');

const log = (v) => { // debug函数,该函数不做任何处理,直接返回
    console.log(v); // 打印v
    return;
}
const f = _.flowRight(_.toUpper, _.first, log _.reverse);
console.log(f(['a', 'b', 'c'])); // C

我们在调试的时候可以写一个辅助函数,我们通过这个辅助函数来观察每一个中间函数的执行结果。

以上就是函数组合。