在交流中,有时候丢出一个“Monad”就可以把问题说清楚,但是我发现大多数人并不知道什么是monad,甚至有的没听说过。所以特此写一篇通俗易懂的文章来描述编程语言中的Monad。
读这篇文章前,你需要
- 写过java或c#(scala/kotlin等使用者估计也用过java,haskell/ml使用者估计早就知道monad是什么了,而只用过动态类型语言的,也许能理解本文的“是什么”,但不一定能理解“为什么”)
- 知道何为“泛型”
- 了解过异步/非阻塞IO编程
- 读前可以百度(嗯,这个没必要谷歌)查一查“幺半群”、“幺元”,大概花2分钟的时间即可
- [可选]了解一些范畴、对象、态射,大概5分钟的时间即可
本文将以java代码,来构造一个“合格”的Monad,以说清Monad的what/why/how。
What - Monad是什么
图解Monad
首先给大家一个直观的感受,形象的描述一下Monad能干什么,以方便后续的理解:
这是类型T
1 | T |
Monad类似于“罐子”、“箱子”一类的东西,不过为了更加形象,我们把它比做 上下都开口的圆桶 (后面你就知道为什么了)。
我们可以通过一个函数,把T类型的对象
装到Monad里面。通常我们把这个函数称作unit
或者return
。由于return
是一个java关键字,所以后文我们用unit
来称呼这个函数。
1 | Monad---+ |
我们得到了第一个Monad,Monad<T>
。
编程语言里都有函数,我们可以传入一种变量,得到另一种变量。Monad也是如此,我们可以给Monad一种特殊的函数,这种函数接受前一个Monad包含的类型,并返回一个包含了另一类型的新的Monad。
1 | 新Monad |
这样,我们得到了一个新的Monad,即Monad<U>
。
我们可以对这个Monad再进行一次、两次、…、无数次上面的操作:
1 | Monad---+ Monad---+ Monad---+ Monad---+ |
这些Monads被某些“绑定函数”绑在一起,形成了最终的Monad<N>
,就像一个管道一样。
此时务必注意,上面我们讨论的都是“类型”,并不是具体的“对象”。
并且,上面所谓的“Monad”实际上是指“某种Monad”。
我们常听到的、文章里使用的Monad,并非 一种 类型,而是“具有上述特征的” 一类 类型。
代码描述
我们讨论的是Haskell中的Monad,但是相信不少人并不熟悉Haskell,我们直接用java来描述Monad的定义。
1 | // 此处我们暂时不讨论Applicative和Functor |
这里的unit
在haskell中被称作return
,bind
在haskell中记作>>=
,读音为bind。
unit
函数是个static函数,它接收X
类型对象,并把它包裹在Monad里面,返回一个MyMonad<X>
类型对象。bind
方法接收一个“当前被包裹的类型”到“包裹了新类型的Monad类型”的函数,并返回新的Monad类型对象。
unit函数的作用是将对象“放入”Monad中,但是怎么放还得看实现。
bind方法的作用是将前一个Monad与后一个Monad进行绑定。这里说得比较抽象,实际上在代码中只存在前一个Monad,所谓的“后一个Monad”是在调用了绑定函数后生成出来的(即[[bind接收的那个函数的]函数体中]返回的Monad)。但是,在逻辑上,后一个Monad必然存在,所以这里才称之为“绑定”。
此外,Monad还需要满足如下特性:
1 | unit(a).bind(k) == k(a) // 幺元(左) |
一、unit(a)再与某个函数k绑定,得到的结果应当与k(a)相等
二、某个monad的实例与unit绑定,得到的结果应当仍然是该实例本身
三、长相比较奇怪的结合律。说起它不得不提一句非常有名的话 ———— “Monad是自函子范畴上的幺半群”。
Why
为什么说Monad是自函子范畴上的幺半群
不必觉得不明觉厉,很显然这句话就是瞎凑的,只不过凑得很巧妙罢了。
为什么是“自函子”“范畴”
以MyMonad举例,不管再怎么bind,它也不会变成YourMonad,它永远都是MyMonad,只不过泛型类型在变化罢了,所以是自函子。
而“范畴”只是为了把讨论的目标从“MyMonad”提升到“Monad”罢了。
此处务必注意,自函子范畴里的对象都是 函数类型,也就是说,当我们讨论幺半群的时候,这个幺半群里面的元素都是函数(类型)。
为什么是幺半群
幺半群要满足什么条件?
- 有幺元
- 满足结合律
unit函数即为幺元,unit函数签名是符合bind函数接受的函数的,并且在实现时需要满足限制的特性。
我们假设这个幺半群的运算符为⊕
。
(M ⊕ N) ⊕ O == M ⊕ (N ⊕ O)
一眼就能看出来,bind函数并不是⊕
。但是,有它的影子在里面。
从图3可以看出,每两个连续的Monad只要不交换顺序,都可以自由地结合。此处的“结合律”并不在类型中体现,只是逻辑上的结合律。在上一部分的最后也给出了一个长相奇怪的结合律,这是需要写实现时自行遵守的。
为什么我们需要Monad
我觉得这是非FP程序员感到最疑惑的,例如一个最简单的Monad实现:
1 | class MyMonad<T> { |
这的确没错,也没啥错误。但是让人觉得:???我要Monad何用?为何不直接个变量t,直接调函数?
即使拓展到Maybe Monad(即java中的Optional),也会让人很无语:我就写个if == null
的事,何必搞个Monad呢?
在上述情况,的确如此,Monad处境比较尴尬,最大的问题就是:没必要。何必多写那么长代码来实现一个很简单的功能呢?
在异步编程中,无论是写C时候用的epoll,还是高层封装nodejs,或者java的NIO,总会有while循环来处理每个事件,我们通常称之为事件循环(eventloop)。
写代码时需要向循环中注册各种“回调函数”,它们将在对应事件触发时被调用。在开发时,显然会把重复逻辑抽出来放到单独的函数里供各处进行调用。而这个公共函数如果涉及事件循环的回调,那么则无法用return
关键字返回内容,也无法控制流程,使得该函数执行完毕后才执行调用者的下一段代码。
很多时候我们会将一个callback函数传入,这个函数接收异常或者结果,并继续执行后续内容。这在nodejs中非常常见,例如:
1 | function doSomething(a, b, cb) { |
这种写法比较繁琐,就像go语言一样,需要强制程序员显式处理error。此外还容易写出“回调地狱”。
使用Monad可以完美地解决这两个问题。例如nodejs的Promise、vertx(java)的Future(注意,并不是java自带的Future)。此处以Future为例,通常代码会写成这样:
1 | Future<Result> doSomething(String a, String b) { |
需要多步处理时,只需一个个步骤地compose即可,若需要异常处理,在最后setHandler即可,相当于整个compose的内容都被try了起来,在setHandler
里catch
和finally
。
这里的compose
即为bind
,succeededFuture
即为unit
。
这是我使用Monad最多的场景。
还有没有其他的场景?
有。在后面会从“用途”的角度来介绍Monad。
How 如何使用Monad
异步任务
前面提到过,在java中最常用的就是异步任务对应的Monad。
java中,可以使用vertx的Future与CompositeFuture。nodejs可以使用es6自带的Promise,或者第三方的高性能Promise实现:bluebird。scala里到处都是Monad,可能找一个非Monad的容器还挺难的。haskell就不用说了,你如果经常使用haskell写代码的话,肯定比我清楚。
代码没什么可说的,只要遵循上面这些库的使用方法即可(对于java等语言,看方法名称觉得挺对的,然后能过编译,那基本就没啥问题了)。此处不必纠结于Monad,因为Monad实现是比Monad本身更加具体的,会有更多辅助函数,务必尽情地调用,不要拘泥于Monad。这里拿vertx的Future为例:
比如说你现在想通过http访问某个资源,获取其内容,然后写入数据库。http访问是非Future实现,数据库访问是Future实现。业务逻辑可以这么写:
1 | Future<JsonObject> getHttpBodyWrap() { |
务必总是在执行的最后使用setHandler来检查或者处理异常,否则异常将被忽略,对排查问题很不利。不过,即使不设置setHandler,future也会不断执行下去。
如果是http应用,可以在api层做异常处理,而整个应用内部不必关心异常(就像普通的同步代码,有异常就throw一样)。
从用途看Monad
现在我们都知道了,Monad并不是一种类型,而是具有某种特征的类型的集合。我们抽几种Monad来看看(java为例):
- 异步任务Monad:
Future<T>
- List Monad(没错,List也可以是Monad):
List<T>
- Maybe Monad:
Optional<T>
可以看出,对于Monad,由“用途”和“当前类型”组成。
Future代表了异步任务,List表示列表,Optional代表“可空对象”。其泛型类型<T>
表示当前这个Monad“里面”的类型。
所以,“一种特定的Monad”对应了“一种特定的用途”。这也符合Monad的限制:当一个变量提升到某个Monad空间中后,它就再也不能从这个Monad空间里逃逸出来了(只使用Monad自己的函数,不使用特定实现的函数)。
比如“异步任务”,既然它是异步的,那么在Future Monad对象建立的时间点,所有异步任务的“过程”都要被构建出来,但是很显然,其中任何一环的变量都还不确定,也就不可能在这个时间点将里面的变量取出。
对于Optional,你只能在flatMap
接收的函数里处理非空情形,强行取出并处理会有空指针的风险。
这样,就有一个很大的问题。
比如,程序从控制台读取一串文字,使用了Reader Monad,然后做了一大堆处理,处理完后其值可能为空,所以使用了Maybe Monad,然后你想把值输出到控制台,又要使用Writer Monad。由于变量没办法主动逃逸出来,所以处理会非常麻烦:
1 | Reader<String> => Reader<Maybe<String>> => Reader<Maybe<Writer<?>>> |
这简直是灾难。。。
当然,在一个过程式的语言中,这并不是什么问题,因为你随时都可以阻塞地处理各个任务,即使要求非阻塞,也可以直接抽象一个Async Monad(或者Future Monad),然后对于所有内存操作Monad都在最后取出结果做处理。但是Monad被描述的这么“严密”,肯定会有解决方案把?
确实有,在haskell中解决方案被称为lift。但是在java/c#中由于类型系统的问题,无法实现该变换,但我们可以拙劣的模仿一下。
比如你现在有个Maybe Monad,它的lift可以这么写:
1 | class MaybeT<T> { |
Haskell的做法也比较相似,但因为语法糖和类型系统非常牛,所以没必要写的这么丑陋。