假设你已经会使用一门简单的编程语言 Python

从一个具体问题开始

你写过这种代码吗:

1
2
3
4
5
6
7
8
9
user = get_user(id)
if user is None:
return None
profile = get_profile(user)
if profile is None:
return None
score = get_score(profile)
if score is None:
return None

每一步都可能失败,所以每一步之后都要检查。这段代码的真实逻辑只有三行,但被错误处理淹没了。

Monad 要解决的正是这类问题 如何把”带有某种额外语境的计算”优雅地串联起来。


三个层次来理解

第一层:Monad 是一种设计模式

先忘掉范畴论。Monad 就是一个包装盒,加上两个操作:

  • return(或 pure):把一个普通值放进盒子
  • >>=(bind):把盒子里的值取出来,交给下一个函数,返回新的盒子
1
2
普通值  →  return  →  [盒子]
[盒子] → >>= f → [新盒子]

关键在于:**盒子里藏着某种”额外的东西”**,而 bind 知道怎么处理这个额外的东西。


第二层:三个具体例子

Maybe Monad 处理可能失败的计算

盒子是 Just valueNothing

bind 的规则:如果是 Nothing,直接传递 Nothing,不执行后续函数。

1
get_user(id) >>= get_profile >>= get_score

上面那坨 if-None 代码,就变成了这一行。Nothing 会自动穿透整条链。


List Monad 处理多个可能的结果

盒子是一个列表。bind 的规则:对列表里每个元素应用函数,然后把结果”摊平”。

1
2
[1,2,3] >>= \x -> [x, x*10]
-- 结果:[1, 10, 2, 20, 3, 30]

这其实就是 flatMap。你在 PyTorch 里用过的很多操作背后有类似结构。


IO Monad 处理有副作用的计算

这是 Haskell 最著名的设计。盒子是”一个会产生副作用的动作”。bind 把动作串联起来,但副作用被封装在盒子里,不会污染纯函数世界


第三层:它们的共同结构

三个 Monad 看起来完全不同,但它们满足同样的三条规律(Monad Laws):

  1. return a >>= ff a(左单位元)
  2. m >>= returnm(右单位元)
  3. (m >>= f) >>= gm >>= (\x -> f x >>= g)(结合律)

这三条规律保证了 bind 的行为是可预测、可组合的。满足这三条,你就造了一个 Monad。


Maybe Monad,用 Python 写

先定义”盒子”:

1
2
3
4
5
6
7
8
9
10
11
class Maybe:
def __init__(self, value):
self.value = value # None 表示失败

def bind(self, func):
if self.value is None:
return Maybe(None) # 失败就一直传递失败
return func(self.value) # 成功就把值交给下一个函数

def __repr__(self):
return f"Maybe({self.value})"

然后定义几个可能失败的函数:

1
2
3
4
5
6
7
8
9
10
def get_user(user_id):
users = {1: "Alice", 2: "Bob"}
return Maybe(users.get(user_id)) # 找不到就是 Maybe(None)

def get_profile(username):
profiles = {"Alice": {"age": 30}}
return Maybe(profiles.get(username))

def get_score(profile):
return Maybe(profile.get("age"))

现在串联起来:

1
2
3
4
5
result = get_user(1).bind(get_profile).bind(get_score)
print(result) # Maybe(30)

result = get_user(99).bind(get_profile).bind(get_score)
print(result) # Maybe(None) 中间失败了,后面全部跳过

关键点

.bind() 做了什么?

1
2
3
盒子.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
2
3
4
5
6
7
8
9
10
11
12
13
class MyList:
def __init__(self, values):
self.values = values

def bind(self, func):
# 对每个元素应用函数,每次得到一个列表,然后摊平
result = []
for v in self.values:
result.extend(func(v).values)
return MyList(result)

def __repr__(self):
return f"MyList({self.values})"

用一下:

1
2
3
4
5
def double_or_triple(x):
return MyList([x * 2, x * 3])

result = MyList([1, 2, 3]).bind(double_or_triple)
print(result) # MyList([2, 3, 4, 6, 6, 9])

每个元素都被展开了,然后拼在一起。


这就是 flatMap

你在 Python/JavaScript/PyTorch 里见过的 flatMap,本质就是 List Monad 的 bind:

1
2
3
4
5
# 普通 map:一个进,一个出
[1, 2, 3] → map(double_or_triple) → [[2,3], [4,6], [6,9]]

# flatMap:一个进,多个出,然后摊平
[1, 2, 3] → flatMap(double_or_triple) → [2, 3, 4, 6, 6, 9]

bind 就是 flatMap。这不是巧合,这就是同一件事。


一个更有意思的例子:组合所有可能性

1
2
3
4
5
6
def to_suits(card):
return MyList([f"{card}♠", f"{card}♥", f"{card}♦", f"{card}♣"])

