函数式编程(五)—— 函子

JavaScriptFunctor

# Functor

# 为什么要学函子?

函子(representative functor)是范畴论里的概念,指从任意范畴到集合范畴的一种特殊函子。 我们没有办法避免副作用,但是我们尽可能的将副作用控制在可控的范围内,我们可以通过函子去处理副作用,我们也可以通过函子去处理异常,异步操作等。

# 什么是Functor

  • 容器:包含值和值的变形关系(这个变形关系就是函数)
  • 函子:是一个特殊的容器,通过一个普通的对象来实现,该对象具有 map 方法,map方法可以运行一个函数对值进行处理(变形关系)

# 理解Functor

class Container {
  constructor (value) {
    // 这个函子的值是保存在内部的,不对外公布
    // _下划线的成员都是私有成员,外部无法访问,值是初始化的传的参数
    this._value = value
  }
  
  //有一个对外的方法map,接收一个函数(纯函数),来处理这个值
  map (fn) {
    // 返回一个新的函子,把fn处理的值返回给函子,由新的函子来保存
    return new Container(fn(this._value))
  }
}

// 创建一个函子的对象
let r = new Container(5)
  .map(x => x + 1) // 6
  .map(x => x ** 2) // 36

// 返回了一个container函子对象,里面有值是36,不对外公布
console.log(r) //Container { _value: 36 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

上面还是面向对象的编程思想,要修改成函数式编程的思想,需要避免使用new

class Container {
  //使用类的静态方法,of替代了new Container的作用
  static of (value) {
    return new Container(value)
  }
  constructor (value) {
    this._value = value
  }
  
  map (fn) {
    return Container.of(fn(this._value))
  }
}

const r = Container.of(5)
            .map(x=>x+2) // 7
            .map(x=> x**2) // 49

console.log(r) // Container { _value: 49 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 总结

  • 函数式编程的运算不直接操作值,而是由函子完成
  • 函子就是一个实现了 map 契约的对象
  • 我们可以把函子想象成一个盒子,这个盒子里封装了一个值
  • 想要处理盒子中的值,我们需要给盒子的 map 方法传递一个处理值的函数(纯函数),由这个函数来对值进行处理
  • 最终 map 方法返回一个包含新值的盒子(函子)

遗留问题:如果value是null undefined,怎么办?

Container.of(null)
  .map(x=>x.toUpper) // 报错,使得函数不纯
1
2

下面会有好几种函子,处理不同的问题

# MyBe函子

MayBe 函子的作用就是可以对外部的空值情况做处理(控制副作用在允许的范围)

class MayBe {
  static of (value) {
    return new MayBe(value)
  }
  constructor (value) {
    this._value = value
  }

  map(fn) {
    // 判断一下value的值是不是null和undefined,如果是就返回一个value为null的函子,如果不是就执行函数
    return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this._value))
  }

 // 定义一个判断是不是null或者undefined的函数,返回true/false
  isNothing() {
    return this._value === null || this._value === undefined
  }
}

const r = MayBe.of('hello world')
  .map(x => x.toUpperCase())

console.log(r) //MayBe { _value: 'HELLO WORLD' }


// 如果输入的是null,是不会报错的
const rnull = MayBe.of(null)
  .map(x => x.toUpperCase())
console.log(rnull) //MayBe { _value: null }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

但是这里有一个问题就是,如果map中间有好几步,最后返回是null,并不知道是哪一个步骤返回的。解决这个问题,需要看下一个函子。

# Either函子

  • Either 两者中的任何一个,类似于 if...else...的处理
  • 当出现问题的时候,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))
  }
}

let r1 = Right.of(12).map(x => x + 2)
let r2 = Left.of(12).map(x => x + 2)
console.log(r1) // Right { _value: 14 }
console.log(r2) // Left { _value: 12 }
// 为什么结果会不一样?因为Left返回的是当前对象,并没有使用fn函数

