slide 地址 (opens new window)

# 四、Talk is cheap!Show me the ... MONEY!

以下内容主要参考自 Professor Frisby Introduces Composable Functional JavaScript (opens new window)

show-me-the-money

# 4.1.容器(Box)

假设有个函数,可以接收一个来自用户输入的数字字符串。我们需要对其预处理一下,去除多余空格,将其转换为数字并加一,最后返回该值对应的字母。代码大概长这样...

const nextCharForNumStr = (str) =>
  String.fromCharCode(parseInt(str.trim()) + 1)

nextCharForNumStr(' 64 ') // "A"
1
2
3
4

因缺思厅,这代码嵌套的也太紧凑了,看多了“老阔疼”,赶紧重构一把...

五官太紧凑
const nextCharForNumStr = (str) => {
  const trimmed = str.trim()
  const number = parseInt(trimmed)
  const nextNumber = number + 1
  return String.fromCharCode(nextNumber)
}

nextCharForNumStr(' 64 ') // 'A'
1
2
3
4
5
6
7
8

很显然,经过之前内容的熏(xi)陶(nao),一眼就可以看出这个修订版代码很不 Pointfree...

为了这些只用一次的中间变量还要去想或者去查翻译,也是容易“老阔疼”,再改再改~

老阔疼
const nextCharForNumStr = (str) => [str]
  .map(s => s.trim())
  .map(s => parseInt(s))
  .map(i => i + 1)
  .map(i => String.fromCharCode(i))

nextCharForNumStr(' 64 ') // ['A']
1
2
3
4
5
6
7

这次借助数组的 map 方法,我们将必须的4个步骤拆分成了4个小函数。

这样一来再也不用去想中间变量的名称到底叫什么,而且每一步做的事情十分的清晰,一眼就可以看出这段代码在干嘛。

我们将原本的字符串变量 str 放在数组中变成了 [str],这里就像放在一个容器里一样。

代码是不是感觉好 door~~ 了?

稳

不过在这里我们可以更进一步,让我们来创建一个新的类型 Box。我们将同样定义 map 方法,让其实现同样的功能。

const Box = (x) => ({
  map: f => Box(f(x)),        // 返回容器为了链式调用
  fold: f => f(x),            // 将元素从容器中取出
  inspect: () => `Box(${x})`, // 看容器里有啥
})

const nextCharForNumStr = (str) => Box(str)
  .map(s => s.trim())
  .map(i => parseInt(i))
  .map(i => i + 1)
  .map(i => String.fromCharCode(i))
  .fold(c => c.toLowerCase()) // 可以轻易地继续调用新的函数

nextCharForNumStr(' 64 ') // a
1
2
3
4
5
6
7
8
9
10
11
12
13
14

此外创建一个容器,除了像函数一样直接传递参数以外,还可以使用静态方法 of

函数式编程一般约定,函子有一个 of 方法,用来生成新的容器。

Box(1) === Box.of(1)
1

其实这个 Box 就是一个函子(functor),因为它实现了 map 函数。当然你也可以叫它 Mappable 或者其他名称。

不过为了保持与范畴学定义的名称一致,我们就站在巨人的肩膀上不要再发明新名词啦~(后面小节的各种奇怪名词也是来源于数学名词)。

functor 是实现了 map 函数并遵守一些特定规则的容器类型。

那么这些特定的规则具体是什么咧?

** 1. 规则一:**

fx.map(f).map(g) === fx.map(x => g(f(x)))
1

这其实就是函数组合...

** 2. 规则二:**

const id = x => x

fx.map(id) === id(fx)
1
2
3
diagram-functor

# 4.2.Either / Maybe

cat

假设现在有个需求:获取对应颜色的十六进制的 RGB 值,并返回去掉#后的大写值。

const findColor = (name) => ({
  red: '#ff4444',
  blue: '#3b5998',
  yellow: '#fff68f',
})[name]

const redColor = findColor('red')
  .slice(1)
  .toUpperCase() // FF4444

const greenColor = findColor('green')
  .slice(1)
  .toUpperCase()
// Uncaught TypeError:
// Cannot read property 'slice' of undefined
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

以上代码在输入已有颜色的 key 值时运行良好,不过一旦传入其他颜色就会报错。咋办咧?

