一个前端的 Functor,applicative Functor,monad 初探

一个前端的 functor,applicative functor,monad 初探

前言

在使用 ramda 的时候,经常会在文档中出现一些概念性的名词,比如 functor,applicative functor,monad。这些“高端词汇”都是啥意思,宝宝很好奇,索性就来探一探。

其实解释这些概念,更接近本质方式是数学形式的推导,但是由于水平和精力有限,这篇文章会结合 haskell 中的定义,探索它们在 javascript 和 ramda 库中的体现。

Functor

先来看一看在 haskell 中是如何定义一个函子的,任何可以被 fmap (a -> b)映射的类型实例,该类型就是函子。(其实看不懂也无所谓~)

1
2
class Functor f where
fmap :: (a -> b) -> f a -> f b

数组

先来个简单的例子,在 ramda 中如何实现 Array.prototype.map 的功能。

可以使用 R.map ,像下面这样

1
2
const a = [1, 2, 3];
const b = R.map(String, a); // ['1', '2', '3']

换一个角度想这个过程:a 是一个容器,里面有3个值,通过函数 R.map(String) 映射出了另外一个同样有3个值的容器 b。

a容器和b容器其实就是 函子,简单地说就是可被映射的容器就是 函子 ,函子是可包含值的容器。

函数

其实函数也是 函子,可将函数视为包裹着值的容器

恒值函数

先看一个比较简单的函数,() => 1,这个是一个包裹着值1 的容器,我们可以将这个容器映射为一个包裹着2的容器:

1
2
const a = () => 1;
const b = R.map(x => x+1, a); // () => 2

恒值函数是一个包裹着恒定值的函数,利用 ramda 我们可以像下面一样创建这样一个容器:

1
R.always(2);    // () =>  2;

在日常开发中,我们接触到的大多数函数都不是恒值函数,而是普通函数,接下来说一说普通函数。

普通函数

普通函数也是函子,可以想象 x => x + 1 , 我们可以把这个函数视为一个容器,这个容器的值就是 参数 + 1 ,一个不确定的值而已。

既然我们可以把函数视为一个 包含不确定值的容器

思考一个问题:如何将 x => x + 1 这样一个容器,映射成 x => (x + 1) * 2

你可能会写出下面这样的代码:

1
2
3
const a = x => x + 1;
const b = x => a(x) * 2;
b(1); // 4

换成 ramda 的写法

1
2
3
const a = x => x + 1;
const b = R.map(x => x * 2, a);
b(1); // 4

容器 a 映射成了 b ,映射过程:生成了一个 b 容器,它的值是a中不确定的值*2

接下来我们来看一看 functor 的升级版 applicative functor。

Applicative Functor

先看一看 haskell 中的定义,

1
2
3
class Functor f => Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b

首先 Applicative Functor 必须是 Functor ,另外存在 pure 方法接收一个值 a ,返回一个包裹了 a 的容器。

其次,支持<*>函数,在 haskell 中可以像下面这样操作 applicative functor :

1
2
3
pure (+10) <*> Just 9  --> Just 19
--> 或者使用 applicative 风格
(+10) <$> <*> Just 9 --> Just 19

R.ap

在 ramda 中数组和函数同样也都是 applicative functor,我们可以利用R.ap函数充当 haskell 中<*>的角色。

先来看一看R.ap函数的通用定义,是不是和 applicative functor 的 <*> 函数一样:

1
Apply f => f (a → b) → f a → f b

函数

有一个包裹着映射规则 x => x + 1 的容器 a 和包裹着值2的容器 b。

思考:如何将 a 中的映射规则应用到容器 b 的值上,并将产生的新值放到一个新的容器里。

我们可以利用R.ap函数:

1
2
3
const a = R.always(x => x + 1); // 容器 a
const b = R.always(2); // 容器 b
const c = R.ap(a, b); // 容器 c:() => 3

数组

数组也是类似,在数组中包裹一个函数x => x + 1,然后在另一个数组上应用这个数组。

1
2
3
4
const a = [1];
const b = [x => x + 1];

R.ap(b, a); // [2]

如果数组 a 中有 a 个元素,数组 b 中有 b 个元素,那么最终会生成 a*b 个元素的数组

1
2
3
4
const a = [1, 2];
const b = [x => x + 1, x => x * 2];

R.ap(b, a); // [2, 2, 3, 4]

见识到 applicative functor 之后,接下来感受一下 Monad 是什么。

Monad

haskell 中 Monad 定义:

1
2
3
class Monad m where
return :: a -> m a
(>>=) :: m a -> (a -> m b) -> m b

简单来说,首先 return 函数接收一个值,返回一个包裹这个值的容器。其次对于 >>= 函数来说,接收一个容器和对其中值的映射规则,返回一个新的容器,这个容器就是映射规则返回的容器。

换一种角度,其实 Monad 就是在说某种特殊的函数满足结合率这件事,>>= 函数描述了 m 这种容器在 >>= 下满足结合率的这一特性,然后 return 在其中扮演单位元的角色,任何容器通过 >>= 函数与 return 组合,返回的一定是自身。

在 javascript 中,最典型的 monad 就是 Promise。

Promise

先创建一个 Promise:

1
const getUser = new Promise(...);

我们创建了一个 Promise 实例。那么如何去消费这个实例产出的值呢?大家都知道,可以用 then 方法:

1
2
const consumer = user => {...};
getUser.then(consumer);

在 consumer 函数中去消费 user。如果我们想在拿到 user 之后再去进行异步操作怎么办?改写上面的例子

1
2
const getDetailByUser = user => new Promise(...);
getUser.then(getDetailByUser).then(...);

我们再来简化地描述这个过程:

1
2
getUser :: Promise<user>
getDetailByUser :: user -> Promise<detail>

把 Promise 看做一个包裹着未来值的容器 m,把 user 用 a 代替,detail 用 b 代替,省去 <> :

1
2
getUser :: m a
getDetailByUser :: a -> m b

其中 then 方法是不是就有些类似 >>= 函数。

1
then :: m a ->(a -> m b)-> m b

在 then 函数的作用下类似 a -> m b 这样的过程满足结合率,那么 return 的单位元的职责是由谁来承担的呢?

1
2
getUser.then(Promise.resolve).then(getDetailByUser)
getUser.then(getDetailByUser).then(Promise.resolve)

这两行代码的效果都是一样的,Promise.resolve 承担着单位元的作用,任何过程 a -> Promise 与 Promise.resolve 通过 then 组合(左结合或右结合),其结果都是 a -> Promise

通过对比在 haskell 中的定义, Promise 符合 Monad 的形式。

通常 monad 模式会用来封装一些副作用,使得这部分”不纯”的逻辑与外部“纯洁”的逻辑隔离开,比如在 haskell 中的 IO 类型。

这篇文章算是给前段时间的困惑画上了一个句号吧,如果有些地方理解有误,还请多多指出。

参考资料:
《 Haskell 趣学指南 》
IO and monads
https://www.seas.upenn.edu/~cis194/fall16/lectures/06-io-and-monads.html
写给小白的Monad指北
https://zhuanlan.zhihu.com/p/65449477

0%