用 Python 解释 Haskell Monad
假设你已经会使用一门简单的编程语言 Python
从一个具体问题开始
你写过这种代码吗:
1 | user = get_user(id) |
每一步都可能失败,所以每一步之后都要检查。这段代码的真实逻辑只有三行,但被错误处理淹没了。
Monad 要解决的正是这类问题 如何把”带有某种额外语境的计算”优雅地串联起来。
三个层次来理解
第一层:Monad 是一种设计模式
先忘掉范畴论。Monad 就是一个包装盒,加上两个操作:
return(或pure):把一个普通值放进盒子>>=(bind):把盒子里的值取出来,交给下一个函数,返回新的盒子
1 | 普通值 → return → [盒子] |
关键在于:**盒子里藏着某种”额外的东西”**,而 bind 知道怎么处理这个额外的东西。
第二层:三个具体例子
Maybe Monad 处理可能失败的计算
盒子是 Just value 或 Nothing。
bind 的规则:如果是 Nothing,直接传递 Nothing,不执行后续函数。
1 | get_user(id) >>= get_profile >>= get_score |
上面那坨 if-None 代码,就变成了这一行。Nothing 会自动穿透整条链。
List Monad 处理多个可能的结果
盒子是一个列表。bind 的规则:对列表里每个元素应用函数,然后把结果”摊平”。
1 | [1,2,3] >>= \x -> [x, x*10] |
这其实就是 flatMap。你在 PyTorch 里用过的很多操作背后有类似结构。
IO Monad 处理有副作用的计算
这是 Haskell 最著名的设计。盒子是”一个会产生副作用的动作”。bind 把动作串联起来,但副作用被封装在盒子里,不会污染纯函数世界。
第三层:它们的共同结构
三个 Monad 看起来完全不同,但它们满足同样的三条规律(Monad Laws):
return a >>= f≡f a(左单位元)m >>= return≡m(右单位元)(m >>= f) >>= g≡m >>= (\x -> f x >>= g)(结合律)
这三条规律保证了 bind 的行为是可预测、可组合的。满足这三条,你就造了一个 Monad。
Maybe Monad,用 Python 写
先定义”盒子”:
1 | class Maybe: |
然后定义几个可能失败的函数:
1 | def get_user(user_id): |
现在串联起来:
1 | result = get_user(1).bind(get_profile).bind(get_score) |
关键点
.bind() 做了什么?
1 | 盒子.bind(函数) |
你不需要写任何 if-None 检查。失败的处理逻辑被封装在 bind 里了。
Haskell 符号对照
现在你再看 Haskell,每个符号都有对应:
| Haskell | Python 等价 |
|---|---|
>>= |
.bind() |
Just 5 |
Maybe(5) |
Nothing |
Maybe(None) |
return 5 |
Maybe(5) |
1 | get_user 1 >>= get_profile >>= get_score |
就是:
1 | get_user(1).bind(get_profile).bind(get_score) |
一模一样的结构。
List Monad,用 Python 写
先回忆一下 Maybe 的 bind 逻辑:
- 盒子有值 → 取出来,交给函数,得到新盒子
- 盒子是空 → 直接传递空
List 的 bind 逻辑类似,但盒子里有多个值:
1 | class MyList: |
用一下:
1 | def double_or_triple(x): |
每个元素都被展开了,然后拼在一起。
这就是 flatMap
你在 Python/JavaScript/PyTorch 里见过的 flatMap,本质就是 List Monad 的 bind:
1 | # 普通 map:一个进,一个出 |
bind 就是 flatMap。这不是巧合,这就是同一件事。
一个更有意思的例子:组合所有可能性
1 | def to_suits(card): |
List Monad 自动帮你做了所有组合的展开。
这在 Haskell 里经常用来替代嵌套循环 你不需要写两层 for,只需要两次 bind。
现在回头看 Maybe 和 List 的共同结构
1 | Maybe 的 bind:取出值 → 应用函数 → 如果中途失败就短路 |
两个完全不同的”额外逻辑”,但接口完全一样:.bind(func)。
这就是为什么 Monad 是一个抽象 不同的盒子,不同的额外语境,但组合的方式是统一的。
三个 Monad 的对比
| 盒子装的是 | bind 做的额外事情 | |
|---|---|---|
| Maybe | 可能有值,可能没有 | 失败就短路,不再执行 |
| List | 多个可能的值 | 展开所有结果并摊平 |
| IO | 一个待执行的动作 | 把动作按顺序串联起来 |
IO Monad
IO Monad 是最难的一个,但我们慢慢来。
先问一个问题
Haskell 是纯函数式语言,意味着:
同样的输入,永远得到同样的输出。函数不能有副作用。
但是……程序总要读键盘、写文件、打印东西吧?
这些操作天然是有副作用的 你调用 print("hello") 之后,世界就不一样了。
Haskell 怎么解决这个矛盾?
关键思路:把”动作”本身变成值
Haskell 的解法不是”允许副作用”,而是:
不要执行副作用,而是描述它。
1 | # 普通思路:直接执行 |
IO(lambda: print(s)) 是一个盒子,盒子里装的是一个动作的描述,而不是动作的结果。
用 Python 模拟 IO Monad
1 | class IO: |
定义几个基本 IO 动作:
1 | def io_print(s): |
串联起来:
1 | program = ( |
关键:副作用被推迟到最后
1 | 定义 io_input(...) → 只是描述"我要读输入",没有真正读 |
整个程序构建阶段是纯的 你只是在拼装描述。副作用被推到了最边缘,由 run() 统一触发。
在 Haskell 里,run() 就是 main 整个程序就是一个巨大的 IO 值,Haskell 运行时负责执行它。
现在三个 Monad 放在一起看
1 | # Maybe:失败短路 |
接口完全一样,.bind() 而已。
但三个盒子装的东西完全不同,bind 内部的逻辑也完全不同。
现在可以给出完整定义了
Monad 是一个满足三件事的盒子:
- 能把普通值装进去(
pure/return) - 能把盒子里的值取出来交给函数,返回新盒子(
bind) - 这个过程满足结合律 串联的顺序不影响结果
就这些。没有魔法。
总结
| 你可能遇到的问题 | 用哪个 Monad | bind 帮你处理了什么 |
|---|---|---|
| 链式操作中途可能失败 | Maybe | 自动短路,不用写 if-None |
| 每步产生多个可能结果 | List | 自动展开组合,不用写嵌套循环 |
| 需要副作用但想保持纯函数 | IO | 把副作用封装成值,推迟到最后执行 |
Monad 是一种可组合的计算语境
语境就是盒子里那个”额外的东西” 失败的可能、多个结果、待执行的副作用。Monad 让你用统一的方式组合它们,而不是每次都手动处理。



