|
| 1 | +# **写给小白的 Monad 指北 ** |
| 2 | + |
| 3 | +最近公司来了个新同事,他姓白,年纪又很小,我们都叫他小白。小白最近在学习函数式编程,前几天他过来问我一个问题。 |
| 4 | +小白:我正规985学校毕业,为什么看了这么多Monad介绍,还是云里雾里的。是这些文章写得的有问题,还是我的理解力有问题? |
| 5 | +我:你学什么专业的? |
| 6 | +小白:高分子。毕业后我在家自学了半年编程。 |
| 7 | +我:好吧... |
| 8 | +最后我决定写这篇文章,帮小白搞清楚这个问题。 |
| 9 | + |
| 10 | +什么是Monad?从实用主义的角度上说,**Monad就是函数式编程中特有的一种设计模式**。为什么是函数式特有的?因为它主要解决的就是**函数之间如何组合**的问题。 |
| 11 | + |
| 12 | +## **一、函数** |
| 13 | + |
| 14 | +为了研究函数之间如何组合,我们首先需要定义什么是函数。在编程语言中,函数可以被理解为从类型A到类型B的映射,形式上可以写成 `f: A -> B`。一个函数 `f` 接收一个类型为 `A` 的输入,返回一个类型为 `B` 的输出。 |
| 15 | + |
| 16 | +特别的,函数本身也可以作为输入或输出,这就是所谓的高阶函数。有了高阶函数的概念,我们就可以定义一个变换 `curry`,它负责将多参数函数化归为单参数函数。这样在理论上我们就只用研究单参数函数了。 |
| 17 | + |
| 18 | +```javascript |
| 19 | +function add(x, y) { |
| 20 | + return x + y; |
| 21 | +} |
| 22 | + |
| 23 | +// curry化后,变成接收一个参数,并返回一个新函数 |
| 24 | +function curry_add(x) { |
| 25 | + return function(y) { |
| 26 | + return x + y; |
| 27 | + } |
| 28 | +} |
| 29 | + |
| 30 | +add(3, 4) === curry_add(3)(4); // 结果都为 7 |
| 31 | + |
| 32 | +// 在数学上,这可以看作是类型的变换: |
| 33 | +// (A, B) -> C 等价于 A -> (B -> C) |
| 34 | +``` |
| 35 | +redux中间件那个看起来有点吓人的形式 `store => next => action => { ... }` 本质上就是一个柯里化后的多参数函数。 |
| 36 | + |
| 37 | +函数最重要的操作是**组合(composition)**。如果有一个函数 `g: A -> B` 和另一个函数 `f: B -> C`,我们可以将它们组合成一个新函数 `h: A -> C`。 |
| 38 | + |
| 39 | +```javascript |
| 40 | +// 为了更符合代码从左到右的执行顺序,我们约定 compose(g, f) 代表数据先流经 g,再流经 f |
| 41 | +function compose(g, f) { |
| 42 | + return function(x) { |
| 43 | + // 先执行 g(x),然后将其结果作为输入传给 f |
| 44 | + return f(g(x)); |
| 45 | + } |
| 46 | +} |
| 47 | +// 值得注意的是,这个约定与一些函数式编程库(如 Ramda)的 `compose` 定义顺序相反。 |
| 48 | +// 我们这里定义的从左到右的组合方式,在社区中通常被称为 `pipe` 函数,因为它更像数据流过一根管道。 |
| 49 | +``` |
| 50 | + |
| 51 | +函数组合最重要的性质是满足**结合律**。对于一个 `h -> g -> f` 的执行顺序,无论我们是先组合 `h` 和 `g`,还是先组合 `g` 和 `f`,最终结果都一样。 |
| 52 | + |
| 53 | +```javascript |
| 54 | +// 结合律: (h -> g) -> f 等价于 h -> (g -> f) |
| 55 | +compose(compose(h, g), f) // 在效果上等价于 |
| 56 | +compose(h, compose(g, f)) |
| 57 | +``` |
| 58 | + |
| 59 | +满足结合律意味着我们可以随意组合,因为计算结果与局部结合顺序无关。这使得我们可以像管道一样把一长串函数链接起来,例如 `f(g(h(x)))` 可以被看作是 `compose(h, g, f)(x)`(假设 `compose` 支持多参数)。 |
| 60 | + |
| 61 | +Monad本质上说的就是函数满足结合律这件事情,但它不是简单地说普通函数满足结合律(这显而易见),而是说**某种特殊形式的函数**,也能通过一种新的组合方式,同样满足结合律。 |
| 62 | + |
| 63 | +## **二、一个问题:Promise 的组合** |
| 64 | + |
| 65 | +假设我们有如下两个异步函数,我们希望把它们组合成一个新的异步函数: |
| 66 | + |
| 67 | +```javascript |
| 68 | +// String -> Promise<User> |
| 69 | +async function getUserById(userId) { /* ... */ } |
| 70 | + |
| 71 | +// User -> Promise<Dept> |
| 72 | +async function getDeptByUser(user) { /* ... */ } |
| 73 | +``` |
| 74 | +我们的目标是创建一个函数 `getDeptByUserId`,它的类型应该是 `String -> Promise<Dept>`。如果我们尝试使用上面定义的普通 `compose`: |
| 75 | + |
| 76 | +```javascript |
| 77 | +// 错误的尝试 |
| 78 | +const getDeptByUserId_wrong = compose(getUserById, getDeptByUser); |
| 79 | +``` |
| 80 | +这为什么是错误的?让我们看看执行过程: |
| 81 | +1. `getUserById(userId)` 返回一个 `Promise<User>`。 |
| 82 | +2. 普通的`compose`会把这个 `Promise<User>` 对象直接传给 `getDeptByUser`。 |
| 83 | +3. 但 `getDeptByUser` 期望的输入是 `User` 类型,而不是 `Promise<User>`! |
| 84 | + |
| 85 | +问题出在哪里?这两个函数的返回值被一个“容器”——`Promise`——包裹了。我们需要一种新的组合方式来处理这种带“容器”的函数。 |
| 86 | + |
| 87 | +```javascript |
| 88 | +// g 是第一个函数,f 是第二个函数 |
| 89 | +function composeM(g, f) { |
| 90 | + return function(x) { |
| 91 | + // 1. 先执行 g(x),得到一个 Promise |
| 92 | + // 2. 使用 .then 从 Promise 中取出值,再传给 f |
| 93 | + return g(x).then(f); |
| 94 | + } |
| 95 | +} |
| 96 | + |
| 97 | +// 正确的组合:先 getUserById,再 getDeptByUser |
| 98 | +const getDeptByUserId = composeM(getUserById, getDeptByUser); |
| 99 | +``` |
| 100 | +这个特殊的 `composeM` 完美地解决了问题。更重要的是,可以证明,这种新的组合方式**同样满足结合律**!这就是通往 Monad 的大门。 |
| 101 | + |
| 102 | +## **三、另一个例子:List 的组合** |
| 103 | + |
| 104 | +让我们看看另一种常见的“容器”——数组(List)。考虑以下两个函数: |
| 105 | + |
| 106 | +```javascript |
| 107 | +// number -> number[] (List<number>) |
| 108 | +function positive(x) { |
| 109 | + return x > 0 ? [x] : []; |
| 110 | +} |
| 111 | + |
| 112 | +// number -> number[] (List<number>) |
| 113 | +function duplicate(x) { |
| 114 | + return [x, x]; |
| 115 | +} |
| 116 | +``` |
| 117 | +同样,我们无法用普通 `compose` 来组合它们。`Array.prototype.flatMap` 恰好是我们的工具。 |
| 118 | + |
| 119 | +```javascript |
| 120 | +// g 是第一个函数,f 是第二个函数 |
| 121 | +function composeM_List(g, f) { |
| 122 | + return function(x) { |
| 123 | + return g(x).flatMap(f); |
| 124 | + } |
| 125 | +} |
| 126 | + |
| 127 | +// 组合:先 positive,再 duplicate |
| 128 | +const p = composeM_List(positive, duplicate); |
| 129 | +console.log([1, -1, 2].flatMap(p)); // 输出 [1, 1, 2, 2] |
| 130 | +``` |
| 131 | +我们再次发现,对于返回数组的函数,我们也能定义一个满足结合律的 `composeM`。 |
| 132 | + |
| 133 | +## **四、Monad** |
| 134 | + |
| 135 | +现在,数学家要开始表演了。数学是一门只关注“形式”的科学,形式上一样的东西在数学上可以认为是完全等价的,这就是“抽象”的威力。 |
| 136 | + |
| 137 | +`Promise` 的例子中,函数类型是 `A -> Promise<B>`。 |
| 138 | +`List` 的例子中,函数类型是 `A -> List<B>`。 |
| 139 | + |
| 140 | +它们的共同模式是:`A -> M<B>`。 |
| 141 | + |
| 142 | +`M` 就是一个符号,一个上下文的抽象。 |
| 143 | +* 当 `M` 是 `Promise` 时,它表示异步计算。 |
| 144 | +* 当 `M` 是 `List` 时,它表示可能产生零个或多个结果的计算。 |
| 145 | +* 当 `M` 是 `Identity` (即 `Identity<T> = T`) 时,`A -> Identity<B>` 就退化成了 `A -> B`,我们回到了普通的函数。 |
| 146 | + |
| 147 | +**Monad 的核心,就是为 `A -> M<B>` 这种类型的函数(在范畴论中称为 Kleisli 箭头)定义一个满足结合律的组合操作 `composeM` 和一个单位元 `unit`。** |
| 148 | + |
| 149 | +一个满足结合律的系统,在数学上称为**半群(Semigroup)**。如果这个系统还有一个**单位元(Identity Element)**,它就升级成了**幺半群(Monoid)**。 |
| 150 | + |
| 151 | +`unit` 是一个特殊的函数,它把一个普通值 `a` 放入 Monad 容器中,其类型是 `A -> M<A>`。 |
| 152 | + |
| 153 | +```javascript |
| 154 | +// 对于 Promise 而言 |
| 155 | +function unit_Promise(a) { |
| 156 | + return Promise.resolve(a); |
| 157 | +} |
| 158 | + |
| 159 | +// 对于 List 而言 |
| 160 | +function unit_List(a) { |
| 161 | + return [a]; |
| 162 | +} |
| 163 | +``` |
| 164 | +所以,**Monad 就是“自函子范畴上的一个幺半群”**² 这句天书的“人话”版本就是:它是一个关于 `A -> M<B>` 类型函数的、带单位元的、满足结合律的组合系统。 |
| 165 | + |
| 166 | +**从 `composeM` 到 `bind` (`flatMap`)** |
| 167 | + |
| 168 | +如果我们换一个“面向对象”的视角,不从组合函数出发,而是从一个已有的 monadic 值 `ma` (`M<A>`类型的对象) 出发,我们可以定义一个更常见的方法,通常叫做 `bind` 或 `flatMap`。 |
| 169 | + |
| 170 | +`bind: (ma: M<A>, f: A -> M<B>) -> M<B>` |
| 171 | + |
| 172 | +对于 `Promise`,它就是 `then`;对于 `List`,它就是 `flatMap`。 |
| 173 | + |
| 174 | +`bind` 和 `composeM` 是等价的:`composeM(g, f)` 等价于 `x => g(x).bind(f)`。 |
| 175 | + |
| 176 | +**Monad 定律 (Monad Laws)** |
| 177 | + |
| 178 | +`composeM` 的结合律和单位元性质,可以等价地用 `bind` 和 `unit` 来表述,这就是著名的 **Monad 三定律**。为了表述清晰,我们用“效果等价”来描述定律,因为在实际编程中,这通常意味着产生相同的值或最终状态,而非严格的对象引用相等(`===`)。 |
| 179 | + |
| 180 | +1. **左单位元 (Left Identity)**: `unit(x).bind(f)` **效果等价于** `f(x)` |
| 181 | + 把一个值装进容器,然后 bind 一个函数,等价于直接把该值应用到函数上。 |
| 182 | +* `Promise.resolve(x).then(f)` 效果等价于 `f(x)` ¹ |
| 183 | +* `[x].flatMap(f)` 效果等价于 `f(x)` |
| 184 | + |
| 185 | +2. **右单位元 (Right Identity)**: `ma.bind(unit)` **效果等价于** `ma` |
| 186 | + 用 `unit` 函数去 bind 一个容器,等于什么也没做。 |
| 187 | +* `promise.then(Promise.resolve)` 效果等价于 `promise` |
| 188 | +* `list.flatMap(x => [x])` 效果等价于 `list` |
| 189 | + |
| 190 | +3. **结合律 (Associativity)**: `ma.bind(f).bind(g)` **效果等价于** `ma.bind(x => f(x).bind(g))` |
| 191 | + 一连串的 bind 操作,可以任意组合。 |
| 192 | +* `promise.then(f).then(g)` 效果等价于 `promise.then(x => f(x).then(g))` |
| 193 | +* `list.flatMap(f).flatMap(g)` 效果等价于 `list.flatMap(x => f(x).flatMap(g))` |
| 194 | + |
| 195 | + |
| 196 | +> 严格来说,`.then(f)` 会将 `f` 的执行推迟到下一个微任务(microtask),而直接调用 `f(x)` 可能是同步的。但从数据流和最终结果的角度看,它们是等价的。 |
| 197 | +
|
| 198 | +> **给好奇宝宝的“天书”注解**:**自函子 (Endofunctor)** 就是那个类型构造器 `M`,它将范畴内的类型映射回同一个范畴(例如 `T -> M<T>`); **范畴 (Category)** 在此可简单理解为你所用编程语言的类型系统;而 **幺半群 (Monoid)** 正是我们讨论的那个拥有单位元(`unit`)和满足结合律的二元操作(`composeM`)的组合系统。 |
| 199 | +
|
| 200 | +## **五、作为设计模式的 Monad** |
| 201 | + |
| 202 | +Monad 是一种对函数计算过程的通用抽象机制,关键是统一形式,统一操作模式,复用代码。因为这种模式很常见,一些语言会提供语法糖来方便编写,例如 `async/await` 就是 `Promise` Monad 的语法糖,而 Scala/Haskell 等语言中的 `for-comprehension` / `do` 语法,则可以用于任何 Monad。 |
| 203 | + |
| 204 | +```scala |
| 205 | +// for-comprehension 语法 |
| 206 | +for { |
| 207 | + x <- mx |
| 208 | + y <- my |
| 209 | +} yield x + y |
| 210 | + |
| 211 | +// 会被编译器脱糖为一系列嵌套的 flatMap/map 调用 |
| 212 | +mx.flatMap { x => |
| 213 | + my.map { y => |
| 214 | + x + y |
| 215 | + } |
| 216 | +} |
| 217 | +``` |
| 218 | +这个嵌套结构也解释了为什么我们自己手写代码时,更喜欢可读性更好的链式调用:`mx.flatMap(f).flatMap(g)`。 |
| 219 | + |
| 220 | +## **六、Monad 到底干了什么?** |
| 221 | + |
| 222 | +Monad 相比于普通的函数组合,关键是引入了一个包装结构 `M`,相当于把 `value` 包装在一个 `context` 中(monadic value = a value in a context)。 |
| 223 | + |
| 224 | +这使得我们可以在 `bind` 的实现中,将一部分通用逻辑(如异步等待、空值检查、状态传递、日志记录等)隐藏到这个 `context` 的处理中,从而让我们的主逻辑代码可以像简单的函数链一样清晰地表达出来。这有点类似于面向切面编程(AOP)的作用。 |
| 225 | + |
| 226 | +## **七、State Monad:封装副作用的艺术** |
| 227 | + |
| 228 | +Monad 对于函数式语言还有一个特别的意义:它提供了一种环境封装机制,可以把副作用隔离到某个环境对象中,保证核心函数的“纯洁”。**State Monad** 是最好的例子。 |
| 229 | + |
| 230 | +假设,我们需要在程序中使用随机数: |
| 231 | +`function addRandom(a) { return a + Random.nextInt(); }` |
| 232 | +这个函数依赖全局变量 `Random`,是有副作用的。为了变纯,我们必须把状态作为参数传递: |
| 233 | +`function addRandom(a, random) { return [a + random.nextInt(), random]; }` |
| 234 | +返回值是一个元组 `[新值, 新状态]`。这是一个通用模式。所有需要状态的纯函数,其类型签名都可以写成 `(Value, State) -> (NewValue, NewState)`。 |
| 235 | + |
| 236 | +利用柯里化,我们可以把它变成 `Value -> (State -> (NewValue, NewState))`。 |
| 237 | +这完美匹配了 Monad 的形式 `A -> M<B>`! |
| 238 | +* `A` 就是 `Value` |
| 239 | +* `M<B>` 就是 `State -> (NewValue, NewState)` |
| 240 | +* `M` 就是 `State -> (..., NewState)` 这个结构,我们称之为 `State s` |
| 241 | +* `B` 就是 `NewValue` |
| 242 | + |
| 243 | +所以,我们的函数类型是 `A -> State s B`,其中 `State s B` 是 `s -> (B, s)` 的别名。 |
| 244 | + |
| 245 | +> `State s` 就对应于符号M。再次强调,数学是一种彻底的形式主义,只要能按照确定的规则替换为指定形式,那么它们在数学上本质上就是一回事。 |
| 246 | +
|
| 247 | +一旦形式匹配,我们就可以定义 `unit` 和 `bind`: |
| 248 | +```javascript |
| 249 | +// unit :: a -> State s a |
| 250 | +function unit(a) { |
| 251 | + // unit 的作用是把值 a 包装进 State Monad |
| 252 | + // 它返回一个函数,该函数接收一个状态 s,然后原封不动地返回 a 和 s |
| 253 | + return function(s) { |
| 254 | + return [a, s]; |
| 255 | + } |
| 256 | +} |
| 257 | + |
| 258 | +// bind :: State s a -> (a -> State s b) -> State s b |
| 259 | +function bind(ma, f) { |
| 260 | + // bind 的作用是组合两个有状态的计算。 |
| 261 | + // 它也返回一个函数,这个函数代表了整个组合后的计算。 |
| 262 | + return function(s) { |
| 263 | + // 1. 执行第一个计算 ma,传入初始状态 s,得到新值 a 和新状态 s1 |
| 264 | + const [a, s1] = ma(s); |
| 265 | + // 2. 将新值 a 传给函数 f,得到下一个有状态计算 f(a) |
| 266 | + const mb = f(a); |
| 267 | + // 3. 执行下一个计算 mb,并传入新状态 s1,得到最终值 b 和最终状态 s2 |
| 268 | + const [b, s2] = mb(s1); |
| 269 | + // 4. 返回最终结果和最终状态 |
| 270 | + return [b, s2]; |
| 271 | + } |
| 272 | +} |
| 273 | + |
| 274 | +// 我们可以用一个简单的流程图来形象地理解这个状态传递过程: |
| 275 | +// |
| 276 | +// 初始状态(s) ---> [ 执行 ma ] --产生--> 值(a) 和 新状态(s1) |
| 277 | +// | |
| 278 | +// | (值 a 被用于生成下一个计算) |
| 279 | +// v |
| 280 | +// (s1 被传入) ---> [ 执行 f(a) ] --产生--> 最终值(b) 和 最终状态(s2) |
| 281 | +// |
| 282 | +// 最终返回:[b, s2] |
| 283 | +``` |
| 284 | +`bind` 的实现巧妙地把“传递状态”这个繁琐的过程封装了起来。 |
| 285 | + |
| 286 | +**State Monad 为什么可以封装副作用?因为它实现了延迟计算。`bind` 仅仅负责把一堆函数组装成一个更大的函数,它本身并不会立即执行。只有当你最后给这个大函数提供一个初始状态时,整个计算链才会真正启动。** |
| 287 | + |
| 288 | +## **八、其他的 Monad** |
| 289 | + |
| 290 | +Monad 模式在编程中无处不在: |
| 291 | + |
| 292 | +* **Option/Maybe Monad**: 用于处理可能为空的值。Kotlin/Swift 中的 `?.` 链式调用就是它的体现。如果遇到 `null`,整个链条会“短路”并返回 `null`,避免了层层嵌套的 `if (a != null)`。 |
| 293 | +* **Either/Result Monad**: 用于处理可能失败的操作,它能同时携带成功的值或失败的错误信息。 |
| 294 | +* **IO Monad**: 用于封装与外部世界交互的副作用(如读写文件、打印到控制台),将不纯的操作包裹起来,使得程序的其他部分保持纯净。 |
| 295 | + |
| 296 | +希望这篇文章能帮你驱散 Monad 的迷雾。它不是什么魔法,而是一种强大、通用、用来组织和抽象代码的优雅模式。掌握了它,你就掌握了函数式编程的一个核心思想。 |
0 commit comments