JavaScript函数式编程第二弹—函数组合(合成)

697 阅读8分钟

阅读本文之前可以先了解上一篇文章,JavaScript函数式编程第一弹—高阶函数、纯函数、柯里化

函数组合

函数式编程有两个最基本的运算:合成和柯里化。

背景知识

  • 纯函数和柯里化很容易写出洋葱代码 h(g(f(x))) 洋葱代码

比如:获取数组的最后一个元素再转换成大写字母

// 使用lodash提供的API 先翻转数组 --> 再取第一个元素 --> 再转换成大写字母
_.toUpper(_.first(_.reverse(array)))

函数组合可以让我们把细粒度的函数重新组合生成一个新的函数,避免写出洋葱代码

管道

下面这个图表示了函数处理数据的过程,给fn输入参数a,得到结果b。可以理解成a数据通过管道fn得到b数据。

下面这个图更是把管道fn拆分成了三个管道f1,f2,f3,数据a通过管道f3得到m,m通过管道f2得到n,n通过管道f1得到数据b。其实m和n是什么我们不用关心

类似于下面的函数

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

函数组合

  • 函数组合 (compose):如果一个函数要经过多个函数处理才能得到最终值,这个时候可以把所有中间步骤合并成一个函数
  • 函数组合默认是从右到左执行
  • 参与合成的函数也必须是纯的
  • f(x)和g(x)合成为f(g(x)),有一个隐藏的前提,就是f和g都只能接受一个参数。
// 函数组合演示
function compose(f, g) {
  return function (value) {
    return f(g(value))
  }
}

// 数组翻转函数
function reverse (array) {
  return array.reverse()
}

// 获取第一个元素函数
function first (array) {
  return array[0]
}

// 组合函数,获取函数最后一个元素
const last = compose(first, reverse)

console.log(last([1, 2, 3, 4])) // 4

Lodash中的组合函数 —— flow()/flowRight()

lodash 中组合函数 flow() 或者 flowRight(),都可以组合多个函数。

  • flow() 是从左到右运行
  • flowRight() 是从右到左运行

下面实例是获取数组的最后一个元素并转化成大写字母

const _ = require('lodash')

const reverse = arr => arr.reverse()
const first = arr => arr[0]
const toUpper = str => str.toUpperCase()

const f = _.flowRight(toUpper, first, reverse)

console.log(f(['AAA', 'BBB', 'CCC'])) // CCC

函数组合原理模拟

上面的例子我们来分析一下:

入参不固定,参数都是函数,出参是一个函数,这个函数要有一个初始的参数值

function compose (...args) {
  // 返回的函数,有一个传入的初始参数即value
  return function (value) { // 因为前面提到:组合有一个隐藏的前提,函数都只能接受一个参数,所以不需要...arg来接收参数,只一个value就可以了
    // reduce的第一个参数是一个回调函数,第二个参数是acc的初始值,这里acc的初始值就是value
    return args.reverse().reduce(function (acc, fn) {
      return fn(acc)
    }, value)
  }
}

const fc = compose(toUpper, first, reverse)
console.log(fc(['AAA', 'BBB', 'CCC'])) // CCC

函数组合-结合律

什么是函数组合结合律?

下面三个情况结果一样,我们既可以把 g 和 h 组合,还可以把 f 和 g 组合。

// 结合律(associativity)
compose(f, compose(g, h))
// 等同于
compose(compose(f, g), h)
// 等同于
compose(f, g, h)

函数组合-调试

如果运行的结果和预期不一致,要怎么调试呢?怎么能知道中间运行的结果呢?

下面输入ROYAL NEVER GIVE UP要对应输出royal-never-give-up(RNG加油!!!) 思路:先用空格将字符串分割为数组,将数组每个元素转为全小写,在将数组元素用'-'连接

const _ = require('lodash')

// _.split(string, separator),因为_.split方法第一个参数为需要分割的字符串,第二个参数为分割符
// 且我们最后调用组合函数的时候要传入字符串,所以字符串要在第二个参数位置传入,因此需要二次封装一个split函数

// 通过柯里化将多个参数转成一个参数
const split = _.curry((sep, str) => _.split(str, sep))

// 大写变小写,用到toLower(),因为这个函数本身只有一个参数,所以可以在函数组合中直接使用

// _.join(array, [separator=',']) 
// join方法也需要两个参数,第一个参数是数组,第二个参数是分隔符,数组也是最后的时候才传递,也需要进行柯里化转换
const join = _.curry((sep, array) => _.join(array, sep))

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

console.log(f('ROYAL NEVER GIVE UP')) // r-o-y-a-l-,-n-e-v-e-r,-g-i-v-e-,-u-p

但是最后的结果却不是我们想要的,那应该怎么调试呢?

const _ = require('lodash')
 
const split = _.curry((sep, str) => _.split(str, sep))
const join = _.curry((sep, array) => _.join(array, sep))

// 我们需要对中间值进行打印,并且知道其位置,用柯里化输出一下
const log = _.curry((tag, v) => {
  console.log(tag, v)
  return v
})

