四、Talk is cheap!Show me the … MONEY!
以下内容主要参考自 Professor Frisby Introduces Composable Functional JavaScript
4.1.容器(Box)
假设有个函数,可以接收一个来自用户输入的数字字符串。我们需要对其预处理一下,去除多余空格,将其转换为数字并加一,最后返回该值对应的字母。代码大概长这样…
const nextCharForNumStr = (str) =>
String.fromCharCode(parseInt(str.trim()) + 1)
nextCharForNumStr(' 64 ') // "A"
因缺思厅,这代码嵌套的也太紧凑了,看多了“老阔疼”,赶紧重构一把…
const nextCharForNumStr = (str) => {
const trimmed = str.trim()
const number = parseInt(trimmed)
const nextNumber = number + 1
return String.fromCharCode(nextNumber)
}
nextCharForNumStr(' 64 ') // 'A'
很显然,经过之前内容的熏(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']
这次借助数组的 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
此外创建一个容器,除了像函数一样直接传递参数以外,还可以使用静态方法 of
。
函数式编程一般约定,函子有一个 of 方法,用来生成新的容器。
Box(1) === Box.of(1)
其实这个 Box
就是一个函子(functor),因为它实现了 map
函数。当然你也可以叫它 Mappable
或者其他名称。
不过为了保持与范畴学定义的名称一致,我们就站在巨人的肩膀上不要再发明新名词啦~(后面小节的各种奇怪名词也是来源于数学名词)。
functor 是实现了 map 函数并遵守一些特定规则的容器类型。
那么这些特定的规则具体是什么咧?
** 1. 规则一:**
fx.map(f).map(g) === fx.map(x => f(g)(x))
这其实就是函数组合…
** 2. 规则二:**
const id = x => x
fx.map(id) === id(fx)
4.2.Either / Maybe
假设现在有个需求:获取对应颜色的十六进制的 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
以上代码在输入已有颜色的 key
值时运行良好,不过一旦传入其他颜色就会报错。咋办咧?
暂且不提条件判断和各种奇技淫巧的错误处理。咱们来先看看函数式的解决方案~
函数式将错误处理抽象成一个 Either
容器,而这个容器由两个子容器 Right
和 Left
组成。
// 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
可以看出 Right
和 Left
相似于 Box
:
- 最大的不同就是
fold
函数,这里需要传两个回调函数,左边的给Left
使用,右边的给Right
使用。 - 其次就是
Left
的map
函数忽略了传入的函数(因为出错了嘛,当然不能继续执行啦)。
现在让我们回到之前的问题来~
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
从以上代码不知道各位读者老爷们有没有看出使用 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()
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)
啊,常规操作,看起来不错哟~
不错你个蛇头…!
以上代码有个 bug
,当 json
文件写的有问题时,在 JSON.parse
时会出错,所以这步也要用 tryCatch
包起来。
但是,问题来了…
返回值这时候可能是 Right(Right(''))
或者 Right(Left(e))
(想想为什么不是 Left(Right(''))
或者 Left(Left(e)))
。
也就是说我们现在得到的是两层容器,就像俄罗斯套娃一样…
要取出容器中的容器中的值,我们就需要 fold
两次…!(若是再多几层…)
<img height=“400” alt=“dog” src=“https://buptsteve.github.io/blog/imgs//fp-in-js/dog.jpg”>
因缺思厅,所以聪明机智的函数式又想出一个新方法 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(c => tryCatch(() => JSON.parse(c))) // 使用 chain 和 tryCatch
.fold(
e => 3000,
c => c.port
)
其实这里的 Left
和 Right
就是单子(Monad),因为它实现了 chain
函数。
monad 是实现了 chain 函数并遵守一些特定规则的容器类型。
在继续介绍这些特定规则前,我们先定义一个 join
函数:
// 这里的 m 指的是一种 Monad 实例
const join = m => m.chain(x => x)
- 规则一:
join(m.map(join)) === join(join(m))
- 规则二:
// 这里的 M 指的是一种 Monad 类型
join(M.of(m)) === join(m.map(M.of))
这条规则说明了 map
可被 chain
和 of
所定义。
m.map(f) === m.chain(x => M.of(f(x)))
也就是说 Monad
一定是 Functor
Monad
十分强大,之后我们将利用它处理各种副作用。但别对其感到困惑,chain
的主要作用不过将两种不同的类型连接(join
)在一起罢了。
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]))
虽然理论上对于 <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)
除此之外,<Boolean, &&>
也满足半群的定义~
const All = (x) => ({
x,
concat: ({ x: y }) => All(x && y), // 采用解构获取值
inspect: () => `All(${x})`,
})
All(true)
.concat(All(false))
.inspect() // All(false)
最后,让我们对于字符串创建一个新的半群 First,顾名思义,它会忽略除了第一个参数以外的内容。
const First = (x) => ({
x,
concat: () => First(x), // 忽略后续的值
inspect: () => `First(${x})`,
})
First('blah')
.concat(First('yoyoyo'))
.inspect() // First('blah')
咿呀哟?是不是感觉这个半群和其他半群好像有点儿不太一样,不过具体是啥又说不上来…?
这个问题留给下个小节。在此先说下这玩意儿有啥用。
const data1 = {
name: 'steve',
isPaid: true,
points: 10,
friends: ['jame'],
}
const data2 = {
name: 'steve',
isPaid: false,
points: 2,
friends: ['young'],
}
假设有两个数据,需要将其合并,那么利用半群,我们可以对 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'],
}
*/
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!!!
从以上代码可以看出幺半群比半群要安全得多,
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)]) // 传入幺半群实例
所以对于思维高度抽象的函数式来说,这样的代码肯定是需要继续重构精简的~
2.List、Map
在讲解如何重构之前,先介绍两个炒鸡常用的不可变数据结构:List
、Map
。
顾名思义,正好对应原生的 Array
和 Object
。
3.利用 List、Map 重构
因为 immutable
库中的 List
和 Map
并没有 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
这样一来上一节的代码就可以精简成这样:
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)
4.利用 foldMap 重构
注意到 map
和 fold
这两步操作,从逻辑上来说是一个操作,所以我们可以新增 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
所以最终版长这样:
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)
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
4.8.Task
1.基本介绍
有了上一节中 LazyBox
的基础之后,接下来我们来创建一个新的类型 Task。
首先 Task
的构造函数可以接收一个函数以便延迟计算,当然也可以用 of
方法来创建实例,很自然的也有 map
、chain
、concat
、empty
等方法。
与众不同的是它有个 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
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)
3.原理意义
上面的代码乍一看好像没啥用,只不过是把待执行的代码用函数包起来了嘛,这还能吹上天?
还记得前面章节说到的副作用么?虽然说使用纯函数是没有副作用的,但是日常项目中有各种必须处理的副作用。
所以我们将有副作用的代码给包起来之后,这些新函数就都变成了纯函数,这样我们的整个应用的代码都是纯的~,并且在代码真正执行前(fork
前)还可以不断地 compose
别的函数,为我们的应用不断添加各种功能,这样一来整个应用的代码流程都会十分的简洁漂亮。
4.异步嵌套示例
以下代码做了 3 件事:
- 读取 config1.json 中的数据
- 将内容中的 8 替换成 6
- 将新内容写到 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!')
})
})
)
让我们用 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}`)
)
代码一目了然,按照线性的先后顺序完成了任务,并且在其中还可以随意地插入或修改需求~
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)
现在我们有了一个容器,它的内部值为局部调用(partially applied)后的函数。接着我们想让它应用到 Box(3)
上,最后得到 Box(5)
的预期结果。
说到从容器中取值,那肯定第一个想到 chain
方法,让我们来试一下:
Box(2)
.chain(x => Box(3).map(add(x)))
.inspect() // Box(5)
成功实现~,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)
运算规则
F(x).map(f) === F(f).ap(F(x))
// 这就是为什么
Box(2).map(add) === Box(add).ap(Box(2))
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 = ...
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)
- 例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'))
- 例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
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'))
然而我们想得到的是一个包含数组的 Task([file1, file2])
,这样就可以调用它的 fork
方法,查看执行结果。
为了解决这个问题,函数式编程一般用一个叫做 traverse
的方法来实现。
files
.traverse(Task.of, file => readFile(file, 'utf-8'))
.fork(console.error, console.log)
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)
)
}
看着有点儿晕?
不急,首先看代码主体是一个 reduce
,这个很熟了,就是从左到右遍历元素,其中的第二个参数传递的就是幺半群(monoid)的单位元(empty)。
再看第一个参数,主要就是通过 applicative functor
调用 ap
方法,再将其执行结果使用 concat
方法合并到数组中。
所以最后返回的就是 Task([foo, bar])
,因此我们可以调用 fork
方法执行它。
4.11.自然变换(Natural Transformations)
1.基本概念
自然变换就是一个函数,接受一个函子(functor),返回另一个函子。看看代码熟悉下~
const boxToEither = b => b.fold(Right)
这个 boxToEither
函数就是一个自然变换(nt),它将函子 Box
转换成了另一个函子 Either
。
那么我们用
Left
行不行呢?
答案是不行!
因为自然变换不仅是将一个函子转换成另一个函子,它还满足以下规则:
nt(x).map(f) == nt(x.map(f))
举例来说就是:
const res1 = boxToEither(Box(100))
.map(x => x * 2)
const res2 = boxToEither(
Box(100).map(x => x * 2)
)
res1 === res2 // Right(200)
即先对函子 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)
)
根据自然变换,它显然和 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')
)
)
)
}
// 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)
))
)
这是什么鬼???
肯定不能这么干…
// 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,
)
第二版的问题是多余的嵌套代码。
// 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,
)
第三版的问题是多余的重复逻辑。
// 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,
)
4.12.同构(Isomorphism)
同构是在数学对象之间定义的一类映射,它能揭示出在这些对象的属性或者操作之间存在的关系。
简单来说就是两种不同类型的对象经过变形,保持结构并且不丢失数据。
具体怎么做到的呢?
其实同构就是一对儿函数:to
和 from
,遵守以下规则:
to(from(x)) === x
from(to(y)) === y
这其实说明了这两个类型都能够无损地保存同样的信息。
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
这能有啥用呢?
const truncate = (str) => (
chars.from(
// 我们先用 to 方法将其转成数组
// 这样就能使用数组的各类方法
chars.to(str).slice(0, 3)
).concat('...')
)
truncate(str) // hel...
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)
参考资料
- JS函数式编程指南
- Pointfree 编程风格指南
- Hey Underscore, You’re Doing It Wrong!
- Functional Concepts with JavaScript: Part I
- Professor Frisby Introduces Composable Functional JavaScript
- 函数式编程入门教程
- What are Functional Programming,
Monad, Monoid, Applicative, Functor ??
以上 to be continued…
搬运自本人 blog https://github.com/BuptStEve/blog
js 函数功能强大
然而经过改造后,执行耗时是原来的3倍……
Vanilla JS 世界上最快速、最轻量、完美跨平台的 JavaScript 框架,没有之一。