随着最近函数式编程(或函数式编程风格)的兴起,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.
单子的诅咒是,一旦你获得了顿悟,一旦你明白了——“哦,原来如此”——你就失去了向任何人解释的动力。
— Douglas Crockford
绝大多数程序员,尤其是那些没有函数式编程背景的程序员,往往认为 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{ Functor map(Function f); }
但是仅仅语法合规不足以理解函子是什么。函子提供的唯一操作是map(),map()接受一个函数f,这个函数f接收函子中封装的值,转换它并将结果按原样包装到第二个函子中。函子始终是一个不可变的容器,因此map()永远不会改变执行它的原始对象,而是将结果包裹在一个全新的函子中,可能是不同的类型。此外,当应用恒等函数时,函子不应执行任何操作,即map(x -> x)。这种情况下应该总是返回相同的函子或相等的实例。
通常Functor
函子用一个统一的 API 概括了多个常见的习惯用法,如:collections, promises, optionals等。下面介绍几个函子,让你更流畅地使用这个 API:
interface Functor> { F map(Function f); } class Identity implements Functor > { private final T value; Identity(T value) { this.value = value; } public Identity map(Function f) { final R result = f.apply(value); return new Identity<>(result); } }
上面的例子是个最简单的函子,只是持有一个值,对该值所能做的就是在map方法中对其进行转换,但无法提取它。与函子交互的唯一方法是进行一系列类型安全的转换:
IdentityidString = new Identity<>("abc"); Identity idInt = idString.map(String::length);
你也可以使用链式写法:
IdentityidBytes = 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
class FOptionalimplements Functor > { private final T valueOrNull; private FOptional(T valueOrNull) { this.valueOrNull = valueOrNull; } public FOptional map(Function f) { if (valueOrNull == null) return empty(); else return of(f.apply(valueOrNull)); } public static FOptional of(T a) { return new FOptional (a); } public static FOptional empty() { return new FOptional (null); } }
FOptional
import com.google.common.collect.ImmutableList; class FListimplements Functor > { private final ImmutableList list; FList(Iterable value) { this.list = ImmutableList.copyOf(value); } @Override public FList> map(Function f) { ArrayList result = new ArrayList (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; FListcustomers = new FList<>(asList(cust1, cust2)); FList 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
现在你应该看到函子的第一个好处,它们抽象出各种数据结构的内部表示,并提供一致的、易于使用的 API用于操作数据。作为最后一个例子,让我介绍一下promise函子,类似于Future。Promise“承诺”某个值有一天会变得可用,它还没有出现,可能是因为等待后台计算,或者正在等待外部事件,但它会在未来的某个时间出现。
Promisecustomer = //... Promise bytes = customer .map(Customer::getAddress) .map(Address::street) .map((String s) -> s.substring(0, 3)) .map(String::toLowerCase) .map(String::getBytes);
是不是很熟悉?这就是我想说的!Promise
还有许多其他函子的例子,例如可以表示值或错误的函子。但现在是了解monad的时候了。
从functors到monads 我假设你已经了解了函子的工作原理以及为什么它们是有用的抽象。但是函子并不像人们想象的那样普遍,如果你的转换函数(作为参数传递给 map()的那个)返回函子实例而不是简单的返回值,会发生什么?好吧,函子也只是一个值,所以没有什么不好的事情发生。返回的任何内容都放回函子中,因此所有行为都一致。然而,想象一下你有这个解析String的方法:
FOptionaltryParse(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
FOptionalstr = FOptional.of("42"); FOptional > num = str.map(this::tryParse);
问题出现了,tryParse()会返回一个FOptional
FOptionalnum1 = //... FOptional > num2 = //... FOptional date1 = num1.map(t -> new Date(t)); //doesn't compile! FOptional date2 = num2.map(t -> new Date(t));
这里我们尝试通过传入一个 int -> Date 的函数把 FOptional 中的int 转换成Date ,对于num1这很简单。但到了num2情况变得复杂了,num2.map()接收的函数f的输入不再是一个int,而是一个FOptional
FOptionalnum3 = num2.join()
由于这种模式非常普遍,因此引入了特殊的方法叫作flatMap()。flatMap()非常类似于map(),但它期望接收函数作为参数,返回函子(而不是嵌套函子) ,或者准确地说 — monad :
interface Monad> extends Functor { M flatMap(Function f); }
我们简单地得出结论, flatMap 只是一种语法糖,可以实现更好的组合。但是flatMap方法(在 Haskell中通常叫作 bind 或者 >>= )让一切变得不同了,因为它允许以纯函数式的风格组合复杂的转换。如果FOptional是monad的一个实例,那么之前的转换就能按预期工作:
FOptionalnum = FOptional.of("42"); FOptional 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; PromiseloadCustomer(int id) { //... } Promise readBasket(Customer customer) { //... } Promise calculateDiscount(Basket basket, DayOfWeek dow) { //... } Promise 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; Monadmonth = //... Monad dayOfMonth = //... Monad date = month.flatMap((Month m) -> dayOfMonth .map((int d) -> LocalDate.of(2016, m, d)));
我们有两个独立的 monad,一个包装的类型是 Month,另一个是 Integer。为了构建LocalDate,我们必须构建一个可以访问两个 monad 内部结构的嵌套转换。仔细思考一下,为什么在一个地方使用flatMap()而在另一个地方使用map()。想想如果还有第三个Monad
这种应用两个参数(在这个例子中是m和d)的函数是很常见的,在Haskell有一个名为 liftM2 特殊辅助函数,正是在map和flatmap之上实现的用于这种转换的函数。在 Java 伪语法中,就像这样:
MonadliftM2(Monad t1, Monad t2, BiFunction 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
FListvips = customers.filter(c -> c.totalOrders > 1_000);
对于optionals它也同样适用。在这种情况下,如果 optional 的内容不符合某些标准,我们可以将非空 optional 转换为空的,空的 optional保持不变。
从monad的列表到列表的monad 另一个有用的源自 flatMap()的方法是 sequence()。只需查看类型签名,就可以轻松猜出它的作用:
Monad> sequence(Iterable > monads)
通常我们有一堆相同类型的monad,但我们希望有一个该类型列表的单个 monad。这可能听起来很抽象,但它非常有用。想象一下,你想通过 ID 从数据库中同时加载一些客户,因此你对不同的 ID多次使用了loadCustomer(id)方法,每次调用都返回Promise
FList> custPromises = FList .of(1, 2, 3) .map(database::loadCustomer); Promise > customers = custPromises.sequence(); customers.map((FList c) -> ...);
对代表客户 ID 列表的FList
这其实只是flatMap()和monads实用性的冰山一角。尽管来自相当晦涩的范畴论,monads依然被证明是非常有用的抽象,即使在面向对象的编程语言(如 Java)中也是如此。能够组合函数返回monad是如此普遍有用,以至于许多不相关的类都遵循 monadic 行为。
此外,一旦你将数据封装在 monad 中,通常很难明确地将其取出。取出这种操作不是 monad 行为的一部分,并且通常会破坏monad的哲学。例如,技术上 ,Promise
现在,我希望你明白了为什么 monad 现在如此流行。即使在像 Java 这样的面向对象语言中,它们也是非常有用的抽象。



