JavaScript函数式编程,真香之组合函数(二)

9,143 阅读17分钟

JavaScript函数式编程,真香之认识函数式编程(一)

该系列文章不是针对前端新手,需要有一定的编程经验,而且了解 JavaScript 里面作用域,闭包等概念

组合函数

组合是一种为软件的行为,进行清晰建模的一种简单、优雅而富于表现力的方式。通过组合小的、确定性的函数,来创建更大的软件组件和功能的过程,会生成更容易组织、理解、调试、扩展、测试和维护的软件。

对于组合,我觉得是函数式编程里面最精髓的地方之一,所以我迫不及待的把这个概念拿出来先介绍,因为在整个学习函数式编程里,所遇到的基本上都是以组合的方式来编写代码,这也是改变你从一个面向对象,或者结构化编程思想的一个关键点。

我这里也不去证明组合比继承好,也不说组合的方式写代码有多好,我希望你看了这篇文章能知道以组合的方式去抽象代码,这会扩展你的视野,在你想重构你的代码,或者想写出更易于维护的代码的时候,提供一种思路。

组合的概念是非常直观的,并不是函数式编程独有的,在我们生活中或者前端开发中处处可见。

比如我们现在流行的 SPA (单页面应用),都会有组件的概念,为什么要有组件的概念呢,因为它的目的就是想让你把一些通用的功能或者元素组合抽象成可重用的组件,就算不通用,你在构建一个复杂页面的时候也可以拆分成一个个具有简单功能的组件,然后再组合成你满足各种需求的页面。

其实我们函数式编程里面的组合也是类似,函数组合就是一种将已被分解的简单任务组织成复杂的整体过程

现在我们有这样一个需求:给你一个字符串,将这个字符串转化成大写,然后逆序。

你可能会这么写。

// 例 1.1

var str = 'function program'

// 一行代码搞定
function oneLine(str) {
    var res = str.toUpperCase().split('').reverse().join('')
    return res;
}

// 或者 按要求一步一步来,先转成大写,然后逆序
function multiLine(str) {
    var upperStr = str.toUpperCase()
    var res = upperStr.split('').reverse().join('')
    return res;
}

console.log(oneLine(str)) // MARGORP NOITCNUF
console.log(multiLine(str)) // MARGORP NOITCNUF

可能看到这里你并没有觉得有什么不对的,但是现在产品又突发奇想,改了下需求,把字符串大写之后,把每个字符拆开之后组装成一个数组,比如 ’aaa‘ 最终会变成 [A, A, A]。

那么这个时候我们就需要更改我们之前我们封装的函数。这就修改了以前封装的代码,其实在设计模式里面就是破坏了开闭原则。

那么我们如果把最开始的需求代码写成这个样子,以函数式编程的方式来写。

// 例 1.2

var str = 'function program'

function stringToUpper(str) {
    return str.toUpperCase()
}

function stringReverse(str) {
    return str.split('').reverse().join('')
}

var toUpperAndReverse = 组合(stringReverse, stringToUpper)
var res = toUpperAndReverse(str)

那么当我们需求变化的时候,我们根本不需要修改之前封装过的东西。

// 例 2

var str = 'function program'

function stringToUpper(str) {
    return str.toUpperCase()
}

function stringReverse(str) {
    return str.split('').reverse().join('')
}

// var toUpperAndReverse = 组合(stringReverse, stringToUpper)
// var res = toUpperAndReverse(str)

function stringToArray(str) {
    return str.split('')
}

var toUpperAndArray = 组合(stringToArray, stringToUpper)
toUpperAndArray(str)

可以看到当变更需求的时候,我们没有打破以前封装的代码,只是新增了函数功能,然后把函数进行重新组合。

这里可能会有人说,需求修改,肯定要更改代码呀,你这不是也删除了以前的代码么,也不是算破坏了开闭原则么。我这里声明一下,开闭原则是指一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。是针对我们封装,抽象出来的代码,而不是调用的逻辑代码。所以这样写并不算破坏开闭原则。

