单子Monad
这篇简单讲解一下单子(Monad)的基本概念。更高阶的概念和功能会在后续的文章里介绍。
单子(Monad)和应用函子(Applicative Functor)
上回我们提到,为了实现Int -> Char -> String
到Maybe Int -> Maybe Char -> Maybe String
的升格,应用函子(applicative functor)通过<*> :: Maybe (Char -> String) -> Maybe Char -> Maybe String
来提供升格操作。今天我们介绍的单子(Monad),则通过(>>=) :: Maybe Char -> (Char -> Maybe String) -> Maybe String
来提供升格操作。
更一般的,在Haskell中两者分别是
(<*>) :: Functor f => f (a -> b) -> f a -> f b -- applicative functor
(>>=) :: Functor f => f a -> (a -> f b) -> f b -- monad
为什么需要单子,应用函子的局限在哪里呢?观察上面的<*>
,它最大的问题在于计算逻辑是第一步就决定了的,换言之,它需要计算逻辑作为输入。而单子则以函子为输入,意味着在取得原函子的实例后,还能根据这个实例决定要做什么计算逻辑。下面这个例子(计算边长1到10的所有矩形的面积)展示了单子的优势,
[1..10] >>= \x ->
[x..10] >>= \y ->
return (x * y) -- return把表达式装进函子(这里是列表)中
注意这个例子里的第二个列表,取x
作为起始来避免重复计算相同的面积。这是应用函子做不到的,
fmap (*) [1..10] <*> [???..10]
根本原因在于乘法*
所定义的操作在一开始就被包裹进了列表函子里(也就是上文中的f (a -> b)
),这样就没有办法根据x
的值再去确定第二个列表(也就是上文中的f a
)该怎么写了。
再值得注意的一点是,>>=
实际上还定义了严格的计算顺序。它隐含表达了f b
所包含的计算,必须在f a
的计算完成之后,并且这两个计算操作一定会发生。比如我们有两个IO函数,
getLine :: IO String -- IO是个函子
putStrLn :: String -> IO () -- 表示IO函子中不存放任何信息,副作用是打印String到命令行
如果我们用>>=
连接两次getLine
操作,
readLine >>= \x ->
readLine >>= \y ->
putStrLn x
这里即便最后putStrLn x
没有用到y
,第二个readLine
语句也不会被编译器优化掉,这是因为从>>=
的类型定义看,putStrLn x
依赖于它(f b
)前面的y
(a
)。而严格的计算顺序和保证执行,正是IO和State操作所必要的两个性质。
State单子
现实中,很多操作需要改变系统状态。比如伪随机数生成器,每次产生一个数的同时,会改变seed,也就是getRandom :: seed -> (a, seed)
。在Haskell中,涉及状态转换的操作是通过State单子来实现的。以下是State函子的定义,
newtype State s a = State { runState :: s -> (a, s) }
这里有几个概念需要解释一下,
- 沿用之前函子的写法
f a
,State函子的f
对应于State s
,State s
所包装的值是a
; -
s
和a
是类型变量,可以指代任意的类型; - 等号右边的
State
是构造函数,这个newtype
定义的意思是,可以通过传给State
构造函数一个s -> (a, s)
类型的状态更新函数,构造出State函子; -
runState
是Haskell记录语法中的提取函数,如果已经有了一个State实例,比如st
,那么可以用runState st
来得到包裹在st
中的那个s -> (a, s)
类型的函数。换言之,runState :: State s a -> s -> (a, s)
。
State s
单子的定义如下,
instance Monad (State s) where
return = pure
-- (>>=) :: State s a -> (a -> State s b) -> State s b
fa >>= f = State $ \s ->
let (a, s') = runState fa s
in runState (f a) s'
可见,a -> State s b
函数(也就是f
),可以通过>>=
串联在一起。串联中的每一个f
会取得前一个f_prev
更新后的状态s'
,然后作为f
的状态更新函数的输入。注意到>>=
本身并不做计算,而只是把状态转换以函数的形式(也就是包裹在State单子里的s -> (a, s)
)串联起来了。计算只有在给到了初始状态State s a
的时候才会真实发生。
Enjoy Reading This Article?
Here are some more articles you might like to read next: