函数式编程之组合与管道

7. 组合与管道

昨天我们学习了柯里化与偏函数,当然不能学完就完了,一些经典的函数什么的还是需要记一下的,比如今天重写新写一下看看能不能写出来,也能加深自己对这方面的理解。

今天我们将要学习的是函数式组合的含义及其实际应用。

函数式组合在函数式编程中被称为组合,我们将通过了解组合的概念并学习大量例子,然后创建自己的 compose 函数。理解 compose 函数底层的运行机制是一项有趣的任务。

7.1 组合的概念

在了解函数式组合之前,我们先来理解一下组合的概念。先来介绍一种理念,它将使我们从组合中受益

Unix 的理念

Unix 的理念有部分内容如下:

每个程序只做好一件事情。为了完成一项新的任务,重新构建要好于在复杂的旧程序中添加 "新属性"

这也是我们在创建函数时秉承的理念。函数式编程遵循了 Unix 的理念

该理念的第二部分是

每个程序的输出都应该是另一个尚未可知的程序的输入

这是什么意思呢?我们来看一些 Unix 平台上的命令

  • cat:用于在控制台显示文本文件的内容(可以将它看做一个函数,接收一个参数,表示文件的位置,并将输出打印到控制台)
  • grep:在给定的文本中搜索内容,返回包含内容的文本行(也可以看做函数,接收一个输入并给出输出)

假设我们想通过 cat 命令发送数据,并将其作为 grep 命令的输入以完成一次搜索。我们知道 cat 命令会返回数据,而 grep 命令会接收数据并将其用于搜索操作。因此,使用 Unix 的管道符号 |,我们就能完成该任务。

cat test.txt | grep 'world'

“|” 被称为管道符号,它允许我们通过组合一些函数去创建一个能够解决问题的新函数。大致来讲,它将左侧函数的输出作为输入发送给右侧的函数。从技术上来讲,该处理过程称为管道。

上面的例子可能很简单,但是它传达了每个程序的输出都应该是另一个尚未可知的程序的输入的理念。

随着需求的加入,我们通过基础函数创建了一个新函数,也就是组合成一个新函数。当然,管道在里面扮演了桥梁的作用。

现在我们通过基础函数的组合了解了组合函数的思想。组合函数真正的优势在于:无须创建新的函数就可以通过基础函数解决眼前的问题。

7.2 函数式组合

本节将讨论一个有用的函数式组合的用例。

7.2.1 回顾 map 与 filter

还记得之前数组的函数式编程里面的问题吗?

我们又一个对象数组,结构如下

let apressBooks = [
	{
		'id': 111,
		'title': 'c# 6.0',
		'author': 'Andrew Troelsen',
		'rating': [4.7],
		'reviews': [{good: 4, excellent: 12}]
	},
	{
		'id': 222,
		'title': 'Efficient Learning Machines',
		'author': 'Rahul Khanna',
		'rating': [4.5],
		'reviews': []
	},
	{
		'id': 333,
		'title': 'Pro AngularJS',
		'author': 'Adam Freeman',
		'rating': [4.0],
		'reviews': []
	},
    {
		'id': 444,
		'title': 'Pro ASP.NET',
		'author': 'Adam Freeman',
		'rating': [4.2],
		'reviews': [{good: 14, excellent: 12}]
	},
]
复制代码

问题是从里面获取含有 title 和 author 字段且评级高于 4.5 的对象。当时我们的解决方案如下

map(filter(apressBooks, book => book.rating[0]>4.5),book => {
    return {title: book.title, author: book.author}
})
复制代码

是不是觉得很熟悉?这不就是上一节讲的吗?将 filter 的输出作为输入参数传递给 map 函数。那么,在 js 中有和 “|” 类似的操作吗?别说,还真可以

7.2.2 compose 函数

本节将创建一个 compose 函数。它需要接受一个函数的输出,并将其输入传递给另一个函数。现在把该过程封装进一个函数

const compose = (a,b) => c => a(b(c))
// 即
const compose = function(a, b){
    return function(c){
        return a(b(c))
    }
}
复制代码

compose 函数简单实现了我们的需求。它接受两个函数,a 和 b,并返回了一个接受参数 c 的函数。当用 c 调用返回函数时,它将用输入 c 调用函数 b,b 的输出将作为 a 的输入。这就是 compose 函数的简单定义。我们先用一个简单的例子快速测试一下 compose 函数。

7.3 应用 compose 函数

假设我们想对一个给定的浮点数进行四舍五入求值。给定的数字为浮点型,因此必须将数字转换为浮点型并调用 Math.round。如果不使用组合,我们将通过下面方式来做