突然产品又灵光一闪,又想改一下需求,把字符串大写之后,再翻转,再转成数组。

要是你按照以前的思考,没有进行抽象,你肯定心理一万只草泥马在奔腾,但是如果你抽象了,你完全可以不慌。

// 例 3

var str = 'function program'

function stringToUpper(str) {
    return str.toUpperCase()
}

function stringReverse(str) {
    return str.split('').reverse().join('')
}

function stringToArray(str) {
    return str.split('')
}

var strUpperAndReverseAndArray = 组合(stringToArray, stringReverse, stringToUpper)
strUpperAndReverseAndArray(str)

发现并没有更换你之前封装的代码,只是更换了函数的组合方式。可以看到,组合的方式是真的就是抽象单一功能的函数,然后再组成复杂功能。这种方式既锻炼了你的抽象能力,也给维护带来巨大的方便。

但是上面的组合我只是用汉字来代替的,我们应该如何去实现这个组合呢。首先我们可以知道,这是一个函数,同时参数也是函数,返回值也是函数。

我们看到例 2, 怎么将两个函数进行组合呢,根据上面说的,参数和返回值都是函数,那么我们可以确定函数的基本结构如下(顺便把组合换成英文的 compose)。

function twoFuntionCompose(fn1, fn2) {
    return function() {
        // code
    }
}

我们再思考一下,如果我们不用 compose 这个函数,在例 2 中怎么将两个函数合成呢,我们是不是也可以这么做来达到组合的目的。

var res = stringReverse(stringToUpper(str))

那么按照这个逻辑是不是我们就可以写出 twoFuntonCompose 的实现了,就是

function twoFuntonCompose(fn1, fn2) {
    return function(arg) {
        return fn1(fn2(arg))
    }
}

同理我们也可以写出三个函数的组合函数,四个函数的组合函数,无非就是一直嵌套多层嘛,变成:

function multiFuntionCompose(fn1, fn2, .., fnn) {
    return function(arg) {
        return fnn(...(fn1(fn2(arg))))
    }
}

这种恶心的方式很显然不是我们程序员应该做的,然后我们也可以看到一些规律,无非就是把前一个函数的返回值作为后一个返回值的参数,当直接到最后一个函数的时候,就返回。

所以按照正常的思维就会这么写。

function aCompose(...args) {
    let length = args.length
    let count = length - 1
    let result
    return function f1 (...arg1) {
        result = args[count].apply(this, arg1)
        if (count <= 0) {
          count = length - 1
          return result
        }
        count--
        return f1.call(null, result)
    }
}

这样写没问题,underscore 也是这么写的,不过里面还有很多健壮性的处理,核心大概就是这样。

但是作为一个函数式爱好者,尽量还是以函数式的方式去思考,所以就用 reduceRight 写出如下代码。

function compose(...args) {
    return (result) => {
        return args.reduceRight((result, fn) => {
          return fn(result)
        }, result)
  }
}

当然对于 compose 的实现还有很多种方式,在这篇实现 compose 的五种思路中还给出了另外脑洞大开的实现方式,在我看这篇文章之前,另外三种我是没想到的,不过感觉也不是太有用,但是可以扩展我们的思路,有兴趣的同学可以看一看。

注意:要传给 compose 函数是有规范的,首先函数的执行是从最后一个参数开始执行,一直执行到第一个,而且对于传给 compose 作为参数的函数也是有要求的,必须只有一个形参,而且函数的返回值是下一个函数的实参。

对于 compose 从最后一个函数开始求值的方式如果你不是很适应的话,你可以通过 pipe 函数来从左到右的方式。

function pipe(...args) {
     return (result) => {
        return args.reduce((result, fn) => {
          return fn(result)
        }, result)
  }
}

