随着最近函数式编程(或函数式编程风格)的兴起,monads
成为一个广泛讨论的话题。关于他们有很多民间传说:
A monad is a monoid in the category of endofunctors, what’s the problem?
单子是内函子范畴中的一个幺半群,有什么问题吗?
— James Iry
The curse of the monad is that once you get the epiphany, once you understand - “oh that’s what it is” - you lose the ability to explain it to anybody.
单子的诅咒是,一旦你获得了顿悟,一旦你明白了——“哦,原来如此”——你就失去了向任何人解释的动力。
绝大多数程序员,尤其是那些没有函数式编程背景的程序员,往往认为 monad
是一些神秘的计算机科学概念,过于理论化以至于对编程生涯没有帮助。这种负面观点可归因于一些介绍 monad
的文章和博客过于抽象或过于狭隘。但事实证明,monad
就在我们身边,甚至是标准的 Java 库,尤其是从 Java Development Kit (JDK) 8
开始。有趣的是,一旦你理解了 monad
,就会对其他的几个概念一通百通。
Monad
概括了各种看似独立的概念,因此学习 monad
的变体只需要很少的时间。例如,你不必学习Java 8 中CompletableFuture
的工作原理,一旦你意识到它是一个 monad,你就会准确地知道它是如何工作的以及你可以从其语义中得到什么。你或许听说过RxJava
,RxJava
中的Observable也是一个 monad,所以RxJava
也变得没那么神秘。你已经在不知不觉中遇到了许多的 monad
示例,因此不要被那些高深的概念吓倒。
Functors
在我们解释 monad
是什么之前,让我们探索一个更简单的结构,称为functor(函子)。函子是封装一些值的类型化数据结构。从语法的角度来看,函子是一个具有以下 API 的容器:
import java.util.function.Function;
interface Functor<T> {
<R> Functor<R> map(Function<T, R> f);
}
但是仅仅语法合规不足以理解函子是什么。函子提供的唯一操作是map()
,map()
接受一个函数f
,这个函数f
接收函子中封装的值,转换它并将结果按原样包装到第二个函子中。函子始终是一个不可变的容器,因此map()
永远不会改变执行它的原始对象,而是将结果包裹在一个全新的函子中,可能是不同的类型。此外,当应用恒等函数时,函子不应执行任何操作,即map(x -> x)
。这种情况下应该总是返回相同的函子或相等的实例。
通常Functor<T>
被比作一个盒子,里面装着值T
,与T
交互的唯一方法是使用map()
对其进行转换。一般情况下,没有常用的方法让值T
从函子中解脱出来,值始终保持在函子的上下文中。
函子用一个统一的 API 概括了多个常见的习惯用法,如:collections
, promises
, optionals
等。下面介绍几个函子,让你更流畅地使用这个 API:
interface Functor<T,F extends Functor<?,?>> {
<R> F map(Function<T,R> f);
}
class Identity<T> implements Functor<T,Identity<?>> {
private final T value;
Identity(T value) { this.value = value; }
public <R> Identity<R> map(Function<T,R> f) {
final R result = f.apply(value);
return new Identity<>(result);
}
}
上面的例子是个最简单的函子,只是持有一个值,对该值所能做的就是在map
方法中对其进行转换,但无法提取它。与函子交互的唯一方法是进行一系列类型安全的转换:
Identity<String> idString = new Identity<>("abc");
Identity<Integer> idInt = idString.map(String::length);
你也可以使用链式写法:
Identity<byte[]> idBytes = new Identity<>(customer)
.map(Customer::getAddress)
.map(Address::street)
.map((String s) -> s.substring(0, 3))
.map(String::toLowerCase)
.map(String::getBytes);
从这个角度来看,对函子进行映射与链式调用没有太大区别:
byte[] bytes = customer
.getAddress()
.street()
.substring(0, 3)
.toLowerCase()
.getBytes();
那为什么还要费心使用这种冗长的包装,不仅不能提供任何附加值,而且不能提取内容的结构呢?事实证明这个原始函子确实没多大用,但是可以使用这个原始函子抽象来模拟其他几个概念,例如:java.util.Optional<T>
,这个从 Java 8 开始时带有map()
方法的函子。让我们从头开始实现它:
class FOptional<T> implements Functor<T,FOptional<?>> {
private final T valueOrNull;
private FOptional(T valueOrNull) {
this.valueOrNull = valueOrNull;
}
public <R> FOptional<R> map(Function<T,R> f) {
if (valueOrNull == null)
return empty();
else
return of(f.apply(valueOrNull));
}
public static <T> FOptional<T> of(T a) {
return new FOptional<T>(a);
}
public static <T> FOptional<T> empty() {
return new FOptional<T>(null);
}
}
FOptional<T>
函子可能持有一个值,但它也可能是空的。这是一种处理null
的类型安全的方式。有两种方式来构造 FOptional
,提供值或创建empty()
实例。就像 Identity
函子一样,FOptional
是不可变的,我们只能从内部与值进行交互。不同的是,对 FOptional
来说,如果它为空,则转换函数f
不会应用于任何值。这意味着函子不一定只封装一个值T
。它也可以包装任意数量的值,就像List
…函子一样:
import com.google.common.collect.ImmutableList;
class FList<T> implements Functor<T, FList<?>> {
private final ImmutableList<T> list;
FList(Iterable<T> value) {
this.list = ImmutableList.copyOf(value);
}
@Override
public <R> FList<?> map(Function<T, R> f) {
ArrayList<R> result = new ArrayList<R>(list.size());
for (T t : list) {
result.add(f.apply(t));
}
return new FList<>(result);
}
}
API 保持不变(都是使用map()
进行转换),但行为却大不相同。我们对FList
中的每一项应用转换,声明性地转换整个列表。因此,如果你有一个列表customers
并且想要得到他们的street
,那么例子如下:
import static java.util.Arrays.asList;
FList<Customer> customers = new FList<>(asList(cust1, cust2));
FList<String> streets = customers
.map(Customer::getAddress)
.map(Address::street);
使用customers.getAddress().street()
这种链式调用的方式不再能简单的得到结果,你不能对customers
集合调用getAddress()
,你必须对每个单独的customer
调用getAddress()
,然后将其放回集合中。顺便说一下,Groovy 发现这种模式非常普遍,以至于它实际上有一个语法糖:customer*.getAddress()*.street()
。 这个方法,被称为spread-dot
,实际上是一种map
的伪装。也许你想知道为什么我在 map
里手动迭代列表,而不是使用Java 8
中的 Stream
:list.stream().map(f).collect(toList())
?如果我告诉你java.util.stream.Stream<T>
在 Java 中也是函子呢?
现在你应该看到函子的第一个好处,它们抽象出各种数据结构的内部表示,并提供一致的、易于使用的 API用于操作数据。作为最后一个例子,让我介绍一下promise函子,类似于Future
。Promise
“承诺”某个值有一天会变得可用,它还没有出现,可能是因为等待后台计算,或者正在等待外部事件,但它会在未来的某个时间出现。
Promise<Customer> customer = //...
Promise<byte[]> bytes = customer
.map(Customer::getAddress)
.map(Address::street)
.map((String s) -> s.substring(0, 3))
.map(String::toLowerCase)
.map(String::getBytes);
是不是很熟悉?这就是我想说的!Promise<Customer>
现在还没有值,它承诺未来会有值,但是没有关系,就像 FOptional
和FList
那样,我们依然可以对promise函子
进行映射,语法和语义完全相同。customer.map()
不会等待底层promise
完成,而是生成另一个不同类型的promise
,这意味着map
是非阻塞的。当上游promise完成时,下游promise会应用传给 map()
的函数,然后将结果传递给更下游。突然之间,我们的函子允许我们以管道的方式进行非阻塞异步计算。但是你不必理解或学习——因为Promise
也是一个函子,它遵循函子语法和定律。
还有许多其他函子的例子,例如可以表示值或错误的函子。但现在是了解monad
的时候了。
从functors到monads
我假设你已经了解了函子的工作原理以及为什么它们是有用的抽象。但是函子并不像人们想象的那样普遍,如果你的转换函数(作为参数传递给 map()
的那个)返回函子实例而不是简单的返回值,会发生什么?好吧,函子也只是一个值,所以没有什么不好的事情发生。返回的任何内容都放回函子中,因此所有行为都一致。然而,想象一下你有这个解析String
的方法:
FOptional<Integer> tryParse(String s) {
try {
final int i = Integer.parseInt(s);
return FOptional.of(i);
} catch (NumberFormatException e) {
return FOptional.empty();
}
}
异常是破坏类型系统和函数纯度的副作用。在纯函数式语言中,没有异常,毕竟我们从未在数学课上听说过抛出异常,对吧?取而代之的是,错误使用值和包装器显式表示。例如,tryParse()
传入一个String
类型的参数,但并不是简单的返回一个int
或是抛出异常,而是通过类型系统明确的告诉我们tryParse()
可能会成功也可能会失败,但不会有任何异常或错误。这种可选的结果由optional
来表示。
试着组合 一下tryParse
和 FOptional<String>
:
FOptional<String> str = FOptional.of("42");
FOptional<FOptional<Integer>> num = str.map(this::tryParse);
问题出现了,tryParse()
会返回一个FOptional<Integer>
,但因为map()
函数会把FOptional<Integer>
当作转换后的值再次包装,变成了FOptional<FOptional<Integer>>
,造成了包装两次的尴尬。请仔细回想一下函子的定义,搞清楚为什么这里得到这个双重包装器。除了看起来很糟糕之外,在 functor 中使用 functor 会破坏函子的组合性和流畅的转换链:
FOptional<Integer> num1 = //...
FOptional<FOptional<Integer>> num2 = //...
FOptional<Date> date1 = num1.map(t -> new Date(t));
//doesn't compile!
FOptional<Date> date2 = num2.map(t -> new Date(t));
这里我们尝试通过传入一个 int -> Date
的函数把 FOptional
中的int
转换成Date
,对于num1
这很简单。但到了num2
情况变得复杂了,num2.map()
接收的函数f
的输入不再是一个int
,而是一个FOptional<Integer>
,显然 java.util.Date
没有一个参数类型是FOptional<Integer>
的构造器,num2.map()
无法通过编译,双重包装破坏了我们的函子。然而,返回函子而不是返回简单值的函数是很常见的需求(如tryParse()
),我们不能简单地忽略这样的需求。一种方案是是引入一种特殊的无参数join()
方法来“flattens(展平)”嵌套函子:
FOptional<Integer> num3 = num2.join()
由于这种模式非常普遍,因此引入了特殊的方法叫作flatMap()
。flatMap()
非常类似于map()
,但它期望接收函数作为参数,返回函子(而不是嵌套函子) ,或者准确地说 — monad :
interface Monad<T,M extends Monad<?,?>> extends Functor<T,M> {
M flatMap(Function<T,M> f);
}
我们简单地得出结论, flatMap
只是一种语法糖,可以实现更好的组合。但是flatMap
方法(在 Haskell中通常叫作 bind
或者 >>=
)让一切变得不同了,因为它允许以纯函数式的风格组合复杂的转换。如果FOptional
是monad
的一个实例,那么之前的转换就能按预期工作:
FOptional<String> num = FOptional.of("42");
FOptional<Integer> answer = num.flatMap(this::tryParse);
Monads 不需要实现map
,它可以简单的在 flatMap()
之上实现。事实上, flatMap
是打开transformations(变换)领域新世界的基本方法。就像函子一样,语法合规不足以将某个类称为 monad,flatMap()
方法必须遵循 monad
定律,它们非常直观,就像 flatMap()
和 identity 的结合性。后者要求对于任意持有值 x
的 monad
和任意函数f
, m(x).flatMap(f)
与f(x)
相同。我们不会深入研究 monad 理论,我们更关注其实际意义。例如, Promise
monad将在未来持有值,你能从类型系统中猜测以下程序中Promise
的行为吗?首先,所有可能需要一些时间才能完成的方法都返回一个Promise
:
import java.time.DayOfWeek;
Promise<Customer> loadCustomer(int id) {
//...
}
Promise<Basket> readBasket(Customer customer) {
//...
}
Promise<BigDecimal> calculateDiscount(Basket basket, DayOfWeek dow) {
//...
}
Promise<BigDecimal> discount =
loadCustomer(42)
.flatMap(this::readBasket)
.flatMap(b -> calculateDiscount(b, DayOfWeek.FRIDAY));
`flatMap()`必须保留 `monadic` 类型,因此所有中间对象都是`Promise`。这不仅保持了类型的顺序,也使得前面的程序完全异步了。`loadCustomer()`返回 一个`Promise`,所以它不会阻塞, `readBasket()` 接收这个 `Promise` 以及它拥有(将拥有)的东西,在应用一个函数后返回另一个 `Promise` ,以此类推。基本上,我们构建了一个异步计算管道,后台完成一个步骤会自动触发下一步。
探索 flatMap()
拥有两个 monad
并将它们包含的值组合在一起是很常见的。然而,函子和 monad
都不允许直接访问它们的内部,我们只能小心地应用转换。想象一下,你有两个 monad
并且想要将它们组合起来:
import java.time.LocalDate;
import java.time.Month;
Monad<Month> month = //...
Monad<Integer> dayOfMonth = //...
Monad<LocalDate> date = month.flatMap((Month m) ->
dayOfMonth
.map((int d) -> LocalDate.of(2016, m, d)));
我们有两个独立的 monad,一个包装的类型是 Month
,另一个是 Integer
。为了构建LocalDate
,我们必须构建一个可以访问两个 monad
内部结构的嵌套转换。仔细思考一下,为什么在一个地方使用flatMap()
而在另一个地方使用map()
。想想如果还有第三个Monad<Year>
,你会如何构造这段代码。
这种应用两个参数(在这个例子中是m
和d
)的函数是很常见的,在Haskell有一个名为 liftM2
特殊辅助函数,正是在map
和flatmap
之上实现的用于这种转换的函数。在 Java 伪语法中,就像这样:
Monad<R> liftM2(Monad<T1> t1, Monad<T2> t2, BiFunction<T1, T2, R> fun) {
return t1.flatMap((T1 tv1) ->
t2.map((T2 tv2) -> fun.apply(tv1, tv2))
);
}
你不必为每个 monad 实现这个方法,flatMap()
就足够了,而且它对所有 monad
都能一致地工作。当你将其与其他monad
一起使用时, liftM2
非常有用。例如: liftM2(list1, list2, function)
将会对 list1
和 list2
中列表项的所有组合(笛卡尔积)应用函数function
。另一方面,对于optionals
,只有当两个optional
都不为空时,它才会应用一个函数。更骚的是,对于 Promise
monad,只有当两个Promise
都完成时,才会异步的执行一个函数。这意味着我们发明了同步两个异步操作的简单同步机制(就像fork-join
算法中的join()
一样)。
我们可以在 flatMap()
之上构建另一个有用的方法filter(Predicate<T> p)
,它接受 monad
中的内容T
,如果T
不满足谓词p
,则将其丢弃。在某种程度上,它类似于map
,但不是 1-to-1
映射,而是1-to-0-or-1
。同样,filter()
对于每个 monad
具有相同的语义,但根据我们实际使用的 monad
,filter()
具有amazing的功能。例如,它允许从列表中过滤掉某些元素:
FList<Customer> vips = customers.filter(c -> c.totalOrders > 1_000);
对于optionals
它也同样适用。在这种情况下,如果 optional
的内容不符合某些标准,我们可以将非空 optional
转换为空的,空的 optional
保持不变。
从monad的列表到列表的monad
另一个有用的源自 flatMap()
的方法是 sequence()
。只需查看类型签名,就可以轻松猜出它的作用:
Monad<Iterable<T>> sequence(Iterable<Monad<T>> monads)
通常我们有一堆相同类型的monad
,但我们希望有一个该类型列表的单个 monad
。这可能听起来很抽象,但它非常有用。想象一下,你想通过 ID
从数据库中同时加载一些客户,因此你对不同的 ID
多次使用了loadCustomer(id)
方法,每次调用都返回Promise<Customer>
。现在你有了一个Promise
列表,但你真正想要的是客户列表。sequence()
(在 RxJava
中sequence()
被称为concat()
或者merge()
)方法就是为此而生的:
FList<Promise<Customer>> custPromises = FList
.of(1, 2, 3)
.map(database::loadCustomer);
Promise<FList<Customer>> customers = custPromises.sequence();
customers.map((FList<Customer> c) -> ...);
对代表客户 ID
列表的FList<Integer>
中的每个ID
应用database.loadCustomer(id)
进行转换,就得到了相当不方便的Promise
列表。幸好, sequence()
拯救了它。对于不同种类的 monad
,在不同的计算上下文中,sequence()
仍然有意义。例如,它可以将 FList<FOptional<T>>
更改为FOptional<FList<T>>
。
这其实只是flatMap()
和monads
实用性的冰山一角。尽管来自相当晦涩的范畴论,monads
依然被证明是非常有用的抽象,即使在面向对象的编程语言(如 Java
)中也是如此。能够组合函数返回monad
是如此普遍有用,以至于许多不相关的类都遵循 monadic
行为。
此外,一旦你将数据封装在 monad
中,通常很难明确地将其取出。取出这种操作不是 monad
行为的一部分,并且通常会破坏monad
的哲学。例如,技术上 ,Promise<T>
中的Promise.get()
可以返回T
,但只能通过阻塞的方式获取,而所有基于的flatMap()
操作都是非阻塞的。另一个例子是FOptional.get()
,FOptional.get()
可能会失败,因为FOptional
可能是空的。甚至是调用FList.get(idx)
从列表中查看特定元素,也是不推荐的,因为通常你可以使用map()
来替换for
循环。
现在,我希望你明白了为什么 monad
现在如此流行。即使在像 Java
这样的面向对象语言中,它们也是非常有用的抽象。