内容简介:昨天我们学习了柯里化与偏函数,当然不能学完就完了,一些经典的函数什么的还是需要记一下的,比如今天重写新写一下看看能不能写出来,也能加深自己对这方面的理解。今天我们将要学习的是函数式组合的含义及其实际应用。函数式组合在函数式编程中被称为组合,我们将通过了解组合的概念并学习大量例子,然后创建自己的 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 函数是通过组合一些简单,并且定义良好的小函数来实现复杂函数的。当然最重要的是自己动手来实现,否则你永远也记不住。至少我是这样。
明天我们要学习的是一个简单而强大的东西——函子,那么明天见。
以上所述就是小编给大家介绍的《函数式编程之组合与管道》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- Clojure 集合管道函数练习
- Java 8 习惯用语,第 2 部分: 函数组合与集合管道模式
- 我可以使用F#中的管道运算符将参数传递给构造函数吗?
- 速度不够,管道来凑——Redis管道技术
- Golang pipline泛型管道和类型管道的性能差距
- Linux 管道那些事儿
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。