文章内容输出来源:拉勾教育 大前端高薪训练营
前言
我在另一篇文章 函数式编程 – 纯函数、柯里化函数 中写到,副作用会让一些函数变得不纯,那么,我们如何把副作用控制在可控的范围内呢,这就涉及到了函子的概念。
函子(Functor)
1. 什么是函子
在开始学习之前,我们先来了解什么是函子?
-
函子是一个容器,包含值和值的变形关系(即函数)。
-
函子是一个特殊的容器,通过一个普通的对象来实现,该对象具有 map 方法,map 方法可以运行一个函数对值进行处理(变形关系)
代码如下(示例):
// 一个容器,包裹一个值 class Container { constructor (value) { this._value = value // 使用_表示变量私有化 } // map方法, 传入变形关系(函数),将容器里面的每一个值,映射到另一个容器 map (fn) { return Container.of(fn(this._value)) } } // 创建函子对象 let r = new Container(5) .map(x => x + 1) // 返回新的函子对象, 在新的函子对象中保存值 .map(x => x * x ) console.log(r);
上面的代码中,Container 是一个函子,它的map方法接受函数f作为参数,然后返回一个新的函子,里面包含的值是被 fn 处理过的(fn(this._value))。
上面生成新的函子对象的时候,用了 new 命令。new 命令是面向对象编程的标志,不符合函数式编程的思想。 -
函数式编程一般约定,函子有一个of方法,用来生成新的容器。
那么,我们接下来就用 of 方法替换掉 new 进行改造。
代码如下(示例):
class Container { // of 使用static,将其设置为静态方法,可以使用 "类.类方法" 的方式调用 static of (value) { return new Container(value) } ...... // 下面代码和上面的一样,就不在此赘述了 } // 链式编程 let r = Container.of(5).map(x => x + 2).map(x => x * x) console.log(r);
-
总结
1、函数式编程的运算不直接操作值,而是由函子完成
2、函子就是一个实现了 map 契约的对象
3、我们可以把函子想象成一个盒子,这个盒子里封装了一个值
4、想要处理盒子中的值,我们需要给盒子的 map 方法传递一个处理值的函数(纯函数),由这个函数来对值进行处理
5、最终 map 方法返回一个包含新值的盒子(函子)
2. MayBe 函子
空值问题
-
函子接受各种函数,处理容器内部的值。但是,当容器内部的值是一个空值(比如null),而外部函数未必有处理空值的机制,如果传入空值,很可能就会出错。
代码如下(示例):
// 值如果不小心传入了空值(副作用) Container.of(null) .map(x => x.toUpperCase()) // TypeError: Cannot read property 'toUpperCase' of null 12
解决方案
-
MayBe 函子的作用就是可以对外部的空值情况做处理(控制副作用在允许的范围),准确的说是,它的 map 方法里面设置了空值检查。
代码如下(示例):
class Maybe { map (fn) { return this.isNothing() ? Maybe.of(null) : Maybe.of(fn(this._value)) } isNothing () { return this._value == null || this._value == undefined } } // 测试 let r = Maybe.of('Hello World').map(x => x.toUpperCase()).map(x => null).map(x => x.split(' ')) console.log(r);
然而,在 MayBe 函子中,我们很难确认是哪一步产生的空值问题,要解决这个问题,我们就要借助下面的 Either 函子 ,去处理异常情况。
3. Either 函子
在普通的面向对象编程中,我们通常使用条件运算语句 if…else… 进行异常等方面的判断。而在函数式编程中,我们是用 Either 函子 进行表达。Either,英文意思,两者中的任何一个。
-
Either 函子内部有两个值:左值(Left)和右值(Right)。右值是正常情况下使用的值,左值是右值不存在时使用的默认值。
代码如下(示例):
// 记录错误信息, 右值不存在时使用的默认值 class Left { static of (value) { return new Left(value) } constructor (value) { this._value = value } map (fn) { return this } } // 正常情况下使用的值 class Right { static of (value) { return new Right(value) } constructor (value) { this._value = value } map (fn) { return Right.of(fn(this._value)) } } // Either 用来处理异常 function parseJSON (str) { try { return Right.of(JSON.parse(str)) } catch (e) { return Left.of({error: e.message }) } } // let r = parseJSON('{ name: zs }') // console.log(r) // 执行 Left let r = parseJSON('{ "name": "zs" }').map(x => x.name.toUpperCase()) console.log(r) // 执行 Right
4. IO 函子
在程序运行中,往往会有很多的函数依赖于外部环境,从而会带来相应的副作用,这也就是我们前面所说的不纯函数,在这里,我们就不多加赘述了。那么,如何可以把不纯的函数,让它 “纯”起来呢?为了解决这个问题,我们需要一个新的 Functor,即 IO 函子。
特性
-
IO 函子与其他函子的不同在于,IO 函子中的 _value 是一个函数,把函数作为值来处理。
-
IO 函子可以把不纯的动作存储到 _value(函数) 中,延迟执行这个不纯的操作(惰性执行)。可以认为,IO 包含的是被包裹的操作的返回值。
-
IO 函子把不纯的操作交给调用者来处理。
代码如下(示例):
const { values } = require('lodash') const fp = require('lodash/fp') class IO { static of (value) { // return new IO(function () { return value }) } constructor (fn) { // value 存储函数 this._value = fn } map (fn) { // 将传入的 fn 进行包裹,利用fp.flowRight() 使之柯里化 return new IO(fp.flowRight(fn, this._value)) } } // 调用,process:node中的进程模块 let r = IO.of(process).map(p => p.execPath) // console.log(r) // IO { _value: [Function] } console.log(r._value()); // 当前node进程的执行路径
5. Folktale
-
folktale 一个标准的函数式编程库,和 lodash、ramda 不同的是,他没有提供很多功能函数。
-
只提供了一些函数式处理的操作,例如:compose、curry 等,一些函子 Task、Either、 MayBe 等。
代码如下(示例):
// Folktale 函数式编程库 const { toUpper, first } = require('lodash/fp') const { compose, curry } = require('folktale/core/lambda') // 第一个参数是传入函数的参数个数 let f = curry(2, (x, y) => x + y) console.log(f(1, 2)); console.log(f(1)(2)); let f = compose(toUpper, first) console.log(f(['one', 'two']));
-
Task 异步执行
Task 函子通过类似 Promise 的 resolve 的风格来声明一个异步流程,在下面的代码中声明的 readFile 函数中返回的 Task 函子 并没有真正发起请求,它只声明了一个请求动作,这个动作并没有被执行。代码如下(示例):
const fs = require('fs') const { task } = require('folktale/concurrency/task') const { split, find } = require('lodash/fp') function readFile (filename) { // 通过类似 Promise 的 resolve 的风格来声明一个异步流程,返回一个Task 函子 return task(resolver => { // fs 的readFile() 执行的是异步操作 fs.readFile(filename, 'utf-8', (err, data) => { // 类似Promise中的resolve 和 reject // reject用来报错误信息,resolve用来获取执行成功的数据。 if (err) resolver.reject(err) resolver.resolve(data) }) }) } let version = readFile('../package.json') // 只声明读取文件的动作,该动作并未执行 .map(split('\n')) // 通过 map 方法,添加不同的数据操作流程。 .map(find(x => x.includes('version'))) // includes() 方法用于判断字符串是否包含指定的子字符串。 .run() // 调用 run() 触发上面的动作,进行 .listen({ onRejected: err => { // 执行失败 console.log(err); }, onResolved: value => { // 执行成功 console.log(value); } }) console.log(version); // "version": "1.0.0"
在上面的代码中,Task 的异步流直到 run 之前都仅仅是「动作」,没有「执行」。task 函子中提供了 run() 方法,用来触发动作的执行。也就是说,执行 run 方法之后,才会触发上面的文件读取,以及对文件内容的一系列处理等操作。Task 函子中,还提供了 listen() 方法,用来监听事件的执行状态。onRejected 表示 动作执行失败后,要执行的函数,onResolved 表示 动作执行成功后,要执行的函数。
6. Pointed 函子
-
Pointed 函子是实现了 of 静态方法的函子;
-
of 方法是为了避免使用 new 来创建对象,更深层的含义是 of 方法用来把值放到上下文
Context(把值放到容器中,使用 map 来处理值)代码如下(示例):
class Container { static of (value) { return new Container(value) } ...... } Contanier.of(2) .map(x => x + 5)
7. Monad(单子)
-
在使用 IO 函子的时候,如果我们写出如下代码:
代码如下(示例):
const fs = require('fs') const fp = require('lodash/fp') let readFile = function (filename) { return new IO(function() { // 返回一个文件类型的实例 return fs.readFileSync(filename, 'utf-8') }) } let print = function(x) { return new IO(function() { console.log(x) // 将文件内容输出 return x }) } // IO(IO(x)) // 调用 _value() 时,执行的是print 中的function let cat = fp.flowRight(print, readFile) // 调用 let r = cat('package.json')._value()._value() console.log(r)
特性
-
Monad 函子是可以变扁的 Pointed 函子,IO(IO(x))
-
Monad 内部封装的值是一个函数(这个函数返回函子)
-
一个函子如果具有 join 和 of 两个方法并遵守一些定律就是一个 Monad
代码如下(示例):
// IO Monad const fs = require('fs') const fp = require('lodash/fp') class IO { static of (value) { return new IO(function () { return value }) } constructor (fn) { this._value = fn } map (fn) { return new IO(fp.flowRight(fn, this._value)) } // 通过 join 方法避免函子嵌套 join () { return this._value() } // 同时调用map 和 join flatMap (fn) { // this.map(fn) 调用完后,返回函子 return this.map(fn).join() } } // 读取文件的内容,并且把他们打印出来 let readFile = function (filename) { return new IO(function () { return fs.readFileSync(filename, 'utf-8') // 同步读取文件 }) } let print = function (x) { return new IO(function () { console.log(x) return x }) } let r = readFile('../package.json') // 返回函子时,调用faltMap; 返回值时,调用map // .map(x => x.toUpperCase()) .map(fp.toUpper) .flatMap(print) // 返回 IO { _value: [Function] } -- 函子 .join() // 返回 map 后的文件内容 console.log(r);
作用
- Monad 函子 主要用来解决函子嵌套的问题,通过 join 方法避免函子嵌套。
何时使用
- 当一个函数返回一个函子的时候,需要使用 Monad。
总结
- 简单说,Monad就是一种设计模式,表示将一个运算过程,通过函数拆解成互相连接的多个步骤。你只要提供下一步运算所需的函数,整个运算就会自动进行下去。也就是说,Monad 是将一个会返回包裹值的函数应用到一个被包裹的值上。
参考
【函数式编程入门教程】
【异步流程与 Task 函子】
【JavaScript函数式编程 IO涵子,错误处理涵子】