result = MyList(["A", "K", "Q"]).bind(to_suits)
print(result)
# MyList(['A♠', 'A♥', 'A♦', 'A♣', 'K♠', 'K♥', 'K♦', 'K♣', 'Q♠', 'Q♥', 'Q♦', 'Q♣'])

List Monad 自动帮你做了所有组合的展开

这在 Haskell 里经常用来替代嵌套循环 你不需要写两层 for,只需要两次 bind。


现在回头看 Maybe 和 List 的共同结构

1
2
Maybe 的 bind:取出值 → 应用函数 → 如果中途失败就短路
List 的 bind:取出值 → 应用函数 → 把所有结果摊平拼接

两个完全不同的”额外逻辑”,但接口完全一样:.bind(func)

这就是为什么 Monad 是一个抽象 不同的盒子,不同的额外语境,但组合的方式是统一的。


三个 Monad 的对比

盒子装的是 bind 做的额外事情
Maybe 可能有值,可能没有 失败就短路,不再执行
List 多个可能的值 展开所有结果并摊平
IO 一个待执行的动作 把动作按顺序串联起来


IO Monad

IO Monad 是最难的一个,但我们慢慢来。

先问一个问题

Haskell 是纯函数式语言,意味着:

同样的输入,永远得到同样的输出。函数不能有副作用。

但是……程序总要读键盘、写文件、打印东西吧?

这些操作天然是有副作用的 你调用 print("hello") 之后,世界就不一样了。

Haskell 怎么解决这个矛盾?


关键思路:把”动作”本身变成值

Haskell 的解法不是”允许副作用”,而是:

不要执行副作用,而是描述它。

1
2
3
4
5
6
# 普通思路:直接执行
print("hello") # 这一行执行时,副作用就发生了

# IO Monad 思路:返回一个"描述"
def io_print(s):
return IO(lambda: print(s)) # 这只是一个待执行的动作,还没发生任何事

IO(lambda: print(s)) 是一个盒子,盒子里装的是一个动作的描述,而不是动作的结果。


用 Python 模拟 IO Monad

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class IO:
def __init__(self, action):
self.action = action # 装着一个函数,还没执行

def bind(self, func):
# 把两个动作串联:先执行 self,把结果交给 func,得到新动作
def new_action():
result = self.action() # 执行第一个动作
next_io = func(result) # 把结果交给 func,得到下一个 IO
return next_io.action() # 执行下一个动作
return IO(new_action)

def run(self):
return self.action() # 只有这里才真正执行

定义几个基本 IO 动作:

1
2
3
4
5
6
7
8
def io_print(s):
return IO(lambda: print(s))

def io_input(prompt):
return IO(lambda: input(prompt))

def io_pure(value):
return IO(lambda: value) # 纯值包进 IO,什么副作用都没有

串联起来:

1
2
3
4
5
6
program = (
io_input("你叫什么名字?")
.bind(lambda name: io_print(f"你好,{name}!"))
)

program.run() # 在这一行之前,什么都没发生

关键:副作用被推迟到最后

1
2
3
定义 io_input(...)    → 只是描述"我要读输入",没有真正读
.bind(io_print(...)) → 只是描述"然后打印",没有真正打印
.run() → 这里才真正执行所有动作

整个程序构建阶段是纯的 你只是在拼装描述。副作用被推到了最边缘,由 run() 统一触发。

在 Haskell 里,run() 就是 main 整个程序就是一个巨大的 IO 值,Haskell 运行时负责执行它。


现在三个 Monad 放在一起看

1
2
3
4
5
6
7
8
# Maybe:失败短路
get_user(1).bind(get_profile).bind(get_score)

# List:展开所有可能
MyList(["A","K","Q"]).bind(to_suits)

# IO:串联动作,推迟副作用
io_input("名字?").bind(lambda name: io_print(f"你好{name}"))

接口完全一样,.bind() 而已。

但三个盒子装的东西完全不同,bind 内部的逻辑也完全不同。


现在可以给出完整定义了

Monad 是一个满足三件事的盒子:

  1. 能把普通值装进去(pure / return
  2. 能把盒子里的值取出来交给函数,返回新盒子(bind
  3. 这个过程满足结合律 串联的顺序不影响结果

就这些。没有魔法。


总结

你可能遇到的问题 用哪个 Monad bind 帮你处理了什么
链式操作中途可能失败 Maybe 自动短路,不用写 if-None
每步产生多个可能结果 List 自动展开组合,不用写嵌套循环
需要副作用但想保持纯函数 IO 把副作用封装成值,推迟到最后执行

Monad 是一种可组合的计算语境

语境就是盒子里那个”额外的东西” 失败的可能、多个结果、待执行的副作用。Monad 让你用统一的方式组合它们,而不是每次都手动处理。