let data = parseFloat('3.56')
let number = Math.round(data)
复制代码

输出将是我们期望的 4,但是这完全可以通过 compose 函数来解决啊

let number = compose(Math.round,parseInt)
复制代码

上面的语句将返回一个新函数,它被存储在一个变量 number 中,与下面的代码等价

number = c => Math.round(parseInt(c))
复制代码

这个过程就是函数式组合!我们将两个函数组合在一起以便能即时地构建出一个新函数。

假设我们有两个函数:

let splitIntoSpaces = str => str.split(' ');
let count = array => array.length
复制代码

如果想构建一个新函数以便计算一个字符串中单词的数量,可以很容易地实现:

const countWords = compose(count, splitIntoSpaces);
复制代码

通过 compose 函数创建新的函数是一种优雅而简单的方式

7.3.1 引入 curry 与 partial

我们知道,仅当函数接受一个参数时,我们才能将两个函数组合。但多参数函数呢?

还记得我们昨天学的吗?是的,我们可以通过 partial 和 curry 来实现。

我们将把 map 和 filter 函数组合起来,它们都接受两个参数,第一个是数组,第二个是操作数组的函数。我们可以通过 partial 函数来组合

我们先把之前的对象数组贴过来

let apressBooks = [
	{
		'id': 111,
		'title': 'c# 6.0',
		'author': 'Andrew Troelsen',
		'rating': [4.7],
		'reviews': [{good: 4, excellent: 12}]
	},
	{
		'id': 222,
		'title': 'Efficient Learning Machines',
		'author': 'Rahul Khanna',
		'rating': [4.5],
		'reviews': []
	},
	{
		'id': 333,
		'title': 'Pro AngularJS',
		'author': 'Adam Freeman',
		'rating': [4.0],
		'reviews': []
	},
    {
		'id': 444,
		'title': 'Pro ASP.NET',
		'author': 'Adam Freeman',
		'rating': [4.2],
		'reviews': [{good: 14, excellent: 12}]
	},
]
复制代码

假设我们根据不同评级在代码库中定义了很多小函数用于过滤图书,如下所示

let filterOutStandingBooks = book => book.rating[0] === 5;
let filterGoodBooks = book => book.rating[0] > 4.5;
let filterBadBooks = book => book.rating[0] < 3.5;
复制代码

再定义一些投影函数

let projectTitleAndAuthor = book => {title: book.title, author: book.author}
let projectAuthor = book => {author: book.author}
let projectTitle = book => {title: book.title}
复制代码

为什么要定义这么多小函数呢?因为组合的思想就是把小函数组合成一个大函数,简单的函数更容易阅读,测试和维护。

现在该解决问题了——获取评级高于 4.5 的图书的标题和作者,我们可以通过 compose 和 partial 来实现

let queryGoodBooks = partial(filter,undefined,filterGoodBooks);
let mapTitleAndAuthor = partial(map,undefined,projectTitleAndAuthor);
let titleAndAuthorForGoodBooks = compose(mapTitleAndAuthor,queryGoodBooks);
复制代码

下面来解释一下

首先,compose 函数只能组合接受一个参数的函数,但是 filter 和 map 接受两个参数,因此,我们不能直接将它们组合。这就是我们先使用 partial 函数部分地应用 map 和 filter 的第二个参数的原因

partial(filter,undefined,filterGoodBooks);
partial(map,undefined,projectTitleAndAuthor);
复制代码

此处我们出入了 filterGoodBooks 函数来查找评级高于 4.5 的图书,传入 projectTitleAndAuthor 函数来获取 apressBooks 对象的 title 和 author 属性。现在的偏应用函数都只接受一个数组参数了!有了这两个偏函数,我们就可以通过 compose 函数将它们组合起来了。

let titleAndAuthorForGoodBooks = compose(mapTitleAndAuthor,queryGoodBooks);
复制代码

现在 titleAndAuthorForGoodBooks 只接受一个参数,下面把 apressBooks 对象数组传给它:

titleAndAuthorForGoodBooks(apressBooks)
/*
    [
        {
            title: 'c# 6.0',
            author: 'ANDREW TRELSEN'
        }
    ]
*/
复制代码

同样,我们只想获取评级高于 4.5 的图书的标题,该怎么办?很简单

let mapTitle = partial(map,undefined,projectTitle);
let titleForGoodBooks = compose(mapTitle,queryGoodBooks);

// 调用
titleForGoodBooks(apressBooks)
/*
    [
        {
            title: 'c# 6.0',
        }
    ]
*/
复制代码