暂且不提条件判断和各种奇技淫巧的错误处理。咱们来先看看函数式的解决方案~

函数式将错误处理抽象成一个 Either 容器,而这个容器由两个子容器 RightLeft 组成。

// Either 由 Right 和 Left 组成

const Left = (x) => ({
  map: f => Left(x),            // 忽略传入的 f 函数
  fold: (f, g) => f(x),         // 使用左边的函数
  inspect: () => `Left(${x})`,  // 看容器里有啥
})

const Right = (x) => ({
  map: f => Right(f(x)),        // 返回容器为了链式调用
  fold: (f, g) => g(x),         // 使用右边的函数
  inspect: () => `Right(${x})`, // 看容器里有啥
})

// 来测试看看~
const right = Right(4)
  .map(x => x * 7 + 1)
  .map(x => x / 2)

right.inspect() // Right(14.5)
right.fold(e => 'error', x => x) // 14.5

const left = Left(4)
  .map(x => x * 7 + 1)
  .map(x => x / 2)

left.inspect() // Left(4)
left.fold(e => 'error', x => x) // error
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

可以看出 RightLeft 相似于 Box

  • 最大的不同就是 fold 函数,这里需要传两个回调函数,左边的给 Left 使用,右边的给 Right 使用。
  • 其次就是 Leftmap 函数忽略了传入的函数(因为出错了嘛,当然不能继续执行啦)。

现在让我们回到之前的问题来~

const fromNullable = (x) => x == null
  ? Left(null)
  : Right(x)

const findColor = (name) => fromNullable(({
  red: '#ff4444',
  blue: '#3b5998',
  yellow: '#fff68f',
})[name])

findColor('green')
  .map(c => c.slice(1))
  .fold(
    e => 'no color',
    c => c.toUpperCase()
  ) // no color
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

从以上代码不知道各位读者老爷们有没有看出使用 Either 的好处,那就是可以放心地对于这种类型的数据进行任何操作,而不是在每个函数里面小心翼翼地进行参数检查。

# 4.3.Chain / FlatMap / bind / >>=

假设现在有个 json 文件里面保存了端口,我们要读取这个文件获取端口,要是出错了返回默认值 3000。

// config.json
{ "port": 8888 }

// chain.js
const fs = require('fs')

const getPort = () => {
  try {
    const str = fs.readFileSync('config.json')
    const { port } = JSON.parse(str)
    return port
  } catch(e) {
    return 3000
  }
}

const result = getPort()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

so easy~,下面让我们来用 Either 来重构下看看效果。

const fs = require('fs')

const Left = (x) => ({ ... })
const Right = (x) => ({ ... })

const tryCatch = (f) => {
  try {
    return Right(f())
  } catch (e) {
    return Left(e)
  }
}

const getPort = () => tryCatch(
    () => fs.readFileSync('config.json')
  )
  .map(c => JSON.parse(c))
  .fold(e => 3000, c => c.port)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

啊,常规操作,看起来不错哟~

不错你个蛇头...!

以上代码有个 bug,当 json 文件写的有问题时,在 JSON.parse 时会出错,所以这步也要用 tryCatch 包起来。

但是,问题来了...

返回值这时候可能是 Right(Right('')) 或者 Right(Left(e))(想想为什么不是 Left(Right('')) 或者 Left(Left(e)))

也就是说我们现在得到的是两层容器,就像俄罗斯套娃一样...

要取出容器中的容器中的值,我们就需要 fold 两次...!(若是再多几层...)

dog

因缺思厅,所以聪明机智的函数式又想出一个新方法 chain~,其实很简单,就是我知道这里要返回容器了,那就不要再用容器包了呗。

...

const Left = (x) => ({
  ...
  chain: f => Left(x) // 和 map 一样,直接返回 Left
})

const Right = (x) => ({
  ...
  chain: f => f(x),   // 直接返回,不使用容器再包一层了
})

const tryCatch = (f) => { ... }

const getPort = () => tryCatch(
    () => fs.readFileSync('config.json')
  )
  // 使用 chain 和 tryCatch
  .chain(c => tryCatch(() => JSON.parse(c)))
  .fold(
    e => 3000,
    c => c.port
  )
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