实现跟 compose 差不多,只是把参数的遍历方式从右到左(reduceRight)改为从左到右(reduce)。

之前是不是看过很多文章写过如何实现 compose,或者柯里化,部分应用等函数,但是你可能不知道是用来干啥的,也没用过,所以记了又忘,忘了又记,看了这篇文章之后我希望这些你都可以轻松实现。后面会继续讲到柯里化和部分应用的实现。

point-free

在函数式编程的世界中,有这样一种很流行的编程风格。这种风格被称为 tacit programming,也被称作为 point-free,point 表示的就是形参,意思大概就是没有形参的编程风格。

// 这就是有参的,因为 word 这个形参
var snakeCase = word => word.toLowerCase().replace(/\s+/ig, '_');

// 这是 pointfree,没有任何形参
var snakeCase = compose(replace(/\s+/ig, '_'), toLowerCase);

有参的函数的目的是得到一个数据,而 pointfree 的函数的目的是得到另一个函数。

那这 pointfree 有什么用? 它可以让我们把注意力集中在函数上,参数命名的麻烦肯定是省了,代码也更简洁优雅。 需要注意的是,一个 pointfree 的函数可能是由众多非 pointfree 的函数组成的,也就是说底层的基础函数大都是有参的,pointfree 体现在用基础函数组合而成的高级函数上,这些高级函数往往可以作为我们的业务函数,通过组合不同的基础函数构成我们的复制的业务逻辑。

可以说 pointfree 使我们的编程看起来更美,更具有声明式,这种风格算是函数式编程里面的一种追求,一种标准,我们可以尽量的写成 pointfree,但是不要过度的使用,任何模式的过度使用都是不对的。

另外可以看到通过 compose 组合而成的基础函数都是只有一个参数的,但是往往我们的基础函数参数很可能不止一个,这个时候就会用到一个神奇的函数(柯里化函数)。

柯里化

在维基百科里面是这么定义柯里化的:

在计算机科学,柯里化(英语:Currying),又译为卡瑞化加里化,是把接受多个参数函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。

在定义中获取两个比较重要的信息:

  • 接受一个单一参数
  • 返回结果是函数

这两个要点不是 compose 函数参数的要求么,而且可以将多个参数的函数转换成接受单一参数的函数,岂不是可以解决我们再上面提到的基础函数如果是多个参数不能用的问题,所以这就很清楚了柯里化函数的作用了。

柯里化函数可以使我们更好的去追求 pointfree,让我们代码写得更优美!

接下来我们具体看一个例子来理解柯里化吧:

比如你有一间士多店并且你想给你优惠的顾客给个 10% 的折扣(即打九折):

function discount(price, discount) {
    return price * discount
}

当一位优惠的顾客买了一间价值$500的物品,你给他打折:

const price = discount(500, 0.10); // $50 

你可以预见,从长远来看,我们会发现自己每天都在计算 10% 的折扣:

const price = discount(1500,0.10); // $150
const price = discount(2000,0.10); // $200
// ... 等等很多

我们可以将 discount 函数柯里化,这样我们就不用总是每次增加这 0.10 的折扣。

// 这个就是一个柯里化函数,将本来两个参数的 discount ,转化为每次接收单个参数完成求职
function discountCurry(discount) {
    return (price) => {
        return price * discount;
    }
}
const tenPercentDiscount = discountCurry(0.1);

现在,我们可以只计算你的顾客买的物品都价格了:

tenPercentDiscount(500); // $50

同样地,有些优惠顾客比一些优惠顾客更重要-让我们称之为超级客户。并且我们想给这些超级客户提供 20% 的折扣。 可以使用我们的柯里化的discount函数:

const twentyPercentDiscount = discountCurry(0.2);

我们通过这个柯里化的 discount 函数折扣调为 0.2(即20%),给我们的超级客户配置了一个新的函数。 返回的函数 twentyPercentDiscount 将用于计算我们的超级客户的折扣:

