从编码的角度看,范畴论在 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 在范畴论中有个学名叫FunctorFunctor是一种能够将一个范畴映射到另一个范畴的东西。本例中的 Box 是最简单的一种Functor,也称Identity Functor

在 JS 中,Functor 是指一个实现了 map 方法的数据结构。

Array:常见的 Functor

既然 Functor 是一种实现了 map 方法的数据结构,那 Array 自然属于 Functor。

对于一个调用了 map 方法的数组,数组这个数据结构可以被看作是一个盒子,通过调用 map 方法,我们可以将盒子盛放的源数据映射为一套新数据,并且新数据也被盛放在 Array 盒子里。

Maybe Functor:识别空数据

Maybe FunctorIdentity 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

这个特性保证了以下两点:

  1. 该 map 方法具备“创造一个新的盒子(Functor)”的能力
  2. 保证该 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}}`,
});

参考

JavaScript 函数式编程实践指南