从零认识 Monad

在交流中,有时候丢出一个“Monad”就可以把问题说清楚,但是我发现大多数人并不知道什么是monad,甚至有的没听说过。所以特此写一篇通俗易懂的文章来描述编程语言中的Monad。

读这篇文章前,你需要

  1. 写过java或c#(scala/kotlin等使用者估计也用过java,haskell/ml使用者估计早就知道monad是什么了,而只用过动态类型语言的,也许能理解本文的“是什么”,但不一定能理解“为什么”)
  2. 知道何为“泛型”
  3. 了解过异步/非阻塞IO编程
  4. 读前可以百度(嗯,这个没必要谷歌)查一查“幺半群”、“幺元”,大概花2分钟的时间即可
  5. [可选]了解一些范畴、对象、态射,大概5分钟的时间即可

本文将以java代码,来构造一个“合格”的Monad,以说清Monad的what/why/how。

What - Monad是什么

图解Monad

首先给大家一个直观的感受,形象的描述一下Monad能干什么,以方便后续的理解:

这是类型T

1
T

Monad类似于“罐子”、“箱子”一类的东西,不过为了更加形象,我们把它比做 上下都开口的圆桶 (后面你就知道为什么了)。

我们可以通过一个函数,把T类型的对象装到Monad里面。通常我们把这个函数称作unit或者return。由于return是一个java关键字,所以后文我们用unit来称呼这个函数。

1
2
3
4
5
6
7
8
9
10
                Monad---+
| |
| |
T ------> T |
| |
| |
+-------+

Monad<T>
图1

我们得到了第一个Monad,Monad<T>

编程语言里都有函数,我们可以传入一种变量,得到另一种变量。Monad也是如此,我们可以给Monad一种特殊的函数,这种函数接受前一个Monad包含的类型,并返回一个包含了另一类型的新的Monad。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
                                      新Monad
+-----------------------------+
| |
Monad---+ | Monad---+ |
| | | | | |
| +-|-----------------+ | |
T ------> T | T -> Monad<U> N | |
| +-|-----------------+ | |
| | | | | |
+-------+ | +-------+ |
| |
+-----------------------------+

整体的类型为 Monad<U>
图2

这样,我们得到了一个新的Monad,即Monad<U>

我们可以对这个Monad再进行一次、两次、…、无数次上面的操作:

1
2
3
4
5
6
7
8
9
10
                Monad---+                Monad---+                Monad---+                                    Monad---+
| | | | | | | |
| +----------------+ +----------------+ +------------- ... ------------------+ |
T ------> T T -> Monad<U> U U -> Monad<V> V V -> ... ... ... -> Monad<N> N |
| +----------------+ +----------------+ +------------- ... ------------------+ |
| | | | | | | |
+-------+ +-------+ +-------+ +-------+

Monad<N>
图3

这些Monads被某些“绑定函数”绑在一起,形成了最终的Monad<N>,就像一个管道一样。
此时务必注意,上面我们讨论的都是“类型”,并不是具体的“对象”。

并且,上面所谓的“Monad”实际上是指“某种Monad”。

我们常听到的、文章里使用的Monad,并非 一种 类型,而是“具有上述特征的” 一类 类型。

代码描述

我们讨论的是Haskell中的Monad,但是相信不少人并不熟悉Haskell,我们直接用java来描述Monad的定义。

1
2
3
4
5
6
// 此处我们暂时不讨论Applicative和Functor

class MyMonad<T> extends MyApplicative<T> {
static <X> MyMonad<X> unit(X x) { ... } // return
<U> MyMonad<U> bind(Function<T, MyMonad<U>> f) { ... } // >>= // Function<T, MyMonad<U>> 即为 T -> MyMonad<U>
}

这里的unit在haskell中被称作returnbind在haskell中记作>>=,读音为bind。

unit函数是个static函数,它接收X类型对象,并把它包裹在Monad里面,返回一个MyMonad<X>类型对象。
bind方法接收一个“当前被包裹的类型”到“包裹了新类型的Monad类型”的函数,并返回新的Monad类型对象。

unit函数的作用是将对象“放入”Monad中,但是怎么放还得看实现。

bind方法的作用是将前一个Monad与后一个Monad进行绑定。这里说得比较抽象,实际上在代码中只存在前一个Monad,所谓的“后一个Monad”是在调用了绑定函数后生成出来的(即[[bind接收的那个函数的]函数体中]返回的Monad)。但是,在逻辑上,后一个Monad必然存在,所以这里才称之为“绑定”。

此外,Monad还需要满足如下特性:

1
2
3
unit(a).bind(k)           == k(a) // 幺元(左)
m.bind(unit) == m // 幺元(右)
m.bind(x -> k(x).bind(h)) == m.bind(k).bind(h) // 长相比较奇怪的结合律

一、unit(a)再与某个函数k绑定,得到的结果应当与k(a)相等
二、某个monad的实例与unit绑定,得到的结果应当仍然是该实例本身
三、长相比较奇怪的结合律。说起它不得不提一句非常有名的话 ———— “Monad是自函子范畴上的幺半群”。

Why

为什么说Monad是自函子范畴上的幺半群

不必觉得不明觉厉,很显然这句话就是瞎凑的,只不过凑得很巧妙罢了。