twentyPercentDiscount(500); // 100

我相信通过上面的 **discountCurry **你已经对柯里化有点感觉了,这篇文章是谈的柯里化在函数式编程里面的应用,所以我们再来看看在函数式里面怎么应用。

现在我们有这么一个需求:给定的一个字符串,先翻转,然后转大写,找是否有TAOWENG,如果有那么就输出 yes,否则就输出 no。

function stringToUpper(str) {
    return str.toUpperCase()
}

function stringReverse(str) {
    return str.split('').reverse().join('')
}

function find(str, targetStr) {
    return str.includes(targetStr)
}

function judge(is) {
    console.log(is ? 'yes' : 'no')
}

我们很容易就写出了这四个函数,前面两个是上面就已经写过的,然后 find 函数也很简单,现在我们想通过 compose 的方式来实现 pointfree,但是我们的 find 函数要接受两个参数,不符合 compose 参数的规定,这个时候我们像前面一个例子一样,把 find 函数柯里化一下,然后再进行组合:

// 柯里化 find 函数
function findCurry(targetStr) {
    return str => str.includes(targetStr)
}

const findTaoweng = findCurry('TAOWENG')

const result = compose(judge, findTaoweng, stringReverse, stringToUpper)

看到这里是不是可以看到柯里化在达到 pointfree 是非常的有用,较少参数,一步一步的实现我们的组合。

但是通过上面那种方式柯里化需要去修改以前封装好的函数,这也是破坏了开闭原则,而且对于一些基础函数去把源码修改了,其他地方用了可能就会有问题,所以我们应该写一个函数来手动柯里化。

根据定义之前对柯里化的定义,以及前面两个柯里化函数,我们可以写一个二元(参数个数为 2)的通用柯里化函数:

function twoCurry(fn) {
    return function(firstArg) { // 第一次调用获得第一个参数
        return function(secondArg) { // 第二次调用获得第二个参数
            return fn(firstArg, secondArg) // 将两个参数应用到函数 fn 上
        }
    }
}

所以上面的 findCurry 就可以通过 twoCurry 来得到:

const findCurry = twoCurry(find)

这样我们就可以不更改封装好的函数,也可以使用柯里化,然后进行函数组合。不过我们这里只实现了二元函数的柯里化,要是三元,四元是不是我们又要要写三元柯里化函数,四元柯里化函数呢,其实我们可以写一个通用的 n 元柯里化。

function currying(fn, ...args) {
    if (args.length >= fn.length) {
        return fn(...args)
    }
    return function (...args2) {
        return currying(fn, ...args, ...args2)
    }
}

我这里采用的是递归的思路,当获取的参数个数大于或者等于 fn 的参数个数的时候,就证明参数已经获取完毕,所以直接执行 fn 了,如果没有获取完,就继续递归获取参数。

可以看到其实一个通用的柯里化函数核心思想是非常的简单,代码也非常简洁,而且还支持在一次调用的时候可以传多个参数(但是这种传递多个参数跟柯里化的定义不是很合,所以可以作为一种柯里化的变种)。

我这里重点不是讲柯里化的实现,所以没有写得很健壮,更强大的柯里化函数可见羽讶的:JavaScript专题之函数柯里化

部分应用

部分应用是一种通过将函数的不可变参数子集,初始化为固定值来创建更小元数函数的操作。简单来说,如果存在一个具有五个参数的函数,给出三个参数后,就会得到一个、两个参数的函数。

看到上面的定义可能你会觉得这跟柯里化很相似,都是用来缩短函数参数的长度,所以如果理解了柯里化,理解部分应用是非常的简单:

function debug(type, firstArg, secondArg) {
    if(type === 'log') {
        console.log(firstArg, secondArg)
    } else if(type === 'info') {
        console.info(firstArg, secondArg)
    } else if(type === 'warn') {
        console.warn(firstArg, secondArg)
    } else {
        console.error(firstArg, secondArg)
    }
}

