函数式编程之组合与管道

栏目: 编程语言 · 发布时间: 6年前

内容简介:昨天我们学习了柯里化与偏函数,当然不能学完就完了,一些经典的函数什么的还是需要记一下的,比如今天重写新写一下看看能不能写出来,也能加深自己对这方面的理解。今天我们将要学习的是函数式组合的含义及其实际应用。函数式组合在函数式编程中被称为组合,我们将通过了解组合的概念并学习大量例子,然后创建自己的 compose 函数。理解 compose 函数底层的运行机制是一项有趣的任务。

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

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

函数式组合在函数式编程中被称为组合,我们将通过了解组合的概念并学习大量例子,然后创建自己的 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 函数是通过组合一些简单,并且定义良好的小函数来实现复杂函数的。当然最重要的是自己动手来实现,否则你永远也记不住。至少我是这样。

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


以上所述就是小编给大家介绍的《函数式编程之组合与管道》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

锋利的jQuery

锋利的jQuery

单东林、张晓菲、魏然 / 人民邮电出版社 / 2009-6 / 39.00元

《锋利的jQuery》循序渐进地对jQuery的各种函数和方法调用进行了介绍,读者可以系统地掌握jQuery的DOM操作、事件监听和动画、表单操作、AJAX以及插件方面等知识点,并结合每个章节后面的案例演示进行练习,达到掌握核心知识点的目的。为使读者更好地进行开发实践,《锋利的jQuery》的最后一章将前7章讲解的知识点和效果进行了整合,打造出一个非常有个性的网站,并从案例研究、网站材料、网站结构......一起来看看 《锋利的jQuery》 这本书的介绍吧!

URL 编码/解码
URL 编码/解码

URL 编码/解码

XML 在线格式化
XML 在线格式化

在线 XML 格式化压缩工具

Markdown 在线编辑器
Markdown 在线编辑器

Markdown 在线编辑器