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 | class Applicative m => Monad (m :: * -> *) where |
可以看到其依存于Applicative,我们一步步溯源回去见到了Applicative和Functor
1 | class Functor (f :: * -> *) where |
我们首先直接解读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 | pure (a1 -> a2 -> ... -> an) <*> f a1 <*> f a2 <*> ... <*> f an-1 |
当然,若是没有<*>的实现,使用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 Int
和Maybe Int
并返回计算后的Maybe Int
- Monad则使得提升后的类型能够被原类型到提升后的类型的一元函数作用。这么做有一个好:将其视为黑箱的话,输入和输出都是提升后的类型,这也是为什么可以很简洁地写出序贯操作。
Monad对于Applicative的加强
Monad允许参数间的相互影响,这是Applicative所无法做到的。一个典型的例子就是List Comprehension,即
1 | [(x,y)|x<-[1..5],y<-[1..x]] |
这个特性也算是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 | > join x = x >>= id |
如果使用之前盒子的比喻的话,二者一个进行着套盒子的操作,另一个进行着脱盒子的操作。此时Monad作为自函子的幺半群的性质就显现出来了。