Koalablog

学一学 Monad

2025-07-24

作为初学者,我们可以武断地认为,这三个概念由 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让下面这两种情况等价:

  1. 先把a放在盒子f里,再通过 fmapa->b函数做变换变成f(b)
  2. 先让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,也即是说,他有下面两个特征:

  1. 是个盒子
  2. 支持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))。也就是说,我们可以将fga三个元素按照任意组合结合,他们都将返回同样的结果 f(g(a))

<*> 让下面两种情况等价:

  1. 先把 g 放在盒子 f 里,然后作用于f(a),生成一个f(g(a))
  2. 先把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,就可以消除这种不良写法。