函数组合
纯函数和柯里化很容易写出洋葱代码 h(g(f(x)))
如“获取数组的最后一个元素再转换成大写字母” _toUpper(_.first(_.reverse(array)))
函数组合可以让我们把细粒度的函数重新组合生成一个新的函数
管道
我们可以将使用函数处理数据想象成一个管道。
当 fn 函数比较复杂的时候,我们可以把 fn 函数拆分成多个小函数
fn = compose(f1, f2, f3)
b = fn(a)
函数组合
函数组合 (compose):如果一个函数要经过多个函数处理才能得到最终值,这个时候可以把中间过程的函数合并成一个函数
函数组合默认是从右到左执行
// 函数组合演示
function compose (f, g) {
return function (value) {
return f(g(value))
}
}
function first (arr) {
return arr[0]
}
function reverse (arr) {
return arr.reverse()
}
// 从右到左运行
let last = compose(first, reverse)
console.log(last([1, 2, 3, 4]))
洋葱代码并没有减少,只是封装起来了。
Lodash 中的组合函数
我们自己实现的 compose 只能组合两个函数,如果需要组合更多函数,我们可以使用 Lodash 中提供的组合函数 flow() 或 flowRight。
flow() 是从左到右,flowRight() 是从右到左运行,使用的更多一些。
// lodash 中的函数组合的方法 _.flowRight()
const _ = require('lodash')
const reverse = arr => arr.reverse()
const first = arr => arr[0]
const toUpper = s => s.toUpperCase()
const f = _.flowRight(toUpper, first, reverse)
console.log(f(['one', 'two', 'three']))
原理模拟
模拟 lodash 中的 flowRight 方法,对一个数组进行操作。
const reverse = arr => arr.reverse()
const first = arr => arr[0]
const toUpper = s => s.toUpperCase()
const f = compose(toUpper, first, reverse)
console.log(f(['one', 'two', 'three']))
function compose (...args) {
// 再次调用需要传递一个数组,即 value
return function (value) {
// 最终需要返回值
// 先使用 reverse 方法反转数组
// 核心:使用数组 reduce 方法对数组进行“累计运算”
return args.reverse().reduce(function (acc, fn) {
// acc 累计器
// fn 当前项,这里是一个函数
return fn(acc)
}, value)
}
}
使用 ES6 优化代码
const compose = (...args) => value => args.reverse().reduce((acc, fn) => fn(acc), value)
// 由于函数表达式没有函数提升,因此执行语句必须放在后面
const f = compose(toUpper, first, reverse)
console.log(f(['one', 'two', 'three']))
结合律
函数的组合要满足结合律(associativity),这里的结合律即数学意义的结合律。
我们既可以把 g 和 h 组合,还可以把 f 和 g 组合,结果都是一样的。
// 结合律(associativity)
let f = compose(f, g, h)
// compose(compose(f, g), h) == compose(f, compose(g, h))
let associative = compose(compose(f, g), h)
实际例子
// 函数组合要满足结合律
const _ = require('lodash')
// const f = _.flowRight(_.toUpper, _.first, _.reverse)
const f = _.flowRight(_.flowRight(_.toUpper, _.first), _.reverse)
console.log(f(['one', 'two', 'three']))
// 函数组合要满足结合律
const _ = require('lodash')
// const f = _.flowRight(_.toUpper, _.first, _.reverse)
const f = _.flowRight(_.toUpper, _.flowRight(_.first, _.reverse))
console.log(f(['one', 'two', 'three']))
根据这两个不同的组合结果可知,不论是组合 _.toUpper
和 _.first
还是组合 _.first
和 _.reverse
,结果都不会改变,均为 TREE
。
如何调试
当我们使用函数组合,结果与预期不一致时,我们应该如何调试呢?
一个例子,我们将 NEVER SAY DIE
转换为 never-say-die
,我们先准备以下函数:
const _ = require('lodash')
// Lodash split 方法第一个参数为 string,第二个参数为 separator
// 由于 split 方法有多个参数,在函数组合中我们无法直接使用 Lodash split 方法
// 我们需要将多个参数转换为一个参数的函数,即函数柯里化
// 并且当把函数组合完毕后,最终调用函数的时候,才会传入所需要的字符串,因此字符串参数应该在最后的位置
// _.split()
const split = .curry((sep, str) => _.split(str, sep))
// _toLower()
// Lodash join 方法与 split 方法类似,我们也需要重新写一个函数
// _.join()
const join = _.curry((sep, array) => _.join(array, sep))
我们已经对 split 函数进行了柯里化,因此当调用 split 的时候,可以只传部分参数。
const f = _.flowRight( join('-'), _.toLower, split(' '))
console.log(f('NEVER SAY DIE'))
// 结果: n-e-v-e-r-,-s-a-y-,-d-i-e
实际结果与我们的预期结果不符,我们需要调试看是哪部分函数出现了问题。
创建一个 log 函数。
const log = v => {
console.log(v)
return v
}
// ...
const f = _.flowRight(join('-'), _.toLower, log, split(' '))
// ...
在组合函数中的参数之间中插入 log 函数从右到左依次进行排查。
经过排查,我们发现是由于 toLower 函数将 [ 'NEVER', 'SAY', 'DIE' ]
转换成了小写字符串 never,say,die
,而 join 函数需要的是数组。我们可以在 toLower 函数外包裹一层 map 函数。
// ...
// 仍然需要柯里化
const map = _.curry((fn, array) => _.map(array, fn))
// ...
const f = _.flowRight(join('-'), map(_.toLower), split(' '))
优化 log 函数
当函数组合存在多个 log 函数时,难以定位函数所在位置。
我们可以使用 trace 额外传入 tag 帮助我们获取额外信息。
const trace = _.curry((tag, v) => {
console.log(tag, v)
return v
})
// ...
const f = _.flowRight(join('-'), trace('map 之后'), map(_.toLower), trace('split 之后'), split(' '))
Lodash 的 fp 模块
我们在使用 Lodash 中的方法时,由于不符合函数式编程,我们往往需要再进行柯里化改造,其实 Lodash 的 fp 模块直接提供了函数式方法。
lodash/fp 模块特点
- lodash 的 fp 模块提供了实用的对函数式编程友好的方法
- 提供了不可变 auto-curried iteratee-first data-last 的方法
lodash 模块中的方法是数据优先,函数之后。
// lodash 模块
const _ = require('lodash')
_.map(['a', 'b', 'c'], _.toUpper)
// => ['A', 'B', 'C']
_.map(['a', 'b', 'c'])
// => ['a', 'b', 'c']
_.split('Hello World', ' ')
lodash/fp 模块的方法是函数优先,数据之后,且已经柯里化。
// 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')
案例
const fp = require('lodash/fp')
const f = fp.flowRight(fp.join('-'), fp.map(fp.toLower), fp.split('_'))
console.log(f('NEVER SAY DIE'))
Lodash map 方法的小问题
const _ = require('lodash')
console.log(_.map(['23', '8', '10'], parseInt))
// output: [ 23, NaN, 2 ]
为什么这里和预期结果不符?因为 _.map 会传递三个参数 value, index|key, collection,parseInt 会接收这三个参数。
parseInt('23', 0, array)
parseInt('8', 1, array)
parseInt('10', 2, array)
而 parseInt 第二个参数是进制。如果该参数小于 2 或者大于 36,则 parseInt() 将返回 NaN。
我们可以使用 lodash/fp 模块中的 map 方法。
const fp = require('lodash/fp')
console.log(fp.map(parseInt, ['23', '8', '10']))
Pointfree
一种编程风格
特点
- 不需要指明处理的数据
- 只需要合成运算过程
- 需要定义一些辅助的基本运算函数
Pointfree 模式的实现方法实际就是函数组合。
const f = fp.flowRight(fp.join('-'), fp.map(_.toLower), fp.split(' '))
案例一
// 非 Point Free 模式
// Hello World => hello_world
function f (word) {
return word.toLowerCase().replace(/\s+/g, '_');
}
// Point Free
const fp = require('lodash/fp')
const f = fp.flowRight(fp.replace(/\s+/g, '_'), fp.toLower)
console.log(f('Hello World'))
案例二
// 把一个字符串中的首字母提取并转换成大写,使用,作为分隔符
// 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'))