Monad 祛魅:do 只是语法糖
互联网上有个经久不衰的梗:每个学会 Monad 的人都会写一篇「Monad 其实就是 X」的教程,而每篇这样的教程都让下一个学习者更困惑。本章约法三章:不用隐喻(不是墨西哥卷饼,不是太空服),不提范畴论,只做两件事——用类型推导出那个缺失的函数,然后把 do 语法糖当着你的面拆开。拆完你会发现:你在 Kotlin 里每天写的 flatMap,就是它;你用的 async/await,是它的专用语法。
类型缺口:上一步的结果,决定下一步做什么
Applicative 的三个校验是独立的:谁也不需要谁的结果。但第 8 章的金字塔不是——查地址需要先拿到用户,查邮编需要先拿到地址。把「下一步」写成函数,类型立刻暴露缺口:
lookupUser :: UserId -> Maybe User userAddress :: User -> Maybe Address -- 吃裸 User,吐 Maybe Address -- 手里有:Maybe User -- 想接上:User -> Maybe Address -- fmap 试试?fmap userAddress (lookupUser uid) :: Maybe (Maybe Address) ← 套两层了!
fmap 差一口气:它把整个结果又包了一层,得到 Maybe (Maybe Address)。我们需要的是一个「接上并拍平」的函数,签名照着需求写就是:
(>>=) :: m a -> (a -> m b) -> m b -- 读作 bind:「拆开左边,喂给右边,不额外套盒」 class Applicative m => Monad m where (>>=) :: m a -> (a -> m b) -> m b return :: a -> m a -- 历史遗留,就是 pure 的别名
这就是 Monad 的全部定义。Maybe 的实例,你甚至已经会写了——它就是金字塔每层重复的那段逻辑,抽出来而已:
instance Monad Maybe where Nothing >>= _ = Nothing -- 上一步空了,下一步不用做 (Just x) >>= f = f x -- 上一步有货,拆开喂给下一步
于是第 8 章的金字塔,坍缩成一条链:
postcode :: UserId -> Maybe Postcode postcode uid = lookupUser uid >>= userAddress >>= addrPostcode
九行变一行,而且每个 >>= 处的短路逻辑一分没丢——它被搬进了实例定义里,写一次,处处生效。这就是 Monad 的全部内容:「把带上下文的步骤串起来」这个模式的官方接口——一个类型类,两个方法,和 Eq、Ord 住同一条街。没有更多了。
do:给链式调用一个人类的排版
链再长一点,lambda 嵌套还是会难看。Haskell 给 >>= 配了专用排版语法 do——注意,是排版,不是新语义。下面的脱糖器是真实的语法变换器(和 GHC 干的事一致),把 do 块拆回 >>= 给你看。三个预置例子都过一遍,特别注意 <- 变成了什么:
规则一共三条,对照着脱糖结果自己归纳一遍:x <- action 变成 action >>= \x ->(「把结果绑给 x」原来是 lambda 参数);let y = e 变成普通 let;单独一行的表达式变成 >>(执行但丢弃结果的 bind)。GHC 编译你的每个 do 块时做的就是这三条替换,没有第四条——「魔法」的尸检报告全文完。金字塔的最终形态:
postcode uid = do user <- lookupUser uid addr <- userAddress user addrPostcode addr
长得像命令式代码,读起来像命令式代码——但你现在知道它的真身是三个嵌套的 lambda,而「每行之间发生什么」由 Maybe 的 >>= 决定:任何一行 Nothing,后面的 lambda 根本不被调用,因为那个 lambda 压根没被喂参数。Kotlin 的 ?. 链是这个东西的单一场景特化;do 是它的通用形态。
杀手锏:同一个 do,换个 Monad 换种人生
do 的语义取决于「谁的 >>=」,这才是抽象的分红时刻:
λ> do { x <- [1,2,3]; y <- "ab"; return (x, y) } -- 列表的 >>=:每种组合都试
[(1,'a'),(1,'b'),(2,'a'),(2,'b'),(3,'a'),(3,'b')]
λ> do { x <- Just 1; y <- Just 2; return (x + y) } -- Maybe:短路管道
Just 3
λ> do { x <- Right 1; y <- Left "坏了"; return (x + y) } :: Either String Int
Left "坏了" -- Either:带原因的短路
同一套排版:列表里它是笛卡尔积/回溯搜索(嵌套 for 循环的声明式版),Maybe 里是判空管道,Either 里是铁路,第 11 章的 IO 里是「顺序执行副作用」,第 12 章的 State 里是「自动穿线的状态机」。学一次语法,收获 N 种控制流——这就是 Monad 接口值得存在的全部理由,也是它被抄进各语言时最常丢失的部分(下游通常只抄某一个实例)。
flatMap 全家:Kotlin/Java 的 Optional.flatMap、List.flatMap、Rust 的 and_then、RxJava 的 flatMap——签名对一下,全是 >>= 换了参数顺序。async/await:JS 的 const x = await fetchX(); const y = await fetchY(x) 就是 Promise 这个(近似)Monad 的 do 语法——await 是 <-,函数体是脱糖前的 do 块。C# 团队设计 async/await 时明确引用了 Haskell 的 do;Kotlin 协程的 suspend 编译成 CPS,同一族技术。你不是在学新东西,是在给旧技能找到通用形态:下游每种「链式上下文」单独发明一套语法(?. 一套、await 一套、flatMap 一套),上游用一个接口全收编。
三条定律,本质都是「链的行为要讲理」:左单位元(return x >>= f ≡ f x,先装盒再拆盒等于白忙)、右单位元(m >>= return ≡ m,拆了又原样装回等于没动)、结合律(链的分组方式不影响结果——所以 do 块可以任意抽出子函数做重构)。第三条最值钱:它保证「把 do 块中间三行提取成辅助函数」永远是安全重构。你重构 await 代码时默认依赖的也是它,只是 Promise 并不完全守约(then 的自动拍平在边角情况破坏定律),偶尔咬人。
Monad 不是什么:误解排雷
祛魅的另一半是把常见误解逐个排掉,每条都有受害者:
「Monad 是容器」——Maybe、列表碰巧像容器,但 IO 装的是行动、State 装的是函数、Reader 装的是依赖读取。通用的说法是「上下文」:值旁边多出来的那圈语义。「Monad 是关于副作用的」——七个经典 Monad 里只有 IO 和副作用有关;Maybe/列表/Either 纯得发亮。「do 是命令式模式」——上一章末尾拆过了,它是嵌套 lambda 的排版。「学 Monad 要先学范畴论」——因果倒置:范畴论给了名字和定律的出处,但你刚才全程只用了类型推导;先用后溯源,和你用 HashMap 不必先学摊还分析一样正当。
还有一个值得专门立牌的视角转换:>>= 其实可以拆成两个更小的零件——fmap 你已经有了,再加一个「拆掉一层嵌套」的 join :: m (m a) -> m a:
m >>= f ≡ join (fmap f m) -- fmap f m :: m (m b) ← 第 9 章卡壳时的那个双层盒子 -- join :: 把双层拍成单层 λ> join (Just (Just 3)) -- Maybe 的 join:拆一层 Just 3 λ> join [[1,2],[3]] -- 列表的 join:concat! [1,2,3]
所以 Monad 相对 Functor 的全部增量就是 join——「嵌套的上下文可以塌缩」。这也精确解释了第 9 章 Promise 的那桩公案:then 自动调用了 join,把 fmap 和 bind 焊死,于是 Promise 世界永远分不出「变换」和「接续」。一个 join,看清两门语言的设计分歧。
它为什么被神化了三十年
技术上你已经全学完了——一个接口、两个函数、三条定律。那这门玄学是哪来的?历史两笔账。第一笔:1990 年代初,纯语言不知道怎么优雅地做 IO(副作用没有顺序,惰性语言连「先打印再读取」都保证不了),Wadler 把范畴论里的 monad 概念引进来解了这个死局(下一章的主角)——「从数学空降」的出身给了它学术光环。第二笔:教程作者们试图用隐喻降低门槛(卷饼!管道!太空服!),隐喻只能传达七成,剩下三成的偏差在读者组合使用时集中爆雷,于是「学不会」的名声越滚越大。
而你走的这条路——metaphor-free:类型缺口 → 推导签名 → 三个实例 → 脱糖——是社区多年试错后公认的正道。如果一定要一句人话总结,用这句最不坏的:Monad 是「可编程的分号」——语句之间发生什么(判空?短路?回溯?穿线?),由实例定义,而不是由语言写死。带着这句话去看下一章:IO 的分号被编程成「顺序执行副作用」,你会看到一门纯语言如何借此重新发明「命令式编程」,而且发明得比原版更可控。
do 的外表容易让人手痒做「命令式重构」:交换两行、提到循环外……记住两点。其一,x <- a 的本质是嵌套 lambda,后面的行活在前面结果的作用域里,交换顺序通常直接编译不过(比命令式安全)。其二,Monad 之间不能随便混用:一个 do 块里只能住一种 Monad,「在 Maybe 的 do 里 await 一个 IO」是类型错误——需要跨层时得用变换器(monad transformer),那是本书刻意不深入的深水区,第 20 章的航线图会标注去处。
1. 把这段 Kotlin 翻译成 do:userRepo.find(id)?.let { it.primaryCard }?.let { billing.charge(it, 100) }。哪个 Monad?每个 ?. 对应哪一行?
2. 在脱糖器里输入一个四行 do(至少含一个 let、一个 <-、一个裸表达式),先手写预期结果再对答案。
3. 用列表 Monad 的 do 写「三重循环」:找出所有满足 a² + b² = c²、且 a<b<c≤20 的勾股三元组。(第 2 章终端支持 do 吗?不支持——正好,写成 >>= 链再喂给它。)
4. 验证左单位元:在终端分别求 Just 5 >>= (\x -> Just (x*2)) 的手工展开值。(终端没有 >>=?对——你刚好可以按 Maybe 实例的定义在纸上「当一次求值器」。)
5. join 视角复健:flatMap 这个名字直译是「先 map 后拍平(flatten)」。用 join (fmap f m) 的等式解释这个名字为什么起得精准,再想想 Kotlin 的 flatten() 单独存在意味着什么。