// 那么这里如何处理异常呢?
// 我们定义一个字符串转换成对象的函数
function parseJSON(str) {
  // 对于可能出错的环节使用try-catch
  // 正常情况使用Right函子
  try{
    return Right.of(JSON.parse(str))
  }catch (e) {
  // 错误之后使用Left函子,并返回错误信息
    return Left.of({ error: e.message })
  }
}

let rE = parseJSON('{name:xm}')
console.log(rE) // Left { _value: { error: 'Unexpected token n in JSON at position 1' } }
let rR = parseJSON('{"name":"xm"}')
console.log(rR) // Right { _value: { name: 'xm' } }

console.log(rR.map(x => x.name.toUpperCase())) // Right { _value: 'XM' }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55

# IO函子

  • IO就是输入输出,IO 函子中的 _value 是一个函数,这里是把函数作为值来处理
  • IO 函子可以把不纯的动作存储到 _value 中,延迟执行这个不纯的操作(惰性执行),包装当前的操 作
  • 把不纯的操作交给调用者来处理

因为IO函数需要用到组合函数,所以需要提前安装Lodash

npm init -y

npm i lodash

const fp = require('lodash/fp')

class IO {
  // of方法快速创建IO,要一个值返回一个函数,将来需要值的时候再调用函数
  static of(value) {
    return new IO(() => value)
  }
  // 传入的是一个函数
  constructor (fn) {
    this._value = fn
  }

  map(fn) {
    // 这里用的是new一个新的构造函数,是为了把当前_value的函数和map传入的fn进行组合成新的函数
    return new IO(fp.flowRight(fn, this._value))
  }
}


// test
// node执行环境可以传一个process对象(进程)
// 调用of的时候把当前取值的过程包装到函数里面,再在需要的时候再获取process
const r = IO.of(process)
  // map需要传入一个函数,函数需要接收一个参数,这个参数就是of中传递的参数process
  // 返回一下process中的execPath属性即当前node进程的执行路径
  .map(p => p.execPath)
console.log(r) // IO { _value: [Function] }


// 上面只是组合函数,如果需要调用就执行下面
console.log(r._value()) // C:\Program Files\nodejs\node.exe
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

# Task函子(异步执行)

  • 函子可以控制副作用,还可以处理异步任务,为了避免地狱之门。
  • 异步任务的实现过于复杂,我们使用 folktale 中的 Task 来演示
  • folktale (opens new window) 一个标准的函数式编程库。和 lodash、ramda 不同的是,他没有提供很多功能函数。只提供了一些函数式处理的操作,例如:compose、curry 等,一些函子 Task、Either、 MayBe 等

# folktale的安装

首先安装folktale的库

npm i folktale
1

# folktale中的curry函数

const { compose, curry } = require('folktale/core/lambda')

// curry中的第一个参数是函数有几个参数,为了避免一些错误
const f = curry(2, (x, y) => x + y)

console.log(f(1, 2)) // 3
console.log(f(1)(2)) // 3
1
2
3
4
5
6
7

# folktale中的compose函数

const { compose, curry } = require('folktale/core/lambda')
const { toUpper, first } = require('lodash/fp')

// compose 组合函数在lodash里面是flowRight
const r = compose(toUpper, first)
console.log(r(['one', 'two']))  // ONE
1
2
3
4
5
6

# Task函子异步执行

  • folktale(2.3.2) 2.x 中的 Task 和 1.0 中的 Task 区别很大,1.0 中的用法更接近我们现在演示的 函子
  • 这里以 2.3.2 来演示
const { task } = require('folktale/concurrency/task')
const fs = require('fs')
// 2.0中是一个函数,函数返回一个函子对象
// 1.0中是一个类