const logDebug = 部分应用(debug, 'log')
const infoDebug = 部分应用(debug, 'info')
const warnDebug = 部分应用(debug, 'warn')
const errDebug = 部分应用(debug, 'error')

logDebug('log:', '测试部分应用')
infoDebug('info:', '测试部分应用')
warnDebug('warn:', '测试部分应用')
errDebug('error:', '测试部分应用')

debug方法封装了我们平时用 console 对象调试的时候各种方法,本来是要传三个参数,我们通过部分应用的封装之后,我们只需要根据需要调用不同的方法,传必须的参数就可以了。

我这个例子可能你会觉得没必要这么封装,根本没有减少什么工作量,但是如果我们在 debug 的时候不仅是要打印到控制台,还要把调试信息保存到数据库,或者做点其他的,那是不是这个封装就有用了。

因为部分应用也可以减少参数,所以他在我们进行编写组合函数的时候也占有一席之地,而且可以更快传递需要的参数,留下为了 compose 传递的参数,这里是跟柯里化比较,因为柯里化按照定义的话,一次函数调用只能传一个参数,如果有四五个参数就需要:

function add(a, b, c, d) {
    return a + b + c +d
}

// 使用柯里化方式来使 add 转化为一个一元函数
let addPreThreeCurry = currying(add)(1)(2)(3)
addPreThree(4) // 10

这种连续调用(这里所说的柯里化是按照定义的柯里化,而不是我们写的柯里化变种),但是用部分应用就可以:

// 使用部分应用的方式使 add 转化为一个一元函数
const addPreThreePartial = 部分应用(add, 1, 2, 3)
addPreThree(4) // 10

既然我们现在已经明白了部分应用这个函数的作用了,那么还是来实现一个吧,真的是非常的简单:

// 通用的部分应用函数的核心实现
function partial(fn, ...args) {
    return (..._arg) => {
        return fn(...args, ..._arg);
    }
}

另外不知道你有没有发现,这个部分应用跟 JavaScript 里面的 bind 函数很相似,都是把第一次穿进去的参数通过闭包存在函数里,等到再次调用的时候再把另外的参数传给函数,只是部分应用不用指定 this,所以也可以用 bind 来实现一个部分应用函数。

// 通用的部分应用函数的核心实现
function partial(fn, ...args) {
    return fn.bind(null, ...args)
}

另外可以看到实际上柯里化和部分应用确实很相似,所以这两种技术很容易被混淆。它们主要的区别在于参数传递的内部机制与控制:

  • 柯里化在每次分布调用时都会生成嵌套的一元函数。在底层 ,函数的最终结果是由这些一元函数逐步组合产生的。同时,curry 的变体允许同时传递一部分参数。因此,可以完全控制函数求值的时间与方式
  • 部分应用将函数的参数与一些预设值绑定(赋值),从而产生一个拥有更少参数的新函数。改函数的闭包中包含了这些已赋值的参数,在之后的调用中被完全求值。

总结

在这篇文章里我重点想介绍的是函数以组合的方式来完成我们的需求,另外介绍了一种函数式编程风格:pointfree,让我们在函数式编程里面有了一个最佳实践,尽量写成 pointfree 形式(尽量,不是都要),然后介绍了通过柯里化或者部分应用来减少函数参数,符合 compose 或者 pipe 的参数要求。

所以这种文章的重点是理解我们如何去组合函数,如何去抽象复杂的函数为颗粒度更小,功能单一的函数。这将使我们的代码更容易维护,更具声明式的特点。

对于这篇文章里面提到的其他概念:闭包、作用域,然后柯里化的其他用途我希望是在番外篇里面更深入的去理解,而这篇文章主要掌握函数组合就行了。

参考文章

文章首发于自己的个人网站桃园,另外也可以在 github blog 上找到。

如果有兴趣,也可以关注我的个人公众号:「前端桃园」