其实这里的 LeftRight 就是单子(Monad),因为它实现了 chain 函数。

monad 是实现了 chain 函数并遵守一些特定规则的容器类型。

在继续介绍这些特定规则前,我们先定义一个 join 函数:

// 这里的 m 指的是一种 Monad 实例
const join = m => m.chain(x => x)
1
2
  1. 规则一:
join(m.map(join)) === join(join(m)))
1
  1. 规则二:
// 这里的 M 指的是一种 Monad 类型
join(M.of(m)) === join(m.map(M.of))
1
2

这条规则说明了 map 可被 chainof 所定义。

m.map(f) === m.chain(x => M.of(f(x)))
1

也就是说 Monad 一定是 Functor

Monad 十分强大,之后我们将利用它处理各种副作用。但别对其感到困惑,chain 的主要作用不过将两种不同的类型连接(join)在一起罢了。

diagram-monad

# 4.4.半群(Semigroup)

定义一:对于非空集合 S,若在 S 上定义了二元运算 ○,使得对于任意的 a, b ∈ S,有 a ○ b ∈ S,则称 {S, ○} 为广群。

定义二:若 {S, ○} 为广群,且运算 ○ 还满足结合律,即:任意 a, b, c ∈ S,有 (a ○ b) ○ c = a ○ (b ○ c),则称 {S, ○} 为半群。

举例来说,JavaScript 中有 concat 方法的对象都是半群。

// 字符串和 concat 是半群
'1'.concat('2').concat('3') === '1'.concat('2'.concat('3'))

// 数组和 concat 是半群
[1].concat([2]).concat([3]) === [1].concat([2].concat([3]))
1
2
3
4
5

虽然理论上对于 <Number, +> 来说它符合半群的定义:

  • 数字相加返回的仍然是数字(广群)
  • 加法满足结合律(半群)

但是数字并没有 concat 方法

没事儿,让我们来实现这个由 <Number, +> 组成的半群 Sum。

const Sum = (x) => ({
  x,
  concat: ({ x: y }) => Sum(x + y), // 采用解构获取值
  inspect: () => `Sum(${x})`,
})

Sum(1)
  .concat(Sum(2))
  .inspect() // Sum(3)
1
2
3
4
5
6
7
8
9

除此之外,<Boolean, &&> 也满足半群的定义~

const All = (x) => ({
  x,
  concat: ({ x: y }) => All(x && y), // 采用解构获取值
  inspect: () => `All(${x})`,
})

All(true)
  .concat(All(false))
  .inspect() // All(false)
1
2
3
4
5
6
7
8
9

最后,让我们对于字符串创建一个新的半群 First,顾名思义,它会忽略除了第一个参数以外的内容。

const First = (x) => ({
  x,
  concat: () => First(x), // 忽略后续的值
  inspect: () => `First(${x})`,
})

First('blah')
  .concat(First('yoyoyo'))
  .inspect() // First('blah')
1
2
3
4
5
6
7
8
9

咿呀哟?是不是感觉这个半群和其他半群好像有点儿不太一样,不过具体是啥又说不上来...?

这个问题留给下个小节。在此先说下这玩意儿有啥用。

const data1 = {
  name: 'steve',
  isPaid: true,
  points: 10,
  friends: ['jame'],
}
const data2 = {
  name: 'steve',
  isPaid: false,
  points: 2,
  friends: ['young'],
}
1
2
3
4
5
6
7
8
9
10
11
12

假设有两个数据,需要将其合并,那么利用半群,我们可以对 name 应用 First,对于 isPaid 应用 All,对于 points 应用 Sum,最后的 friends 已经是半群了...

const Sum = (x) => ({ ... })
const All = (x) => ({ ... })
const First = (x) => ({ ... })

const data1 = {
  name: First('steve'),
  isPaid: All(true),
  points: Sum(10),
  friends: ['jame'],
}
const data2 = {
  name: First('steve'),
  isPaid: All(false),
  points: Sum(2),
  friends: ['young'],
}

const concatObj = (obj1, obj2) => Object.entries(obj1)
  .map(([ key, val ]) => ({
    // concat 两个对象的值
    [key]: val.concat(obj2[key]),
  }))
  .reduce((acc, cur) => ({ ...acc, ...cur }))