//读取文件
function readFile (filename) {
  // task传递一个函数,参数是resolver
  // resolver里面有两个参数,一个是reject失败的时候执行的,一个是resolve成功的时候执行的
  return task(resolver => {
    //node中读取文件,第一个参数是路径,第二个是编码,第三个是回调,错误在先
    fs.readFile(filename, 'utf-8', (err, data) => {
      if(err) resolver.reject(err)
      resolver.resolve(data)
    })
  })
}

//演示一下调用
// readFile调用返回的是Task函子,调用要用run方法
readFile('package.json')
  .run()
  // 现在没有对resolve进行处理,可以使用task的listen去监听获取的结果
  // listen传一个对象,onRejected是监听错误结果,onResolved是监听正确结果
  .listen({
    onRejected: (err) => {
      console.log(err)
    },
    onResolved: (value) => {
      console.log(value)
    }
  })
 
 /** {
    "name": "Functor",
    "version": "1.0.0",
    "description": "",
    "main": "either.js",
    "scripts": {
      "test": "echo \"Error: no test specified\" && exit 1"
    },
    "keywords": [],
    "author": "",
    "license": "ISC",
    "dependencies": {
      "folktale": "^2.3.2",
      "lodash": "^4.17.20"
    }
  }
  */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

# 案例

在package.json文件中提取一下version字段

const { task } = require('folktale/concurrency/task')
const fs = require('fs')
const { split, find } = require('lodash/fp')
// 2.0中是一个函数,函数返回一个函子对象
// 1.0中是一个类

//读取文件
function readFile (filename) {
  // task传递一个函数,参数是resolver
  // resolver里面有两个参数,一个是reject失败的时候执行的,一个是resolve成功的时候执行的
  return task(resolver => {
    //node中读取文件,第一个参数是路径,第二个是编码,第三个是回调,错误在先
    fs.readFile(filename, 'utf-8', (err, data) => {
      if(err) resolver.reject(err)
      resolver.resolve(data)
    })
  })
}

//演示一下调用
// readFile调用返回的是Task函子,调用要用run方法
readFile('package.json')
  //在run之前调用map方法,在map方法中会处理的拿到文件返回结果
  // 在使用函子的时候就没有必要想的实现机制
  .map(split('\n'))
  .map(find(x => x.includes('version')))
  .run()
  // 现在没有对resolve进行处理,可以使用task的listen去监听获取的结果
  // listen传一个对象,onRejected是监听错误结果,onResolved是监听正确结果
  .listen({
    onRejected: (err) => {
      console.log(err)
    },
    onResolved: (value) => {
      console.log(value) // "version": "1.0.0",
    }
  })

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

# Pointed函子

  • Pointed 函子是实现了 of 静态方法的函子 of 方法是为了避免使用 new 来创建对象,更深层的含义是of 方法用来把值放到上下文
  • Context(把值放到容器中,使用 map 来处理值)
class Container { 
// Point函子
// 作用是把值放到一个新的函子里面返回,返回的函子就是一个上下文
    static of (value) { 
        return new Container(value)
    }
    ……  
}

// 调用of的时候获得一个上下文,之后是在上下文中处理数据
Contanier.of(2)
 .map(x => x + 5)
1
2
3
4
5
6
7
8
9
10
11
12

# Monad函子(单子)

# IO函子的嵌套问题

  • 用来解决IO函子多层嵌套的一个问题
const fp = require('lodash/fp')
const fs = require('fs')

class IO {
  static of (value) {
    return new IO(() => {
      return value
    })
  }
  constructor (fn) {
    this._value = fn
  }

  map(fn) {
    return new IO(fp.flowRight(fn, this._value))
  }
}

//读取文件函数
let readFile = (filename) => {
  return new IO(() => {
    //同步获取文件
    return fs.readFileSync(filename, 'utf-8')
  })
}

//打印函数
// x是上一步的IO函子
let print = (x) => {
  return new IO(()=> {
    console.log(x)
    return x
  })
}

