从编码的角度看,范畴论在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 函数式编程实践指南