concatObj(data1, data2)
/*
  {
    name: First('steve'),
    isPaid: All(false),
    points: Sum(12),
    friends: ['jame', 'young'],
  }
*/
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

# 4.5.幺半群(Monoid)

幺半群是一个存在单位元(幺元)的半群。

半群我们都懂,不过啥是单位元?

单位元:对于半群 <S, ○>,存在 e ∈ S,使得任意 a ∈ S 有 a ○ e = e ○ a

举例来说,对于数字加法这个半群来说,0就是它的单位元,所以 <Number, +, 0> 就构成一个幺半群。同理:

  • 对于 <Number, *> 来说单位元就是 1
  • 对于 <Boolean, &&> 来说单位元就是 true
  • 对于 <Boolean, ||> 来说单位元就是 false
  • 对于 <Number, Min> 来说单位元就是 Infinity
  • 对于 <Number, Max> 来说单位元就是 -Infinity

那么 <String, First> 是幺半群么?

显然我们并不能找到这样一个单位元 e 满足

First(e).concat(First('steve')) === First('steve').concat(First(e))

这就是上一节留的小悬念,为何会感觉 First 与 Sum 和 All 不太一样的原因。

格叽格叽,这两者有啥具体的差别么?

其实看到幺半群的第一反应应该是默认值或初始值,例如 reduce 函数的第二个参数就是传入一个初始值或者说是默认值。

// sum
const Sum = (x) => ({ ... })
Sum.empty = () => Sum(0) // 单位元

const sum = xs => xs.reduce((acc, cur) => acc + cur, 0)

sum([1, 2, 3])  // 6
sum([])         // 0,而不是报错!

// all
const All = (x) => ({ ... })
All.empty = () => All(true) // 单位元

const all = xs => xs.reduce((acc, cur) => acc && cur, true)

all([true, false, true]) // false
all([])                  // true,而不是报错!

// first
const First = (x) => ({ ... })

const first = xs => xs.reduce(acc, cur) => acc)

first(['steve', 'jame', 'young']) // steve
first([])                         // boom!!!
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

从以上代码可以看出幺半群比半群要安全得多,

# 4.6.foldMap

# 1.套路

在上一节中幺半群的使用代码中,如果传入的都是幺半群实例而不是原始类型的话,你会发现其实都是一个套路...

const Monoid = (x) => ({ ... })

const monoid = xs => xs.reduce(
    (acc, cur) => acc.concat(cur),  // 使用 concat 结合
    Monoid.empty()                  // 传入幺元
)

monoid([Monoid(a), Monoid(b), Monoid(c)]) // 传入幺半群实例
1
2
3
4
5
6
7
8

所以对于思维高度抽象的函数式来说,这样的代码肯定是需要继续重构精简的~

# 2.List、Map

在讲解如何重构之前,先介绍两个炒鸡常用的不可变数据结构:ListMap

顾名思义,正好对应原生的 ArrayObject

# 3.利用 List、Map 重构

因为 immutable 库中的 ListMap 并没有 empty 属性和 fold 方法,所以我们首先扩展 List 和 Map~

import { List, Map } from 'immutable'

const derived = {
  fold (empty) {
    return this.reduce((acc, cur) => acc.concat(cur), empty)
  },
}

List.prototype.empty = List()
List.prototype.fold = derived.fold

Map.prototype.empty = Map({})
Map.prototype.fold = derived.fold

// from https://github.com/DrBoolean/immutable-ext
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

这样一来上一节的代码就可以精简成这样:

List.of(1, 2, 3)
  .map(Sum)
  .fold(Sum.empty())     // Sum(6)

List().fold(Sum.empty()) // Sum(0)

Map({ steve: 1, young: 3 })
  .map(Sum)
  .fold(Sum.empty())     // Sum(4)

Map().fold(Sum.empty())  // Sum(0)
1
2
3
4
5
6
7
8
9
10
11

# 4.利用 foldMap 重构

注意到 mapfold 这两步操作,从逻辑上来说是一个操作,所以我们可以新增 foldMap 方法来结合两者。

import { List, Map } from 'immutable'