那如果要只获取评级等于 5 的图书的作者呢?这个问题留给你自己去想吧

本节使用了 partial 函数来填充函数的参数。其实你也可以使用 curry 函数做同样的事情。只是选择的问题,但是你能使用 curry 给出上面例子的解决方案吗?可以自己想一下(提示:颠倒 map 和 filter 的参数顺序)

7.3.2 组合多个函数

当前 compose 函数只能组合两个给定的函数。如何组合三个、四个或更多个函数呢?现在的函数肯定解决不了。下面重写 compose 函数,让它能够即时地组合多个函数。

记住,我们需要把每个函数的输出作为输入发送给另一个函数(通过递归地存储上一次执行的函数的输出)。可以使用 reduce 函数,之前我们也是用过它逐次归约多个函数调用。

const compose = (...fns) => {
    return value => reduce(fns.reverse(),(acc,fn) => fn(acc), value)
}
// 用真正的 reduce 函数改写一下
var compose = function(...fns){
    return function(value){
        // 这样更容易看出思想,即将 value 作为初始值,然后将其传入最后一个函数,将返回值一直向前传递
        fns.push(value);
        return fns.reverse().reduce((acc,fn)=>{
            return fn(acc);
        })
    }
}
复制代码

其中最重要的是这一句

reduce(fns.reverse(),(acc,fn) => fn(acc), value)

回顾一下我们之前的 reduce 函数,第一参数是传入的数组,第二个参数对数组的操作,第三个参数是初始值。

首先,我们将传入的数组反转,并传入函数(acc,fn) => fn(acc),它会以传入的 acc 作为其参数依次调用每一个函数。累加器的初始值是 value 变量,它将作为函数的第一个输入。

有了新的 compose 函数,下面用一个旧的例子来测试一下它。上一节,我们组合了一个函数用于计算给定字符串的单词数

let splitIntoSpaces = str => str.split(' ');
let count = array => array.length;
const countWords = compose(count, splitIntoSpaces);

// 计算
countWords("hello your reading about composition")
// 5
复制代码

假设我们想知道给定字符串的单词数是奇数还是偶数。而我们已经有了一个这样的函数

let oddOrEven = ip => ip % 2 == 0 ? 'even': 'odd'
复制代码

通过 compose 函数,我们就可以组合这三个函数组合起来以得到想要的结果

const oddOrEvenWords = compose(oddOrEven,count,splitIntoSpaces);

oddOrEvenWords("hello your reading about composition")
// ['odd']
复制代码

但是这个函数及我改写的函数其实都存在一个问题,即它们都只能运行一次,可以自己试下。我自己测出来的,因为 compose 返回的是函数,然后第一次调用的时候执行了 fns.reverse(),reverse 会改变原数组,第二次调用的时候又改变了原数组,一来一回数组变回原来的顺序了,所以会出错。

那么有什么办法改变这个书中存在的 bug 呢?很简单,我们创建一个额外的副本就可以了,如下

const compose = (...fns) => {
    return value => {
        var fnsCopy = fns.reverse();
        reduce(fnsCopy.reverse(),(acc,fn) => fn(acc), value)
    }
}
// 真正的 reduce 函数
var compose = function(...fns){
    return function(value){
        let fnsCopy = fns.concat();
        fnsCopy.push(value);
        return fnsCopy.reverse().reduce((acc,fn)=>{
            return fn(acc);
        })
    }
}
复制代码

这里有一个可能不经常用到的点,数组的 concat 可以快速拷贝一个数组,但是记住这种拷贝是浅拷贝哦。这样我们就可以进行任意次数的操作啦,所以说看书还是得自己动手啊,毕竟绝知此事要躬行。

7.4 管道/序列

上一节我们了解了 compose 函数数据流的运行机制:compose 函数的数据流是从右往左的,因为最右侧的函数最先执行,将数据传递给下一个函数,从我改写的函数就可以看出来

var compose = function(...fns){
    return function(value){
        let fnsCopy = fns.concat();
        fnsCopy.push(value);
        // 此时数组里面是[f1,f2,f3,value]
        // 然后反转数组,数组变为 [value,f3,f2,f1]
        // 然后执行 reduce,先是 f3(value) -> f2(f3(value)) -> f1(f2(f3(value)))
        // 够清楚了吧
        return fnsCopy.reverse().reduce((acc,fn)=>{
            return fn(acc);
        })
    }
}
复制代码

这一节我们将介绍另一种数据流——最左侧的函数最先执行,最右侧的函数最后执行。还记得之前 Unix 里面的 “|” 操作符吗,它就是从左往右的。这一节我们将实现一个 pipe 的函数,它与 compose 函数所做的事情相同,只不过交换了数据流的方向!

