从编码的角度看,范畴论在 JS 中的应用,本质上还是为了解决函数组合的问题。
Functor(函子):“盒子模式构造函数组合链”
若不借助compose/pipe
函数,还可以通过【构造一个能创造新盒子
的盒子】来构造声明式的数据流。
const Box = (x) => ({
map: (f) => Box(f(x)),
valueOf: () => x,
});
Box 函数的关键在于map
方法,它的入参为x
,并将f(x)
的计算结果成为下一个 Box 的入参x'
。
也就是说,map 方法可以把新的计算结果传递给下一个 Box。通过反复地创造 Box、反复调用 Box 上的 map 方法,我们就可以得到一个声明式的函数调用链:
const computeBox = Box(10).map(add4).map(multiply3).valueOf();
这个 Box 在范畴论中有个学名叫Functor
,Functor
是一种能够将一个范畴映射到另一个范畴的东西。本例中的 Box 是最简单的一种Functor
,也称Identity Functor
。
在 JS 中,Functor 是指一个实现了 map 方法的数据结构。
Array:常见的 Functor
既然 Functor 是一种实现了 map 方法的数据结构,那 Array 自然属于 Functor。
对于一个调用了 map 方法的数组,数组这个数据结构可以被看作是一个盒子,通过调用 map 方法,我们可以将盒子盛放的源数据映射为一套新数据,并且新数据也被盛放在 Array 盒子里。
Maybe Functor:识别空数据
Maybe Functor
在Identity Functor
的基础上,增加了对空数据的校验。
const isEmpty = (x) => x === undefined || x === null;
const Maybe = (x) => ({
map: (f) => (isEmpty(x) ? Maybe(null) : Maybe(f(x))),
valueOf: () => x,
inspect: () => `Maybe ${x}`,
});
可以看到,若入参为空,则 map 方法就不会再执行f函数
,而是直接返回一个空的 Maybe 盒子。对于这个空的 Maybe 盒子,既然数据一直盛放着 null,那就会一直执行 Maybe(null),而不会再执行传入的函数,最终只会返回 Maybe(null)
相比于Identity Functor
,避免了因传入空数据而报错的情况。
成为 Functor 的条件
需要满足恒等性(Identity)和可组合性(Composition)
恒等性
如果传递了一个恒等函数到盒子的 map 方法里,map 方法创造出的新盒子应该和原来的盒子等价。
// 恒等函数:
const identity = (x) => x;
// array的map函数的恒等性考察:
const identityArr = originArr.map((x) => x);
// 此时identityArr和originArr内容相同,故array的map函数是一个Functor
这个特性保证了以下两点:
- 该 map 方法具备“创造一个新的盒子(Functor)”的能力
- 保证该 map 方法足够干净。所谓“行为框架”,就意味着 map 方法的主要作用是串联不同的行为(函数),而不是编辑这些行为。
可组合性
可组合性要求 Functor 能将嵌套的函数拆解为平行的链式调用
Functor.map(x => f(g(x))) = Functor.map(g).map(f)
Monad(单子):解决“嵌套盒子”问题
除了可以通过往 map 方法里传入不同函数来拓展 Functor 的能力,也可以在保有map方法的基础上,往盒子里添加新的方法
。而Monad
也正是从这个思路上衍生出来的。
Functor 是一个实现了 map 方法的盒子,而 Monad 是一个实现了 flatMap 方法的 Functor(既实现了 map 也实现了 flatMap)。Monad 的诞生是为了解决“嵌套盒子”的问题。
嵌套盒子问题
嵌套盒子,在这里指的是 Functor 内部嵌套 Functor 的情况。
导致出现嵌套 Functor 有很多原因,此处仅列举两种典型情况:
线性计算场景下的嵌套 Functor:Functor 作为另一个 Functor 的计算中间态出现
非线性计算场景下的嵌套 Functor:两个 Functor 共同作为计算入参出现
此时,两个 Functor 在逻辑上应该是平行关系,甚至能支持异步,但盒子模式的调用总是链式的、线性的。因此,使用盒子模式去实现非线性的计算时,就不得不将一个数据源包装成盒子,放到另一个数据源的 map 里去
嵌套 Functor 的解法思考
创建 Functor 是把数据包装成盒子的过程;而消除嵌套,则是打开盒子的过程。
因此,我们需要为盒子本身新建一个基础行为 flatMap,该行为预期 map(f)会返回一个嵌套的盒子,并且能够主动将套在里面的那个盒子取出。也就是在 map 结束之后再自动调用 valueOf()
const Monad = (x) => ({
map: (f) => Identity(f(x)),
valueOf: () => x,
inspect: () => `Monad {${x}}`,
flatMap: (f) => map(f).valueOf(),
});
实际上,因为 flatMap 和 map 是平级的方法,两个方法实际上并不在同一个上下文里,所以直接这样使用会失败。
要想将一个盒子中的两个方法放入同一个上下文中,第一方法是可以创建一个 class,如下:
class Monad {
constructor(x) {
this.val = x;
}
valueOf() {
return this.val;
}
map(f) {
return Monad.of(f(this.val));
}
flatMap(f) {
return this.map(f).valueOf();
}
}
Monad.of = function (val) {
return new Monad(val);
};
// 使用
const monad = Monad.of(1);
const nestedMonad = Monad.of(monad);
// 输出Monad {val: 1}
console.log(nestedMonad.flatMap((x) => x));
除此之外,实际上在该案例中根本不需要 of 来创建上下文。既然 flatMap 想要的是 f(x),那完全可以省去先用 map 方法封装成盒子、然后再由 flatMap 解构盒子的过程,直接返回 f(x)。故也可以这样实现:
const Monad = (x) => ({
map: (f) => Monad(f(x)),
flatMap: (f) => f(x),
valueOf: () => x,
inspect: () => `Monad {${x}}`,
});
参考