const derived = {
  fold (empty) {
    return this.foldMap(x => x, empty)
  },
  foldMap (f, empty) {
    return empty != null
      // 幺半群中将 f 的调用放在 reduce 中,提高效率
      ? this.reduce(
          (acc, cur, idx) =>
            acc.concat(f(cur, idx)),
          empty
      )
      : this
        // 在 map 中调用 f 是因为考虑到空的情况
        .map(f)
        .reduce((acc, cur) => acc.concat(cur))
  },
}

List.prototype.empty = List()
List.prototype.fold = derived.fold
List.prototype.foldMap = derived.foldMap

Map.prototype.empty = Map({})
Map.prototype.fold = derived.fold
Map.prototype.foldMap = derived.foldMap

// from https://github.com/DrBoolean/immutable-ext
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

所以最终版长这样:

List.of(1, 2, 3)
  .foldMap(Sum, Sum.empty()) // Sum(6)
List()
  .foldMap(Sum, Sum.empty()) // Sum(0)

Map({ a: 1, b: 3 })
  .foldMap(Sum, Sum.empty()) // Sum(4)
Map()
  .foldMap(Sum, Sum.empty()) // Sum(0)
1
2
3
4
5
6
7
8
9

# 4.7.LazyBox

下面我们要来实现一个新容器 LazyBox

顾名思义,这个容器很懒...

虽然你可以不停地用 map 给它分配任务,但是只要你不调用 fold 方法催它执行(就像 deadline 一样),它就死活不执行...

const LazyBox = (g) => ({
  map: f => LazyBox(() => f(g())),
  fold: f => f(g()),
})

const result = LazyBox(() => ' 64 ')
  .map(s => s.trim())
  .map(i => parseInt(i))
  .map(i => i + 1)
  .map(i => String.fromCharCode(i))
  // 没有 fold 死活不执行

result.fold(c => c.toLowerCase()) // a
1
2
3
4
5
6
7
8
9
10
11
12
13

# 4.8.Task

# 1.基本介绍

有了上一节中 LazyBox 的基础之后,接下来我们来创建一个新的类型 Task。

首先 Task 的构造函数可以接收一个函数以便延迟计算,当然也可以用 of 方法来创建实例,很自然的也有 mapchainconcatempty 等方法。

与众不同的是它有个 fork 方法(类似于 LazyBox 中的 fold 方法,在 fork 执行前其他函数并不会执行),以及一个 rejected 方法,类似于 Left,忽略后续的操作。

import Task from 'data.task'

const showErr = e => console.log(`err: ${e}`)
const showSuc = x => console.log(`suc: ${x}`)

Task
  .of(1)
  .fork(showErr, showSuc) // suc: 1

Task
  .of(1)
  .map(x => x + 1)
  .fork(showErr, showSuc) // suc: 2

// 类似 Left
Task
  .rejected(1)
  .map(x => x + 1)
  .fork(showErr, showSuc) // err: 1

Task
  .of(1)
  .chain(x => new Task.of(x + 1))
  .fork(showErr, showSuc) // suc: 2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# 2.使用示例

接下来让我们做一个发射飞弹的程序~

const lauchMissiles = () => (
  // 和 promise 很像,不过 promise 会立即执行
  // 而且参数的位置也相反
  new Task((rej, res) => {
    console.log('lauchMissiles')
    res('missile')
  })
)

// 继续对之前的任务添加后续操作(duang~给飞弹加特技!)
const app = lauchMissiles()
  .map(x => x + '!')

// 这时才执行(发射飞弹)
app.fork(showErr, showSuc)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 3.原理意义

上面的代码乍一看好像没啥用,只不过是把待执行的代码用函数包起来了嘛,这还能吹上天?

还记得前面章节说到的副作用么?虽然说使用纯函数是没有副作用的,但是日常项目中有各种必须处理的副作用。

所以我们将有副作用的代码给包起来之后,这些新函数就都变成了纯函数,这样我们的整个应用的代码都是纯的~,并且在代码真正执行前(fork 前)还可以不断地 compose 别的函数,为我们的应用不断添加各种功能,这样一来整个应用的代码流程都会十分的简洁漂亮。

side-effects

