Haskell 简明教程
Monad 这个概念被赋予过很多比喻,但它的定义并不复杂:它描述了如何把一系列会产生上下文的计算串联起来,并在每一步自动把嵌套的上下文拍平。
假设我们在写一个订单查询流程。每一步都可能因为数据不存在而返回 Maybe:
getUser :: String -> Maybe User
getOrder :: User -> Maybe Order
getPay :: Order -> Maybe Payment
如果只用 fmap:
fmap getOrder (getUser "Alice")
-- 返回类型变成了 Maybe (Maybe Order)
继续链式调用,返回值就会变成 Maybe (Maybe (Maybe Payment))。Functor 和 Applicative 都没有办法把“产生新上下文的函数”与“已有的上下文”拍平。解决这个问题的就是 Monad。
Monad 是 Functor 和 Applicative 之上的第三层抽象,每一层在前一层的基础上增加一个核心操作:
graph BT
F["Functor<br/>fmap :: (a -> b) -> f a -> f b"]
A["Applicative<br/>pure :: a -> f a<br/>(<*>) :: f (a -> b) -> f a -> f b"]
M["Monad<br/>(>>=) :: m a -> (a -> m b) -> m b"]
A --> F
M --> A
箭头方向表示“要成为 Monad 必须先是 Applicative,要成为 Applicative 必须先是 Functor”。
定义在标准 Prelude 中:
class Applicative m => Monad m where
-- 将普通值放入最小上下文(等价于 Applicative 的 pure)
return :: a -> m a
return = pure
-- 绑定操作符(Bind)
(>>=) :: m a -> (a -> m b) -> m b
-- 忽略前项返回值的顺序执行
(>>) :: m a -> m b -> m b
m >> k = m >>= \_ -> k
>>=(读作 Bind)的职责是:从上下文 m a 中取出值 a,传给会产生新上下文的函数 (a -> m b),并把结果保持在同一层,不产生嵌套。
Monad 也可以通过 join 函数来理解:
join :: Monad m => m (m a) -> m a
join 把两层相同的上下文拍平成一层(例如把 [[a]] 拍平为 [a],把 Just (Just x) 拍平为 Just x)。m >>= f 等价于 join (fmap f m)。
连续书写 >>= 与匿名函数会让代码不断向右缩进。Haskell 提供了语法糖 do 记号,让有先后依赖的计算可以按命令式的顺序书写:
getFinalPayment :: String -> Maybe Payment
getFinalPayment name = do
user <- getUser name
order <- getOrder user
payment <- getPay order
return payment
编译器对 do 块的脱糖规则是机械的:
带箭头提取:x <- m; rest 展开为 m >>= \x -> rest
不带提取的顺序执行:m1; m2 展开为 m1 >> m2
局部纯计算:let x = val 展开为普通的局部绑定。
所以上面的 do 代码在底层就是一条普通的 >>= 链:
getFinalPayment name =
getUser name >>= \user ->
getOrder user >>= \order ->
getPay order
小技巧
如果你熟悉其他语言:把 Monad 理解为 Chainable / FlatMappable / AndThen-able:
直觉:FlatMappable 或 Chainable,即支持自动拍平的、有前后依赖的流水线。核心操作是 >>=(即 flatMap)。
各语言中的动词:
Elm:直接叫 andThen。前一步计算产生值后,“然后(and then)”把该值交给下一步,生成新的包装上下文。
Rust:标准库的 Option::and_then(...) 与 Result::and_then(...)。常用的 ? 错误传播操作符本质上也是在 Result 单子中做带短路的 >>=。
JavaScript / TypeScript:Promise.prototype.then(...)。在 then 的回调中返回一个新 Promise,运行时会自动把它展平,不会产生 Promise<Promise<T>>。这正是 Monad 拍平嵌套上下文(join)的能力。
Java / Scala:Optional.flatMap(...)、Stream.flatMap(...)、CompletableFuture.thenCompose(...)。
盒子的三个层次(Functor、Applicative、Monad):
Functor(map):单个盒子。函数只作用于盒子内部的值,盒子结构原样保留。
Applicative(andMap / map2):多个相互独立的盒子。把各个盒子里的值凑到一起,盒子之间没有先后依赖。
Monad(andThen / flatMap):有前后依赖的盒子。后一个盒子的创建依赖前一个盒子解开后的值,因此支持动态决策与短路。
“可编程的分号”:
在 C、Java 或 Go 中,语句间的分号 ; 只表示顺序执行。
在 Haskell 的 do 记号中,换行与分号之间可以插入上下文相关的逻辑。进入下一行之前,单子会先做自己的处理:在 Maybe 中判断是否为空并短路,在 Either 中传递错误,在 State 中传递状态,在 IO 中安排副作用的顺序。
在 do 记号中,如果箭头左侧写的是可失败的模式(例如 Just x <- action),匹配失败时编译器会脱糖为调用 fail:
-- 脱糖逻辑:
action >>= \x -> case x of
Just v -> rest
_ -> fail "Pattern match failure in do expression"
早期 fail 是 Monad 类型类的方法,但很多单子并没有合理的失败语义,只能用 error 实现。从 GHC 8.8 起,fail 被移到独立的 Control.Monad.Fail.MonadFail 类型类中。这样一来,只有实现了 MonadFail 的单子(如 Maybe、IO、列表)才允许在 do 块中写可失败的模式,其他单子里这样写会直接编译报错。
合法的 Monad 实例需要满足以下三条法则:
左单位元(Left Identity):
return x >>= f == f x
右单位元(Right Identity):
m >>= return == m
结合律(Associativity):
(m >>= f) >>= g == m >>= (\x -> f x >>= g)
这些法则保证了在重构、拆分辅助函数时,计算的行为不会改变。
实际代码里很少直接写 >>=,更多是使用 Control.Monad 模块提供的组合子。下面按“要解决什么问题”分组介绍,签名都写成列表特化后的版本。贯穿的例子是一个小任务:从输入读三行分数,每行都要是 0 到 100 的整数,全部合法且都及格时才写入文件。
先准备一个校验单行的函数:
import Text.Read (readMaybe)
parseScore :: String -> Either String Int
parseScore s = case readMaybe s of
Just n | n >= 0 && n <= 100 -> Right n
_ -> Left ("非法分数: " ++ s)
有了 parseScore,校验一整个列表就是 mapM:对每个元素执行一个返回单子的函数,把结果收集成列表。任何一个元素失败,整体就失败:
mapM :: Monad m => (a -> m b) -> [a] -> m [b]
mapM_ :: Monad m => (a -> m b) -> [a] -> m () -- 忽略返回值,仅保留效果
ghci> mapM parseScore ["90", "75", "60"]
Right [90,75,60]
ghci> mapM parseScore ["90", "x", "60"]
Left "非法分数: x"
forM 是 mapM 参数翻转后的版本(forM = flip mapM)。循环体较长时,把列表写在前面读起来更像其他语言的 for 循环,所以 IO 代码里更常见的是 forM_:
forM_ scores $ \s -> putStrLn ("分数: " ++ show s)
sequence 处理的是已经拿在手里的一列单子动作,把 [m a] 变成 m [a],等价于 mapM id:
ghci> sequence [Just 1, Just 2, Just 3]
Just [1,2,3]
ghci> sequence [Just 1, Nothing, Just 3]
Nothing
这三个函数在现代 base 里只是 traverse、for_、sequenceA 限定在 Monad 上的别名,下一章会介绍更通用的版本。
在 do 块中,如果只想在满足条件时执行某个动作,写 if cond then action else return () 比较啰嗦。when 与 unless 是这个写法的简写:
when :: Applicative f => Bool -> f () -> f ()
unless :: Applicative f => Bool -> f () -> f ()
replicateM n act 把一个动作执行 n 次并收集结果,读固定行数的输入、生成若干个随机数都用它。forever 则无限重复一个动作,常用于服务器主循环或后台线程:
replicateM :: Applicative m => Int -> m a -> m [a]
forever :: Applicative f => f a -> f b
把上面几个函数放到一起,就是完整的任务:
import Control.Monad (replicateM, forM_, when, unless)
main :: IO ()
main = do
ls <- replicateM 3 getLine -- 重复执行:读三行
case mapM parseScore ls of -- 对每个元素校验,一个失败全失败
Left err -> putStrLn err
Right scores -> do
forM_ scores $ \s -> putStrLn ("分数: " ++ show s)
when (all (>= 60) scores) $ -- 条件执行
writeFile "pass.txt" (unlines (map show scores))
unless (all (>= 60) scores) $
putStrLn "有人不及格,不写文件"
$ printf '90\n40\n60\n' | runghc Scores.hs
分数: 90
分数: 40
分数: 60
有人不及格,不写文件
$ printf '90\nx\n60\n' | runghc Scores.hs
非法分数: x
foldM 是 foldl 的单子版本,累加函数可以失败或带效果。比如累加总分,超过上限就中止:
foldM :: Monad m => (b -> a -> m b) -> b -> [a] -> m b
addBounded :: Int -> Int -> Either String Int
addBounded acc s
| acc + s > 250 = Left "总分超出上限"
| otherwise = Right (acc + s)
ghci> foldM addBounded 0 [90, 75, 60]
Right 225
ghci> foldM addBounded 0 [90, 95, 99]
Left "总分超出上限"
同类的还有 filterM,谓词返回 m Bool。它有一个有名的玩法:利用列表单子表示“多种可能”,filterM (\_ -> [True, False]) [1, 2] 得到 [[1,2],[1],[2],[]],也就是幂集。日常代码里更常见的用法是 filterM doesFileExist paths 这种带 IO 的过滤。
如果两个函数都返回单子上下文(f :: a -> m b 与 g :: b -> m c),普通的 . 无法直接复合它们。Control.Monad 提供的 >=>(有时叫鱼骨操作符)可以把它们接起来:
(>=>) :: Monad m => (a -> m b) -> (b -> m c) -> (a -> m c)
(f >=> g) x = f x >>= g
-- 拼接两个可能失败的函数:
half :: Int -> Maybe Int
half x = if even x then Just (x `div` 2) else Nothing
quarter :: Int -> Maybe Int
quarter = half >=> half
ghci> quarter 8
Just 2
ghci> quarter 6
Nothing
Monad 通过 (>>=) 或等价的 join 解决上下文嵌套的问题。
do 记号是 >>= 链的语法糖,脱糖规则是机械的。
MonadFail 把可失败模式的支持从 Monad 中拆了出来,不支持失败的单子里写这种模式会编译报错。
Control.Monad 的函数按问题分组记:对每个元素做带效果的事用 mapM/forM_,条件执行用 when/unless,重复用 replicateM/forever,带累积用 foldM,复合用 >=>。