这篇简单讲解一下单子(Monad)的基本概念。更高阶的概念和功能会在后续的文章里介绍。

单子(Monad)和应用函子(Applicative Functor)

上回我们提到,为了实现Int -> Char -> StringMaybe 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)前面的ya)。而严格的计算顺序和保证执行,正是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 sState s所包装的值是a
  • sa是类型变量,可以指代任意的类型;
  • 等号右边的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的时候才会真实发生。