# 4.异步嵌套示例

以下代码做了 3 件事:

  1. 读取 config1.json 中的数据
  2. 将内容中的 8 替换成 6
  3. 将新内容写到 config2.json 中
import fs from 'fs'

const app = () => (
  fs.readFile('config1.json', 'utf-8', (err, contents) => {
    if (err) throw err

    const newContents = content.replace(/8/g, '6')

    fs.writeFile('config2.json', newContents, (err, _) => {
      if (err) throw err

      console.log('success!')
    })
  })
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

让我们用 Task 来改写一下~

import fs from 'fs'
import Task from 'data.task'

const cfg1 = 'config1.json'
const cfg2 = 'config2.json'

const readFile = (file, enc) => (
  new Task((rej, res) =>
    fs.readFile(file, enc, (err, str) =>
      err ? rej(err) : res(str)
    )
  )
)

const writeFile = (file, str) => (
  new Task((rej, res) =>
    fs.writeFile(file, str, (err, suc) =>
      err ? rej(err) : res(suc)
    )
  )
)

const app = readFile(cfg1, 'utf-8')
  .map(str => str.replace(/8/g, '6'))
  .chain(str => writeFile(cfg2, str))

app.fork(
  e => console.log(`err: ${e}`),
  x => console.log(`suc: ${x}`)
)
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

代码一目了然,按照线性的先后顺序完成了任务,并且在其中还可以随意地插入或修改需求~

# 4.9.Applicative Functor

# 1.问题引入

Applicative Functor 提供了让不同的函子(functor)互相应用的能力。

为啥我们需要函子的互相应用?什么是互相应用?

先来看个简单例子:

const add = x => y => x + y

add(Box.of(2))(Box.of(3)) // NaN

Box(2).map(add).inspect() // Box(y => 2 + y)
1
2
3
4
5

现在我们有了一个容器,它的内部值为局部调用(partially applied)后的函数。接着我们想让它应用到 Box(3) 上,最后得到 Box(5) 的预期结果。

说到从容器中取值,那肯定第一个想到 chain 方法,让我们来试一下:

Box(2)
  .chain(x => Box(3).map(add(x)))
  .inspect() // Box(5)
1
2
3

成功实现~,BUT,这种实现方法有个问题,那就是单子(Monad)的执行顺序问题。

我们这样实现的话,就必须等 Box(2) 执行完毕后,才能对 Box(3) 进行求值。假如这是两个异步任务,那么完全无法并行执行。

别慌,吃口药~

# 2.基本介绍

下面介绍下主角:ap~:

const Box = (x) => ({
  // 这里 box 是另一个 Box 的实例,x 是函数
  ap: box => box.map(x),
  ...
})

Box(add)
  // Box(y => 2 + y) ,咦?在哪儿见过?
  .ap(Box(2))
  .ap(Box(3)) // Box(5)
1
2
3
4
5
6
7
8
9
10

运算规则

F(x).map(f) === F(f).ap(F(x))

// 这就是为什么
Box(2).map(add) === Box(add).ap(Box(2))
1
2
3
4

# 3.Lift 家族

由于日常编写代码的时候直接用 ap 的话模板代码太多,所以一般通过使用 Lift 家族系列函数来简化。

// F 该从哪儿来?
const fakeLiftA2 = f => fx => fy => F(f).ap(fx).ap(fy)

// 应用运算规则转换一下~
const liftA2 = f => fx => fy => fx.map(f).ap(fy)

liftA2(add, Box(2), Box(4)) // Box(6)

// 同理
const liftA3 = f => fx => fy => fz => fx.map(f).ap(fy).ap(fz)
const liftA4 = ...
...
const liftAN = ...
1
2
3
4
5
6
7
8
9
10
11
12
13

# 4.Lift 应用

  • 例1
// 假装是个 jQuery 接口~
const $ = selector =>
  Either.of({ selector, height: 10 })

const getScreenSize = screen => head => foot =>
  screen - (head.height + foot.height)

liftA2(getScreenSize(800))($('header'))($('footer')) // Right(780)
1
2
3
4
5
6
7
8
  • 例2
// List 的笛卡尔乘积
List.of(x => y => z => [x, y, z].join('-'))
  .ap(List.of('tshirt', 'sweater'))
  .ap(List.of('white', 'black'))
  .ap(List.of('small', 'medium', 'large'))
1
2
3
4
5
  • 例3
const Db = ({
  find: (id, cb) =>
    new Task((rej, res) =>
      setTimeout(() => res({ id, ---
title: `${id}`}), 100)
    )
})

const reportHeader = (p1, p2) =>
  `Report: ${p1.title} compared to ${p2.title}`

Task.of(p1 => p2 => reportHeader(p1, p2))
  .ap(Db.find(20))
  .ap(Db.find(8))
  .fork(console.error, console.log) // Report: 20 compared to 8

liftA2
  (p1 => p2 => reportHeader(p1, p2))
  (Db.find(20))
  (Db.find(8))
  .fork(console.error, console.log) // Report: 20 compared to 8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 4.10.Traversable

# 1.问题引入

import fs from 'fs'

// 详见 4.8.
const readFile = (file, enc) => (
  new Task((rej, res) => ...)
)

const files = ['a.js', 'b.js']

// [Task, Task],我们得到了一个 Task 的数组
files.map(file => readFile(file, 'utf-8'))
1
2
3
4
5
6
7
8
9
10
11

然而我们想得到的是一个包含数组的 Task([file1, file2]),这样就可以调用它的 fork 方法,查看执行结果。

为了解决这个问题,函数式编程一般用一个叫做 traverse 的方法来实现。

files
  .traverse(Task.of, file => readFile(file, 'utf-8'))
  .fork(console.error, console.log)
1
2
3

traverse 方法第一个参数是创建函子的函数,第二个参数是要应用在函子上的函数。

# 2.实现

其实以上代码有 bug...,因为数组 Array 是没有 traverse 方法的。没事儿,让我们来实现一下~

Array.prototype.empty = []

// traversable
Array.prototype.traverse = function (point, fn) {
  return this.reduce(
    (acc, cur) => acc
      .map(z => y => z.concat(y))
      .ap(fn(cur)),
    point(this.empty)
  )
}
1
2
3
4
5
6
7
8
9
10
11

看着有点儿晕?

不急,首先看代码主体是一个 reduce,这个很熟了,就是从左到右遍历元素,其中的第二个参数传递的就是幺半群(monoid)的单位元(empty)。

再看第一个参数,主要就是通过 applicative functor 调用 ap 方法,再将其执行结果使用 concat 方法合并到数组中。

所以最后返回的就是 Task([foo, bar]),因此我们可以调用 fork 方法执行它。

# 4.11.自然变换(Natural Transformations)

# 1.基本概念

自然变换就是一个函数,接受一个函子(functor),返回另一个函子。看看代码熟悉下~

const boxToEither = b => b.fold(Right)
1

这个 boxToEither 函数就是一个自然变换(nt),它将函子 Box 转换成了另一个函子 Either

那么我们用 Left 行不行呢?

答案是不行!

因为自然变换不仅是将一个函子转换成另一个函子,它还满足以下规则:

nt(x).map(f) == nt(x.map(f))
1
natural_transformation

举例来说就是:

const res1 = boxToEither(Box(100))
  .map(x => x * 2)
const res2 = boxToEither(
  Box(100).map(x => x * 2)
)

res1 === res2 // Right(200)
1
2
3
4
5
6
7

即先对函子 a 做改变再将其转换为函子 b,是等价于先将函子 a 转换为函子 b 再做改变。

显然,Left 并不满足这个规则。所以任何满足这个规则的函数都是自然变换

# 2.应用场景

1.例1:得到一个数组小于等于 100 的最后一个数的两倍的值

const arr = [2, 400, 5, 1000]
const first = xs => fromNullable(xs[0])
const double = x => x * 2
const getLargeNums = xs => xs.filter(x => x > 100)

first(
  getLargeNums(arr).map(double)
)
1
2
3
4
5
6
7
8

根据自然变换,它显然和 first(getLargeNums(arr)).map(double) 是等价的。但是后者显然性能好得多。

再来看一个更复杂一点儿的例子:

2.例2:找到 id 为 3 的用户的最好的朋友的 id

// 假 api
const fakeApi = (id) => ({
  id,
  name: 'user1',
  bestFriendId: id + 1,
})

// 假 Db
const Db = {
  find: (id) => new Task(
    (rej, res) => (
      res(id > 2
        ? Right(fakeApi(id))
        : Left('not found')
      )
    )
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Task(Either(user))
const zero = Db.find(3)

// 第一版
// Task(Either(Task(Either(user)))) ???
const one = zero
  .map(either => either
    .map(user => Db
      .find(user.bestFriendId)
    )
  )
  .fork(
    console.error,
    either => either // Either(Task(Either(user)))
      .map(t => t.fork( // Task(Either(user))
        console.error,
        either => either
            .map(console.log), // Either(user)
      ))
  )
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
黑人问号4合一

这是什么鬼???

肯定不能这么干...

// Task(Either(user))
const zero = Db.find(3)

// 第二版
const two = zero
  .chain(either => either
    .fold(Task.rejected, Task.of) // Task(user)
    .chain(user => Db
      .find(user.bestFriendId) // Task(Either(user))
    )
    .chain(either => either
      .fold(Task.rejected, Task.of) // Task(user)
    )
  )
  .fork(
    console.error,
    console.log,
  )
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

第二版的问题是多余的嵌套代码。

// Task(Either(user))
const zero = Db.find(3)

// 第三版
const three = zero
  .chain(either => either
    .fold(Task.rejected, Task.of) // Task(user)
  )
  .chain(user => Db
    .find(user.bestFriendId) // Task(Either(user))
  )
  .chain(either => either
    .fold(Task.rejected, Task.of) // Task(user)
  )
  .fork(
    console.error,
    console.log,
  )
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

第三版的问题是多余的重复逻辑。

// Task(Either(user))
const zero = Db.find(3)

// 这其实就是自然变换
// 将 Either 变换成 Task
const eitherToTask = (e) => (
  e.fold(Task.rejected, Task.of)
)

// 第四版
const four = zero
  .chain(eitherToTask) // Task(user)
  .chain(user => Db
    .find(user.bestFriendId) // Task(Either(user))
  )
  .chain(eitherToTask) // Task(user)
  .fork(
    console.error,
    console.log,
  )

// 出错版
const error = Db.find(2) // Task(Either(user))
  // Task.rejected('not found')
  .chain(eitherToTask)
  // 这里永远不会被调用,被跳过了
  .chain(() => console.log('hey man'))
  ...
  .fork(
    console.error, // not found
    console.log,
  )
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

# 4.12.同构(Isomorphism)

同构是在数学对象之间定义的一类映射,它能揭示出在这些对象的属性或者操作之间存在的关系。

简单来说就是两种不同类型的对象经过变形,保持结构并且不丢失数据。

具体怎么做到的呢?

其实同构就是一对儿函数:tofrom,遵守以下规则:

to(from(x)) === x
from(to(y)) === y
1
2

这其实说明了这两个类型都能够无损地保存同样的信息。

# 1. 例如 String[Char] 就是同构的。

// String ~ [Char]
const Iso = (to, from) => ({ to, from })

const chars = Iso(
  s => s.split(''),
  c => c.join('')
)

const str = 'hello world'

chars.from(chars.to(str)) === str
1
2
3
4
5
6
7
8
9
10
11

这能有啥用呢?

const truncate = (str) => (
  chars.from(
    // 我们先用 to 方法将其转成数组
    // 这样就能使用数组的各类方法
    chars.to(str).slice(0, 3)
  ).concat('...')
)

truncate(str) // hel...
1
2
3
4
5
6
7
8
9

# 2. 再来看看最多有一个参数的数组 [a]Either 的同构关系

// [a] ~ Either null a
const singleton = Iso(
  e => e.fold(() => [], x => [x]),
  ([ x ]) => x ? Right(x) : Left()
)

const filterEither = (e, pred) => singleton
  .from(
    singleton
      .to(e)
      .filter(pred)
  )

const getUCH = (str) => filterEither(
  Right(str),
  x => x.match(/h/ig)
).map(x => x.toUpperCase())

getUCH('hello') // Right(HELLO)

getUCH('ello') // Left(undefined)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 参考资料

以上 to be continued...

TOC