作为初学者,我们可以武断地认为,这三个概念由 Haskell 从范畴论中发掘,引入程序语言设计当中,然后由各种语言模仿和借鉴,流传开来。他们虽然都有一些数学上的定义和解释,但我们关心的其实只是在程序设计中他们会带来什么样的影响。
最近努力思考了一下,感觉对这三个概念有了一点自己的理解,所以记录下来。不一定对,因为在此之前我对范畴论的了解几乎是0,对 Haskell 的实际使用经验也是 0,所以只是一个个人的知识总结和记录而已,只能抛砖引玉。
0. 引用
目前看到的,讲解 Monad 的文章,比较好的是:
1. 起点:Functor
大家通常会把 Functor 比喻成一个盒子,其中可以放置一些值。当然这里的值不是字面量、常量的意思,在函数式编程(FP)的语境当中,函数、引用、字面量,乃至于 Functor 都算是值。这是抽象的说法,举个具体的例子就是 Haskell 中的 Maybe
,或是 Rust 当中的 Option<T>
,他们几乎是同一个东西,都用来表示值可能为空。
Ok,我们现在知道 Functor 是个盒子,里面可以存放值。这个盒子在存放之余,还需要可以操作其中的值,不然里面的值就画地为牢了。这个操作在 Haskell 中称为 fmap
,定义如下:
fmap :: Functor f => (a -> b) -> f a -> f b
上述定义翻译成人话就是:
- 先有个操作值的函数
a -> b
- 再给个放在盒子
f
里的a
- 返回出来就是一个放在盒子里的
b
我将fmap
特性理解为一种交换律,这个交换律让 Functor 和函数变换的次序是可以互换的。也就是说,fmap
让下面这两种情况等价:
- 先把
a
放在盒子f
里,再通过fmap
和a->b
函数做变换变成f(b)
- 先让
a
通过a->b
变换,再放在盒子f
里
这里直接引述 Wikipedia 的解释:
In functional programming, a functor is a design pattern inspired by the definition from category theory that allows one to apply a function to values inside a generic type without changing the structure of the generic type.
在 Rust 中,我们可以通过 Option<T>
的 map
方法来体验这个特性:
let maybe_some_string = Some(String::from("Hello, World!"));
// `Option::map` takes self *by value*, consuming `maybe_some_string`
let maybe_some_len = maybe_some_string.map(|s| s.len());
assert_eq!(maybe_some_len, Some(13));
let x: Option<&str> = None;
assert_eq!(x.map(|s| s.len()), None);
很容易看出来的是,我们常见的列表类型,在比较新的语言特性当中都属于 Functor,例如 Python 的 List、Rust 的 Vector、JS/TS 的 Array。这个特性在 Haskell 中还有一个额外的效果,就是让 Map 的求值顺序变得不再重要。大家可能都默认 Array.map 是按照顺序处理 Array 的每一个元素,但在惰性求值的 Haskell 中,Functor 的原子性让求值顺序可以是任意的。
2. 升级:Applicative
首先需要说明的是,一个 Applicative 需要是一个 Functor,也即是说,他有下面两个特征:
- 是个盒子
- 支持
fmap
Applicative 之所以是 Applicative 而不只是 Functor,是因为他需要支持一个新的特性,在 Haskell 中他被称为<*>
。同样,我们在这里放上 Haskell 中的定义:
(<*>) :: Applicative f => f (a -> b) -> f a -> f b
咱再翻译一下这段定义:
- 先有个放着函数
a->b
的盒子 - 再有个放着数值
a
的盒子 - 返回出来一个放着数值
b
的盒子
我将<*>
看作一种结合律,也就是将盒子f
看作一种接收值,生成带有盒子的值的函数,然后把a->b
函数取个名字叫g
,我们就可以简化上面的定义为:f(g) <*> f(a) === f(g(a))
。也就是说,我们可以将f
、g
、a
三个元素按照任意组合结合,他们都将返回同样的结果 f(g(a))
。
<*>
让下面两种情况等价:
- 先把
g
放在盒子f
里,然后作用于f(a)
,生成一个f(g(a))
- 先把
g
作用于a
,再放入盒子f
,生成一个f(g(a))
3. 终点:Monad
显然,Monad 和上面两个盒子一样,继承原本规则的同时,又添加了一条新的。在 Haskell 中,我们使用>>=
符号表示这条规则。话不多说,加上定义:
(>>=) :: Monad m => m a -> (a -> m b) -> m b
翻译如下:
- 首先有个放在盒子里的
a
- 然后有函数
a -> m b
- 通过
>>=
符号,m a
就可以通过函数变换为m b
为什么 Monad 这么重要?大家也许都听说过 Monad 是用来处理副作用的。什么是副作用呢?下面是一个 JavaScript 实现的 fibnacci 数列的生成函数,其中用到了 let
关键字来声明变量,变量就是额外的状态,就是“副作用”。
function fib(n) {
let a = 0;
let b = 1;
if (n === 1) return b;
for (let i = 0; i < n - 1; ++i) {
let temp = a;
a = b;
b = temp + a;
}
return b;
}
/**
> fib(2)
1
> fib(3)
2
> fib(4)
3
> fib(5)
5
> fib(6)
8
**/
为啥我们需要 Monad 来处理副作用?其实是 Haskell 本身的问题导致的。由于 Haskell 所有的表达式都是惰性求值,只有我们访问这个值的时候,值对应的函数或者计算才会执行。当我们执行一些 IO 操作(或者其他副作用)的时候,惰性求值会导致我们无法确定发生的顺序。通过 Monad 执行,就可以稳定地处理这种情况,以确定的顺序执行代码。
放到 JS 里看,其实就是通过 Promise.then 解决回调地狱的问题。有许多个 fs.open / fs.write,我们无法确定 open 和 write 的执行顺序,只能写成:fs.open('xxx', (file) => {fs.write(file, 'content')})
。由于回调嵌套很容易多于 3 层,就会不好维护。通过 Promise 和更进一步的 async / await,就可以消除这种不良写法。