// 从右往左在每个函数后面加一个log,并且传入tag的值,就可以知道每次结果输出的是什么
const f = _.flowRight(join('-'), log('after toLower:'), _.toLower, log('after split:'), split(' '))
// 从右到左
// 第一个log:after split: [ 'ROYAL', 'NEVER', 'GIVE', 'UP' ] 正确
// 第二个log: after toLower: royal,never,give,up  转化成小写字母的同时转成了字符串
console.log(f('ROYAL NEVER GIVE UP')) // r-o-y-a-l-,-n-e-v-e-r,-g-i-v-e-,-u-p


// 修改方式,利用数组的map方法,遍历数组的每个元素让其变成小写 
// 这里的map需要两个参数,第一个是数组,第二个是回调函数,需要柯里化
const map = _.curry((fn, array) => _.map(array, fn))

const f1 = _.flowRight(join('-'), map(_.toLower), split(' '))
console.log(f1('ROYAL NEVER GIVE UP')) // royal-never-give-up

FP模块

函数组合的时候用到很多的函数需要柯里化处理,我们每次都处理那些函数有些麻烦,所以lodash中有一个FP模块

  • lodash 的 fp 模块提供了实用的对函数式编程友好的方法
  • 提供了不可变 auto-curried iteratee-first data-last (函数置先,数据置后)的方法
// lodash 模块 
const _ = require('lodash')
// 数据置先,函数置后
_.map(['a', 'b', 'c'], _.toUpper) 
// => ['A', 'B', 'C'] 

// 数据置先,分割符置后
_.split('Hello World', ' ') 

// lodash/fp 模块 
const fp = require('lodash/fp') 

// 函数置先,数据置后
fp.map(fp.toUpper, ['a', 'b', 'c'])
fp.map(fp.toUpper)(['a', 'b', 'c']) 
// 分割符置先,数据置后
fp.split(' ', 'Hello World') 
fp.split(' ')('Hello World')

体验FP模块对于组合函数的友好

const fp = require('lodash/fp')

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

console.log(f('ROYAL NEVER GIVE UP')) // royal-never-give-up

Lodash-map方法的小问题

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

console.log(_.map(['55', '5', '11'], parseInt)) 
// [ 55, NaN, 3 ]

// _.map的回调函数接收三个参数,第一个参数是遍历的数组,第二个参数是key/index,第三个参数是对应函数
_.map(['55', '5', '10'], function(...args){
  console.log(...args)
})
// 55 0 [ '55', '5', '11' ]
// 5 1 [ '55', '5', '11' ]
// 11 2 [ '55', '5', '11' ]

// parseInt第二个参数表示进制,0默认就是10进制,1不存在2~36范围中,2表示2进制
// parseInt('55', 0, array) 十进制
// parseInt('5', 1, array)
// parseInt('11', 2, array) 二进制

// 要解决的话需要重新封装一个parseInt方法(默认基数为十进制)

// 而使用fp模块的map方法不存在下面的问题
console.log(fp.map(parseInt, ['55', '5', '11'])) 
// [ 55, 5, 11 ]

附上parseInt的使用,忘记的同学快速复习一下

Point Free

是一种编程风格,具体的实现是函数的组合。

fn = R.pipe(f1, f2, f3); 这个公式说明,如果先定义f1、f2、f3,就可以算出fn。整个过程,根本不需要知道a(输入的数据)或b(最终得到的数据)。

Point Free: 我们完全可以把数据处理的过程,定义成一种与参数无关的合成运算。不需要用到代表数据的那个参数,只要把一些简单的运算步骤合成在一起即可。

  • 不需要指明处理的数据
  • 只需要合成运算过程
  • 需要定义一些辅助的基本运算函数
// 比如完成转换:Hello World => hello_world

// 思路:
// 先将字母换成小写,然后将空格换成下划线。如果空格多于一个,要替换成一个
const fp = require('lodash/fp')

// replace方法接收三个参数
// 第一个是正则匹配pattern,第二个是匹配后替换的数据,第三个是要传的字符串
// 所以这里需要传两个参数
const f = fp.flowRight(fp.replace(/\s+/g, '_'), fp.toLower)

console.log(f('Hello World')) //hello_world

Pointfree案例

// world wild web -->W. W. W
// 思路:
// 把一个字符串中的单词首字母提取并转换成大写,使用. 作为分隔符
const fp = require('lodash/fp')

const firstLetterToUpper = fp.flowRight(fp.join('. '), fp.map(fp.first), fp.map(fp.toUpper), fp.split(' '))
console.log(firstLetterToUpper('world wild web')) // W. W. W

// 上面的代码进行了两次的遍历,性能较低
// 优化
const firstLetterToUpper = fp.flowRight(fp.join('. '), fp.map(fp.flowRight(fp.first, fp.toUpper)), fp.split(' '))
console.log(firstLetterToUpper('world wild web')) // W. W. W

Pointfree本质

Pointfree 的本质就是使用一些通用的函数,组合出各种复杂运算。上层运算不要直接操作数据,而是通过底层函数去处理。这就要求,将一些常用的操作封装成函数。

比如,读取对象的role属性,不要直接写成obj.role,而是要把这个操作封装成函数。

var prop = (p, obj) => obj[p];
var propRole = R.curry(prop)('role');

附录