从左往右处理数据流的过程称为管道(pipeline)或序列(sequence)

代码实现如下

const pipe = (...fns) => {
    return (value) => reduce(fns,(acc, fn) => fn(acc), value);
}
// 同样用真正的 reduce 改写一下
const pipe = function(...fns){
    return function(value){
        // 这里定义拷贝数组是因为 fns 是数组,如果每次 unshift 的话,数组长度就一直变化,当然也可以操作完以后再做一个 shift 操作,但是直接重新定义的话更方便一些
        let fnsCopy = fns.concat();
        fnsCopy.unshift(value);
        return fnsCopy.reduce((acc, fn) => {
            return fn(acc);
        })
    }
}
复制代码

同样来试验一下

// 请注意,我们改变了函数传入的顺序
const oddOrEvenWords = pipe(splitIntoSpaces,count,oddOrEven);

oddOrEvenWords("hello your reading about composition")
// ['odd']
复制代码

pipe 和 compose 其实实现的是相同的功能,只是数据流方向的区别。在团队开发中最好确定一种方向,否则容易混乱。

7.5 组合的优势

这一节我们将讨论组合最大的优势——组合满足结合律。然后讨论组合多个函数时如何调试

7.5.1 组合满足结合律

函数总是满足结合律

先来复习一下结合律吧

( a + b ) + c = a + ( b +c )

表现在函数中就是

compose(f,compose(g, h)) == compose(compose(f, g),h)
复制代码

还是拿上一节的函数举例子

// compose(compose(f, g),h)
const oddOrEvenWords = compose(compose(oddOrEven,count),splitIntoSpaces);
oddOrEvenWords("hello your reading about composition")
// ['odd']

// compose(f,compose(g, h))
const oddOrEvenWords = compose(oddOrEven,compose(count,splitIntoSpaces));
oddOrEvenWords("hello your reading about composition")
// ['odd']
复制代码

从上面的例子可以看出,两种情况的执行结果是相同的。这就证明了函数式组合满足结合律。那么这有什么用呢?

最大的用处是允许我们把函数组合到各自所需的 compose 函数中,比如

let countWords = compose(count,splitIntoSpaces);
const oddOrEvenWords = compose(oddOrEven,countWords);

// 或者
let countOddOrEven = compose(oddOrEven,count);
const oddOrEvenWords = compose(countOddOrEven,splitIntoSpaces);
复制代码

由于结合律的存在,我们可以创建各种各样的小函数,最后组成大函数,不用担心结果会有变化,这也是为什么之前我们创建那么多小函数的原因。

7.5.2 使用 tap 函数调试

tap 函数式 underscore.js 中的一个函数,其主要目的是在一个链式调用中对中间结果执行某些操作。我们即将要创建的 identity 函数有类似功能,即打印 compose 函数的中间结果,用于 compose 函数的调试。

const identity = it => {
    console.log(it);
    return it;
}
复制代码

我们只是简单的添加了一行 console.log 来打印输出值,为什么就能调试了呢?没错,就是因为函数组合的结合律,我们可以将它放在任何位置而不会影响结果,只是打印了一下结果而已。

让我们测试一下

const oddOrEvenWords = compose(oddOrEven,count,splitIntoSpaces);
oddOrEvenWords("Test string")
复制代码

假设我们在执行代码时,count 函数抛出错误了怎么办,如何得知 count 接收的参数?这就是 identity 函数发挥作用的地方了。我们将 identity 放在可能发生错误的地方

// compose 数据流从右往左,所以要放在 count 后面
compose(oddOrEven,count,identify,splitIntoSpaces)('Test string');
复制代码

这样就会打印出 count 函数接收到的输入参数了,这对于调试函数接收到的数据非常有帮助。

7.6 小结

今天我们从 Unix 的理念谈起,了解了 cat、grep 这些命令式如何按需组合的。然后创建了自己的 compose 和 pipe 函数。顺带发现了书里的一个 bug。还了解了偏函数与柯里化在函数式组合中发挥的作用。

最后我们介绍了函数式组合的一个重要特性——组合满足结合律!并且利用这个特性提供了一个名为 identity 的小函数。我们可以用它来调试组合过程中出现的错误。

我们需要记住,compose 函数是通过组合一些简单,并且定义良好的小函数来实现复杂函数的。当然最重要的是自己动手来实现,否则你永远也记不住。至少我是这样。

明天我们要学习的是一个简单而强大的东西——函子,那么明天见。

猜你喜欢

转载自juejin.im/post/5ba0c01ee51d450e950fdf7c