1、基本概念
函数组合(Function composition)
在计算机科学中,函数组合是将多个简单的函数,组合成一个更复杂的函数的行为或机制。每个函数的执行结果,作为参数传递给下一个函数,最后一个函数的执行结果就是整个函数的结果。
如下图,可以把函数的处理过程想象成一个管道,a表示输入值,b表示输出值,fn表示处理数据的管道。
如果处理过程(fn)变的复杂,就可以把fn拆解成更小的单元(f1,f1,3)。a成为f1的输入值;f1的输出值m,成为f2的输入值;f2的输出值n,成为f3的输入值,最终得到输出值b,也即整个过程的输出值。
结合律(associativity)
在数学中,意指在一个包含有二个以上的可结合运算子的表示式,只要算子的位置没有改变,其运算的顺序就不会对运算出来的值有影响。
加法和乘法都是可结合的:
(x * y) * z === x * (y * z)
(x + y) + z === x + (y + z)
函数组合遵循结合律,意指只要函数的位置没有发生改变,任意组合优先执行,都不影响执行结果。
compose(f,g,h) == compose(compose(f, g), h) == compose(f, compose(g, h))
2、目标用途
- 避免洋葱嵌套代码,提高代码的可读性
开发中,我们经常会用到这样的函数,比如f(g(h()))。举一个简单例子,假设我们要做一个算术运算,求((3 + 5) * 2)的平方。实际需要用到三个函数:
// 加法运算
const add = (a,b) => a+b
const double = (x) => x * 2
const square = (n) => Math.pow(n,2)
square(double(add(3,5))) // 256
这种洋葱式的语法结构,可读性极差。如果,嵌套关系更多,单凭肉眼几乎很难辨别和区分出调用关系。这类场景,使用函数组合,语法将变得简单清晰。
const cmp = compose(square, double, multi)
const result = cmp(3,5) // 256
- 组合多个单一功能函数生成特定功能的新函数
单一性原则,有助于提高函数的适用性和封闭性。但是,面对复杂功能,方便的组合使用单一功能函数却是个问题。函数组合可以把功能单一的函数,重新组合成功能相对更加丰富完备、通用性更好的新函数,方便使用和复用。
举一个简单的例子,我们经常需要对输入内容进行格式处理。比如,金额。需要经历去除收尾空格、长度截取限制、千分位格式化等处理过程。
const trim = (str) => String.prototype.trim.call(str)
const limit = (str) => String.prototype.substr.call(str,0,11)
const numberic = (str) => String(str).replace(/(\d{1,3})(?=(\d{3})+(?:$|\.))/g, "$1,")
const format = compose(numberic,limit,trim)
format(10000) // 10,000
format(123000000) // 123,000,000
format(' 123456789000000 ') // 12,345,678,900
- 把功能复杂的函数拆解成功能相对单一的函数,便于维护和复用
和上面相反,可以把多个单一功能的函数,重新整合成功能复杂的函数;反之,也可以把功能复杂的函数,拆解成多个单一功能的函数,可单独使用,也可以compose重新组合使用;这样做,方便我们维护和重复使用。
3、实现原理
下面一起分析一下compose的实现
首先,compose是一个高阶函数
- 需要一到多个函数作为参数
- 其返回值也是一个函数,用于接受参数
function compose(...funcs){
return function(){
// ...
}
}
其次,函数组合(Function composition),从右向左依次执行,并且每个函数的执行结果,作为参数传递给下一个参数,直至所有函数执行完毕。这种累积计算结果的运算,非常符合Array.prototype.reduce函数的应用场景。唯一的区别是reduce的运算顺序从左向右执行。可以借助reverse方法反转顺序,或者直接使用reduceRight方法。
借助reverse反转顺序
function compose(...funcs){
return function(...args){
const start = funcs.pop()
return funcs.reverse().reduce((result, next)=>next.call(this,result), start.apply(this,args))
}
}
使用reduceRight
function compose(...funcs){
return function(...args){
const start = funcs.shift()
return funcs.reduceRight((result, next)=>next.call(this,result), start.apply(this,args))
}
}
最后,需要完善compose的参数验证。参数长度为0,说明没有待组合的函数,返回一个返回值即是参数本身的空函数;如果只有一个待组合的函数,直接返回;完整的compose函数如下所示。
function compose(...funcs) {
let len = funcs.length
if (len === 0) {
return (...args) => args
}
if (len === 1) {
return (...args) => funcs[0].apply(this, args)
}
return function (...args) {
const start = funcs.pop()
return funcs.reverse().reduce((result, next) => next.call(this,result), start.apply(this,args))
}
}
当然还可以对参数funcs类型加以验证
function compose(...funcs) {
let len = funcs.length
for (const fn of funcs) {
if (typeof fn !== 'function') throw new TypeError('Params must be composed of functions!')
}
if (len === 0) {
return (...args) => args
}
if (len === 1) {
return (...args) => funcs[0].apply(this, args)
}
return function (...args) {
const start = funcs.pop()
return funcs.reverse().reduce((result, next) => next.call(this,result), start.apply(this,args))
}
}
实现方式还有很多,下面给出另外几种实现方式,有兴趣可以研究一下。
基于for循环的实现思路比较简单。循环执行,并把执行结果,作为参数向下传递,直至循环结束。
function compose(...funcs) {
let len = funcs.length
if(len===0){
return (...args) => args
}
if (len === 1) {
return (...args) => funcs[0].apply(this, args)
}
return function(...input) {
let start = funcs.pop()
let result = start.call(this, input)
for (let fn of funcs.reverse()){
result = fn.apply(this, result)
}
return result
}
}
下面是redux中compose的实现思路。没有使用reverse,或者reduceRight,处理执行顺序问题。而是利用匿名函数,从外到里,层层封装缓存执行;执行时,顺序变成了从里到外,巧妙的处理了执行顺序。
function compose(...funcs) {
if (funcs.length === 0) {
return arg => arg
}
if (funcs.length === 1) {
return funcs[0]
}
return funcs.reduce((a, b) => (...args) => a(b(...args)))
}
增强版的compose
开发过程中,我们会经常用到异步编程。Promise、ajax请求、async...await、setTimeout等诸多异步编程方式,给前端开发带来了极大的便利。下面我们借助async...await,在原来到基础上改造增强compose函数,使其支持异步函数。
reduce版本的异步compose
需要注意async...await的用法问题
function asyncCompose(...funcs) {
let len = funcs.length
if (len === 0) {
return (...args) => args
}
if (len === 1) {
return (...args) => funcs[0].apply(this, args)
}
const start = funcs.pop()
return async function (...args) {
return await funcs.reverse().reduce(async (result, next) => next.call(this, await result), await start.apply(this, args))
}
}
for...of版本的异步compose
function asyncCompose(...funcs) {
let len = funcs.length
if (len === 0) {
return (...args) => args
}
if (len === 1) {
return funcs[0]
}
return async function (...input) {
let start = funcs.pop()
let result = await start(...input)
for (let fn of funcs.reverse()) {
result = await fn(result)
}
return result
}
}
实例验证:
let str = ' 123456789000000 '
const sleep = (fn, time) => new Promise((resolve) => {
setTimeout(() => resolve(fn()), time)
})
const trim = (str) => String.prototype.trim.call(str)
const limit = (str) => String.prototype.substr.call(str, 0, 11)
const numberic = (str) => String(str).replace(/(\d{1,3})(?=(\d{3})+(?:$|\.))/g, "$1,")
const asyncTrim = (str) => sleep(() => trim(str), 1000)
const asyncLimit = (str) => sleep(() => limit(str), 1000)
console.log(compose(numberic, limit, trim)(str));
(async () => console.log(await asyncCompose(numberic, asyncLimit, asyncTrim)(str)))()
管道(Pipeline)
函数式编程中,还有一个跟函数组合(Function Composition)非常类似的概念,叫管道(Pipeline)。我们可以简单把它理解为从左向右的compose。其实现原来和应用跟compose也基本相似。
举一个简单的例子,lodash库中pipeline对应flow方法,compoe对应flowRight。一起看一下它的源代码。
function flow(...funcs) {
const length = funcs.length
let index = length
while (index--) {
if (typeof funcs[index] !== 'function') {
throw new TypeError('Expected a function')
}
}
return function(...args) {
let index = 0
let result = length ? funcs[index].apply(this, args) : args[0]
while (++index < length) {
result = funcs[index].call(this, result)
}
return result
}
}
function flowRight(...funcs) {
return flow(...funcs.reverse())
}
4、关于Pointfree的一点讨论
关于Pointfree的概念,讨论有很多,甚至中文翻译都没有准确的定论。
简单说:
Pointfree就是把数据处理的过程,定义成一种与参数无关的合成运算。不需要用到代表数据的那个参数,只要把一些简单的运算步骤合成在一起即可。
如何理解这句话?我个人觉得从函数式编程的本质去理解,更加清晰。函数式编程,简单笼统的说就是处理函数的过程。把已有的函数处理过程,处理成更适用,更容易维护,更自动化的函数。柯理化(Currying)和偏函数(Partial Application)是针对参数的处理;函数组合(Composition)和管道(Pipeline)是针对函数的组合和拆分的处理;这两种针对函数的编程都有一个共同的特点:只关注函数的处理并生成新的函数,并未显式的声明参数。
举个简单的例子,这里通过compose组合numberic,limit,trim生成format函数。有关参数的信息是隐式的包含在函数的处理过程当中。format接受参数,会传导给底层函数去处理,并得到最终的返回值。
const format = compose(numberic,limit,trim)