// 组合函数,先读文件再打印
let cat = fp.flowRight(print, readFile)
// 调用
// 拿到的结果是嵌套的IO函子 IO(IO(x))
let r = cat('package.json')
console.log(r) 
// IO { _value: [Function] }
console.log(cat('package.json')._value()) 
// IO { _value: [Function] }
// IO { _value: [Function] }
console.log(cat('package.json')._value()._value())
// IO { _value: [Function] }
/**
 * {
  "name": "Functor",
  "version": "1.0.0",
  "description": "",
  "main": "either.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "folktale": "^2.3.2",
    "lodash": "^4.17.20"
  }
}
 */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65

上面遇到多个IO函子嵌套的时候,那么_value就会调用很多次,这样的调用体验很不好。所以进行优化。

# 什么是Monad函子

  • Monad 函子是可以变扁的 Pointed 函子,用来解决IO函子嵌套问题,IO(IO(x))
  • 一个函子如果具有 join 和 of 两个方法并遵守一些定律就是一个 Monad

# 实现一个Monad函子

实际开发中不会这么难,主要是知道monad的实现

const fp = require('lodash/fp')
const fs = require('fs')

class IO {
  static of (value) {
    return new IO(() => {
      return value
    })
  }
  constructor (fn) {
    this._value = fn
  }

  map(fn) {
    return new IO(fp.flowRight(fn, this._value))
  }

  join () {
    return this._value()
  }

  // 同时调用map和join方法
  flatMap (fn) {
    return this.map(fn).join()
  }
}

let readFile = (filename) => {
  return new IO(() => {
    return fs.readFileSync(filename, 'utf-8')
  })
}

let print = (x) => {
  return new IO(()=> {
    console.log(x)
    return x
  })
}

let r = readFile('package.json')
          .flatMap(print)
          .join()     
// 执行顺序
/**
 * readFile读取了文件,然后返回了一个IO函子
 * 调用flatMap是用readFile返回的IO函子调用的
 * 并且传入了一个print函数参数
 * 调用flatMap的时候,内部先调用map,当前的print和this._value进行合并,合并之后返回了一个新的函子
 * (this._value就是readFile返回IO函子的函数:
 *      () => {
          return fs.readFileSync(filename, 'utf-8')
        }
 * )
 * flatMap中的map函数执行完,print函数返回的一个IO函子,里面包裹的还是一个IO函子
 * 下面调用join函数,join函数就是调用返回的新函子内部的this._value()函数
 * 这个this._value就是之前print和this._value的组合函数,调用之后返回的就是print的返回结果
 * 所以flatMap执行完毕之后,返回的就是print函数返回的IO函子
 *  */
 
 r = readFile('package.json')
        // 处理数据,直接在读取文件之后,使用map进行处理即可
        .map(fp.toUpper)
        .flatMap(print)
        .join()  

// 读完文件之后想要处理数据,怎么办?
// 直接在读取文件之后调用map方法即可

/**
 * {
  "NAME": "FUNCTOR",
  "VERSION": "1.0.0",
  "DESCRIPTION": "",
  "MAIN": "EITHER.JS",
  "SCRIPTS": {
    "TEST": "ECHO \"ERROR: NO TEST SPECIFIED\" && EXIT 1"
  },
  "KEYWORDS": [],
  "AUTHOR": "",
  "LICENSE": "ISC",
  "DEPENDENCIES": {
    "FOLKTALE": "^2.3.2",
    "LODASH": "^4.17.20"
  }
}
 */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87

# Monad函子小结

# 什么是Monad?

具有静态的IO方法和join方法的函子

# 什么时候使用Monad?
  • 当一个函数返回一个函子的时候,我们就要想到monad,monad可以帮我们解决函子嵌套的问题。
  • 当我们想要返回一个函数,这个函数返回一个值,这个时候可以调用map 方法
  • 当我们想要去合并一个函数,但是这个函数返回一个函子,这个时候我们要用flatMap 方法
更新时间: 2021-09-15 12:03