十分钟理解函数组合|函数式编程

2,027 阅读10分钟

纯函数和柯里化的概念请观看我前面两篇文章,如果不了解这两个概念,本文的内容很可能看不懂。

我们在使用纯函数和柯里化时很容易写出洋葱代码,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

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

以上就是函数组合。

PointFree

PointFree是一种编程风格,他的具体实现是函数的组合,他更抽象一些。

PointFree的概念是,我们可以把数据处理的过程定义成与数据无关的合成作用,不需要用到代表数据的那个参数,只要把简单的运算步骤合成到一起,在使用这种模式之前我们需要定义一些辅助的基本运算函数。

整个这句话比较绕口,我们可以把这句话提炼成三点。

1.第一点: 不需要指明处理的数据

2.第二点: 只需要合成运算的过程

3.第三点: 在合成运算的时候需要一些辅助的基本运算函数。

使用函数组合在处理问题的时候,其实就是一种PointFree模式,比如下面的这个案例,在这个案例中我们先把一些基本的运算合成为一个函数,而在这个过程中是没有指明要处理的数据的,这就是PointFree模式。

const _ = require('lodash');
const fp = require('lodash/fp');

const f = fp.flowRight(fp.join('-'), fp.map(_.toLower), fp.split(' '))

接下来我们通过案例来演示一下,非PointFree模式和PointFree模式。

我们先来看一下非PointFree模式,假设我们要把Hello World转换为hello_world这样的形式。

按照我们传统的思维方式,我们会先定义一个函数,来接收一个我们要处理的数据,接着我们在这个函数里面对我们的数据进行处理,得到我们想要的结果,这是非PointFree模式。


function f (word) {
    return word.toLowerCase().replace(/\s+/, '_');
}
f('Hello World')

而我们如果使用PointFree模式来解决这个问题的话,我们首先会定义一些基本的运算函数,然后把他们何成为一个新的函数,而在合成的过程中我们不需要指明我们需要处理的数据。

那我们来回顾一下函数式编程的核心,其实就是把运算过程抽象成函数。PointFree模式就是把我们抽象出来的函数再合成为一个新的函数,而这个合成的过程其实又是一个抽象的过程。在这个抽象的过程中我们依然是不需要关心数据的。

下面我们使用PointFree模式来实现一下上面的案例。

我们首先来分析一下,我们可以把这个字符串先转换成小写,然后再把空格替换成下划线,那如果中间的空格比较多,我们应该使用正则来匹配。所以在这个过程中我们要用到两个方法,一个是转换成小写的方法,一个是字符串替换的方法。

const fp = require('lodash/fp');
fp.toLower; // 转换为小写的方法
fp.replace; // 字符串替换的方法

因为我们要使用PointFree的方式来处理,所以我们可以把这两个过程合并成一个新的函数,而在这个过程中我们是不需要指明我们所需要的数据的。

我们先来导入lodash的fp模块,接着我们就要去合成函数,我们先定义一个f等于fp中的flowRight组合函数。

那在函数组合的时候,首先我们要处理的是转换小写的运算,我们传入fp.toLower

const fp = require('lodash/fp');
const f = fp.flowRight(fp.toLower);

然后我们再使用fp.replace替换,因为flowRight是从右向左执行的,所以我们要写在fp.toLower前面。

我们知道fp.replace这个方法是有三个参数的, 第一个参数是匹配的模式,也就是被替换的值,可以是正则表达式,第二个参数是替换成的内容,第三个参数是要处理的字符串。

fp中提供的方法都是已经被柯里化的,所以我们可以只传部分参数他会返回一个新的函数。

那这里我们调用fp.replace的时候就传入两个参数,第一个是匹配空白的正则表达式/\s+/g, 第二个参数是下划线_, 当我们只传两个参数的时候他会返回一个新的函数,新函数会接收要处理的数据,所以这里函数组合就写完了。

const fp = require('lodash/fp');
const f = fp.flowRight(fp.replace(/\s+/g, '_'), fp.toLower);

f('Hello World'); // hello_world

我们可以发现,当我们在函数组合的过程中,我们是不需要去指明我们要处理的数据的。