上一篇我们提到了State单子可以在不借助全局变量的情况下传递状态,本质上状态s作为local变量在函数调用链中传递。而State单子通过(>>=) :: State s a -> (a -> State s b) -> State s b提供了下述关键功能,

  • State s a及包裹在其中的s -> (a, s)函数实现了状态的提取(和转换),并且取出当前被包装的值a
  • a交给用户定义的(a -> State s b)函数。这个函数生成的也是State单子,并且将新的状态转换函数s -> (a, s)包裹了进去(这里状态转换并没有执行,而只是作为一个函数存在)。

State单子 -> 状态提取/转换 -> 值的提取 -> 给定状态和值,产生新的状态转换函数 -> State单子

上面的过程首尾衔接,形成了一个连续的调用链,而sa在这个调用链中作为local变量传递,消除了全局可变性。本质上,这和以下的函数调用没有区别,

(s1, a1) = func1(s0, a0)
(s2, a2) = func2(s1, a1)
...

只是单子通过>>=提供了自动对sa进行解包和打包的机制,而用户只需要专注实现a -> (s -> (b, s))(本质上也就是上面的func1func2)就可以了。

如果整个调用链中都是State单子,那自然没有问题。但如果要在State单子的调用链中插入其他操作,比如读取IO,怎么办呢?这就要用到下面要讲的单子变换。

单子变换

回忆之前State单子的定义是

newtype State s a = State { runState :: s -> (a, s) }
-- (>>=) :: State s a -> (a -> State s b) -> State s b

在Haskell中,IO操作总是被包裹成一个单子。例如函数getLine :: IO String从输入中读取一个字符串,并且返回单子IO String。如果要在State调用链中插入IO操作,很自然的想法便是将a包裹到IO单子里去,

newtype StateIO s IO a = StateIO { runStateIO :: s -> IO (a, s) }
-- (>>=) :: StateIO s IO a -> (a -> StateIO s IO b) -> State s IO b

注意上面的>>=定义中,解包所得仍然是a;或者说,用户提供的值处理函数,所处理的仍然是a

如此一来,在用户定义的(a -> State s IO b)函数中就可以操作IO了,同时结果中,值也被包裹在了IO单子里。本质上,StateIO单子包含了状态s和值IO a

为了方便编程,通常还会提供一个lift函数用于把IO单子提升为StateIO。如此就可以写这样的代码了,

lift getLine >>= \input ->
	...

-- 或在Haskell中等价于,
do
	input <- lift getLine
	...

把上面讨论的内容一般化,我们可以把IO单子替换为任意别的单子m,得到StateT,

newtype StateT s m a = State { runStateT :: s -> m (a, s) }
instance (Monad m) => Monad (StateT s m) where
	m >>= k = StateT $ \s -> do
		(a, s') <- runStateT m s
		runStateT (k a) s'

注意上面do语句中解包所得的是m的包裹(a, s'),因为此时单子的上下文是m

单子变换(lift)也可以用一个通用的类型类来实现,

class MonadTrans t where
	lift :: Monad m => m a -> t m a

单子变换的自动升格

使用lift的一个问题是,如果存在嵌套的单子变换,那每一处都lift会让代码变得很复杂。例如IO (Int, Int) -> StateT Int (RandT g IO) (Int, Int)(读作StateT s m a)就可能需要两次lift:一次到RandT,再一次到StateT。

为了解决这个问题,Haskell提供了MonadRandom,MonadIO,MonadState等模块。例如,除了RandT g m是MonadRandom之外,模块里还声明了,一切包括了MonadRandom的单子栈,也是MonadRandom类型,

instance (MonadRandom m) => MonadRandom (IdentityT m) where
	getRandom = lift getRandom
	...
instance (MonadRandom m) => MonadRandom (StateT m) where
	getRandom = lift getRandom
	...
...

如此,由于RandT g IO是MonadRandom,按照上面的定义,StateT Int (RandT g IO) (Int, Int)自动也成了MonadRandom了,就可以用到MonadRandom中的方法(例如uniform)了。

可见,为了能在程序中方便地串联Monad,Haskell还是做了很多脏活,把不同的单子类型统一到同一类中,并且试图用各种语法糖把这些工作隐藏起来。Monad在形式上固然优美,实际使用中却也难免一些额外的繁复工作。