Monad 初探

引入

通过课上的例子我们可以看到,对于Sheep -> Maybe Sheep这样的操作的序贯组合,使用朴素地匹配Nothing会导致代码冗余且可读性降低。为此我们想到可以将对Nothing的处理放入函数内部处理。但对于所有的函数都进行类似的修改也是极其不方便的,因此我们将这一类操作抽象出来,放在>>=里实现。此时Maybe也由Applicative进化为了Monad。

何为Monad

详细了解Monad需要对范畴论有一定的了解。正如haskell圈里那句经典的"A monad is just a monoid in the category of endofunctors, what's the problem?"。对于有范畴论背景知识的人来说,从“自函子范畴上的幺半群”来理解确实可能更为自然,但对于小白与初学者来说,补充范畴论的相关知识确实也是一件代价高昂的事,因此我们用haskell的语言来了解这个概念,等到有需要的时候再去更深入的了解。

为了更深入的了解haskell中Monad这个类型类,首先我们使用:i Monad查看其具体的定义如下

1
2
3
4
5
6
class Applicative m => Monad (m :: * -> *) where
(>>=) :: m a -> (a -> m b) -> m b
(>>) :: m a -> m b -> m b
return :: a -> m a
fail :: String -> m a
{-# MINIMAL (>>=) #-}

可以看到其依存于Applicative,我们一步步溯源回去见到了Applicative和Functor

1
2
3
4
5
6
7
8
9
10
11
12
class Functor (f :: * -> *) where
fmap :: (a -> b) -> f a -> f b
(<$) :: a -> f b -> f a
{-# MINIMAL fmap #-}

class Functor f => Applicative (f :: * -> *) where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b
GHC.Base.liftA2 :: (a -> b -> c) -> f a -> f b -> f c
(*>) :: f a -> f b -> f b
(<*) :: f a -> f b -> f a
{-# MINIMAL pure, ((<*>) | liftA2) #-}

我们首先直接解读Monad的定义

  • Monad从Applicative里引申过来,继承了fmap,pure和<*>,因而需要额外特殊实现的只有>>=(bind)。
  • return和Applicative里的pure并无太大差别。
  • >>=使我们可以关注于当前的Monad的值而忽略之前的结果。
  • fail则提供了一个处理错误的机制。

也就是说,Monad在Applicative的基础上新增了bind操作。为了理解这个引入的好处,我们需要整体梳理一下这三个类型类的作用。 (注:以下的论述中,我将a -> f a叫做类型的提升,也有不同的叫法与理解如上下文、环境、盒子等等。对于Maybe、List这样的Monad,我个人觉得类型提升的角度比较好理解,而对于Writer Monda,State Monad等,上下文这种理解可能更加直观。然而他们完全是一样的)

Functor有一个好:fmap将a -> b提升到了f a -> f b上,使我们不必为每一种类型地提升都去特定地去迁移已有的函数。 Applicative有一个好:它使得多元函数(而非简单的a -> b)也得以被提升。这是因为

1
2
3
4
  pure (a1 -> a2 -> ... -> an) <*> f a1 <*> f a2 <*> ... <*> f an-1
= f (a1 -> a2 -> ... -> an) <*> f a1 <*> f a2 <*> ... <*> f an-1
= f (a2 -> ... -> an) <*> f a2 <*> ... <*> f an-1
= f an

当然,若是没有<*>的实现,使用liftA2的效果是一样的。 通过这个例子我们可以看到,Applicative其实是Functor的一个多元拓展。 而Monad的好我们在开头已经看到了,它通过>>=将序贯操作组合起来,将类型的匹配与变换规则写入>>=的实现中,使得我们不用在序贯操作中频繁的进行类型的匹配与检查。

总而言之,三者分别实现了这样的功能

  • Functor使得提升后的类型能够被原类型的一元函数所作用,并返回一个提升后的类型
    • fmap (+1) (Just 1) 中,+1这个一元函数从Int->Int提升至Maybe Int->Maybe Int,然后接受Maybe Int并返回计算后的Maybe Int
  • Applicative使得提升后的类型能够被原类型的多元函数所作用,并返回一个提升后的类型
    • pure (+) <*> (Just 3) <*> (Just 5)+这个二元函数从Int->Int->Int提升至Maybe Int->Maybe Int->Maybe Int,然后接受Maybe IntMaybe Int并返回计算后的Maybe Int
  • Monad则使得提升后的类型能够被原类型到提升后的类型的一元函数作用。这么做有一个好:将其视为黑箱的话,输入和输出都是提升后的类型,这也是为什么可以很简洁地写出序贯操作。

Monad对于Applicative的加强

Monad允许参数间的相互影响,这是Applicative所无法做到的。一个典型的例子就是List Comprehension,即

1
2
[(x,y)|x<-[1..5],y<-[1..x]]
[1..5] >>= \x->[1..x] >>= \y->return(x,y)

这个特性也算是Monad对于Applicative的一个优越性:计算的值可以依赖于前面的结果。

Monad还有什么用

提升类型有什么好处呢?Maybe或者List这样的提升是很自然也很实用的做法;而另一大想法就是利用其存储额外的信息/状态。而且这使得副作用得以实现(并被很好的隔离开来)。典型的应用如Writer Monad,State Monad, IO Monad。这些在后续的课程中都会有更详细的学习,在这里就先不做过多的介绍了。

*范畴论中的单子

简单说,范畴论并不关心具体的函数对象,而关心对象间的态射及其组合。一个范畴由一组对象和一组态射组成。对于任意两个对象\(A\rightarrow B\)都存在一个态射的集合\(C(A,B)\)。而这些态射需要满足复合运算、结合律并对任意对象\(A\)\(C(A,A)\)中存在单位态射。

而Functor本质上是范畴间的态射。一个Functor将范畴\(C\)里的对象和态射射映射到范畴\(D\)上,并且保留单位态射与态射间的复合关系。借用Mabye来思考这个问题是有助于理解的。

自函子则是范畴\(C\)到其自己的函子,并且自函子可以自身复合。

让我们再回头看开头的名言:单子是自函子范畴上的幺半群。我们先想象一下范畴上的幺半群:它只有一个对象\(A\),并且有无数\(A\rightarrow A\)的“自环”——也就是态射,而这些态射的复合就是这个幺半群的运算。现在让我们把对象\(A\)设定为一个范畴\(C\),而这些态射就是所谓的自函子。这些态射中由一个单位自函子\(I\),一个自函子\(T\)及其若干阶复合\(T\cdot T,T\cdot T\cdot T,\ldots\)。这上面存在着两个自然变换(可以理解为函子到函子的态射):一个是\(I\rightarrow T\),对应着haskell里的return。另一个是\(T\cdot T->T\)(在范畴论里这称做join). 在haskell里面我们并没有看到这个操作,但事实上它可以由>>=实现。

1
2
3
> join x = x >>= id
> :t join
join :: Monad m => m (m b) -> m b

如果使用之前盒子的比喻的话,二者一个进行着套盒子的操作,另一个进行着脱盒子的操作。此时Monad作为自函子的幺半群的性质就显现出来了。