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

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

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

组合函数

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

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

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

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

好比咱们如今流行的 SPA (单页面应用),都会有组件的概念,为何要有组件的概念呢,由于它的目的就是想让你把一些通用的功能或者元素组合抽象成可重用的组件,就算不通用,你在构建一个复杂页面的时候也能够拆分红一个个具备简单功能的组件,而后再组合成你知足各类需求的页面。程序员

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

如今咱们有这样一个需求:给你一个字符串,将这个字符串转化成大写,而后逆序。数据库

你可能会这么写。编程

// 例 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]。segmentfault

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

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

// 例 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 = 组合(stringReverse, 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.01 的折扣。

// 这个就是一个柯里化函数,将原本两个参数的 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 上找到。

若是有兴趣,也能够关注个人我的公众号:「前端桃园」

相关文章
相关标签/搜索