为什么是“自函子”“范畴”

以MyMonad举例,不管再怎么bind,它也不会变成YourMonad,它永远都是MyMonad,只不过泛型类型在变化罢了,所以是自函子。
而“范畴”只是为了把讨论的目标从“MyMonad”提升到“Monad”罢了。

此处务必注意,自函子范畴里的对象都是 函数类型,也就是说,当我们讨论幺半群的时候,这个幺半群里面的元素都是函数(类型)。

为什么是幺半群

幺半群要满足什么条件?

  1. 有幺元
  2. 满足结合律

unit函数即为幺元,unit函数签名是符合bind函数接受的函数的,并且在实现时需要满足限制的特性。

我们假设这个幺半群的运算符为

(M ⊕ N) ⊕ O == M ⊕ (N ⊕ O)

一眼就能看出来,bind函数并不是。但是,有它的影子在里面。
从图3可以看出,每两个连续的Monad只要不交换顺序,都可以自由地结合。此处的“结合律”并不在类型中体现,只是逻辑上的结合律。在上一部分的最后也给出了一个长相奇怪的结合律,这是需要写实现时自行遵守的。

为什么我们需要Monad

我觉得这是非FP程序员感到最疑惑的,例如一个最简单的Monad实现:

1
2
3
4
5
6
7
class MyMonad<T> {
private T t;
private MyMonad(T t) { this.t = t; }
static <X> MyMonad<X> unit(T t) { return new Monad(t); }
<U> MyMonad<U> bind(Function<T, MyMonad<U>> f) { return f.apply(t); }
boolean equals(MyMonad o) { return t == o.t; }
}

这的确没错,也没啥错误。但是让人觉得:???我要Monad何用?为何不直接个变量t,直接调函数?

即使拓展到Maybe Monad(即java中的Optional),也会让人很无语:我就写个if == null的事,何必搞个Monad呢?
在上述情况,的确如此,Monad处境比较尴尬,最大的问题就是:没必要。何必多写那么长代码来实现一个很简单的功能呢?

在异步编程中,无论是写C时候用的epoll,还是高层封装nodejs,或者java的NIO,总会有while循环来处理每个事件,我们通常称之为事件循环(eventloop)。
写代码时需要向循环中注册各种“回调函数”,它们将在对应事件触发时被调用。在开发时,显然会把重复逻辑抽出来放到单独的函数里供各处进行调用。而这个公共函数如果涉及事件循环的回调,那么则无法用return关键字返回内容,也无法控制流程,使得该函数执行完毕后才执行调用者的下一段代码。

很多时候我们会将一个callback函数传入,这个函数接收异常或者结果,并继续执行后续内容。这在nodejs中非常常见,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function doSomething(a, b, cb) {
io.run(a, b, function(err, res) {
console.log('io.run got result');
cb(err, res);
});
}

doSomething('abc', 'def', function(err, res) {
if (err) {
console.error('error!', err);
} else {
console.log('success: %j', res);
}
});

这种写法比较繁琐,就像go语言一样,需要强制程序员显式处理error。此外还容易写出“回调地狱”。

使用Monad可以完美地解决这两个问题。例如nodejs的Promise、vertx(java)的Future(注意,并不是java自带的Future)。此处以Future为例,通常代码会写成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Future<Result> doSomething(String a, String b) {
io.run(a, b).compose(res -> {
console.log("io.run got result");
return Future.succeededFuture(res);
});
}

doSomething('abc', 'def').setHandler(r -> {
if (r.failed()) {
logger.error("error!", r.cause());
} else {
logger.info("success: {}", r.result());
}
});

需要多步处理时,只需一个个步骤地compose即可,若需要异常处理,在最后setHandler即可,相当于整个compose的内容都被try了起来,在setHandlercatchfinally

这里的compose即为bindsucceededFuture即为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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Future<JsonObject> getHttpBodyWrap() {
/* 执行步骤 1 */
Future<JsonObject> f = Future.future(); // 新建一个空的future。
getHttpBody(GET, uri, r -> {
// 这里是异步过程
/* 执行步骤 4 */
if (r.failed()) {
f.fail(r.cause()); // 当前future失败
} else {
f.complete(new JsonObject(r.result())); // 完成当前future
}
});
/* 执行步骤 2 */
return f; // 把这个future返回出去
}

Future<?> getAndSave() {
/* 执行步骤 3 */
return getHttpBodyWrap().compose(json -> /* 执行步骤 5 */db.save(json));
}

/* 执行开始 */
getAndSave().setHandler(r -> {
/* 执行步骤 6 */
/* 执行结束 */
});

务必总是在执行的最后使用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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MaybeT<T> {
Maybe<T> maybe;
MaybeT(Maybe<T> maybe) {
this.maybe = maybe;
}
static MaybeT<T> unit(T t) {
return new MaybeT(Maybe.unit(t));
}
<U> MaybeT<U> bind(Function<T, ReaderMaybe<U>> f) {
return new MaybeT(maybe.bind(x -> f(x).get()));
}
private Maybe<T> get() {
return this.maybe;
}
}

class Maybe<T> {
/* ... */
MaybeT<T> lift();
}

Haskell的做法也比较相似,但因为语法糖和类型系统非常牛,所以没必要写的这么丑陋。