Java中函数式编程Monad概念介绍

在本教程中,我们将了解 monad,以及它们如何帮助我们处理效果。我们将学习使我们能够链接 monad 和操作的基本方法:map()和flatMap()。 在整篇文章中,我们将探讨 Java 生态系统中一些流行 monad 的 API,重点关注它们的实际应用。

效果effect 在函数式编程中,“效果”通常是指导致超出函数或组件范围的更改的操作。

为了在处理这些影响时应用函数式编程范例,我们可以将操作或数据包装在容器中。我们可以将 monad 视为允许我们处理函数范围之外的效果的容器,从而保持函数的纯度。

例如,假设我们有一个将两个双精度数相除的函数:

double divide(double dividend, double divisor) {
    return dividend / divisor;
}
尽管它看起来像一个纯函数,但当我们传递零作为除数 参数的值时,该函数会通过抛出 ArithmeticException 产生副作用。但是,我们可以使用 monad 来包装函数的结果并包含其效果。

让我们更改该函数并使其返回一个Optional

Optional<Double> divide(double dividend, double divisor) {
    if (divisor == 0) {
        return Optional.empty();
    }
    return Optional.of(dividend / divisor);
}
正如我们所看到的,当我们尝试除以零时,该函数不再产生副作用。

以下是其他一些流行的 Java monad 示例,可以帮助我们处理各种效果:

  • Optional<>  ——处理可为空性
  • List<>、Stream<> –管理数据集合
  • Mono<>、 CompletableFuture<> ——处理并发和 I/O
  • Try<>, Result<> –处理错误
  • Either<> –处理二元性

函子 当我们创建一个 monad 时,我们需要允许它改变其封装的对象或操作,同时保持相同的容器类型。

我们以Java Streams为例。如果在“现实世界”中,可以通过调用Instant.ofEpochSeconds()方法将Long类型的实例转换为Instant ,则这种关系必须保留在Stream的世界中。

为了实现这一点,Stream API 公开了一个高阶函数来“提升”原始关系。这个概念也称为“函子”,允许转换封装类型的方法通常称为“ map ”:

Stream<Long> longs = Stream.of(1712000000L, 1713000000L, 1714000000L);
Stream<Instant> instants = longs.map(Instant::ofEpochSecond);

尽管“ map ”是此函数类型的典型术语,但特定的方法名称本身对于对象是否有资格作为函子来说并不是必需的。例如,CompletableFuture monad 提供了一个名为thenApply()的方法:

CompletableFuture<Long> timestamp = CompletableFuture.completedFuture(1713000000L);
CompletableFuture<Instant> instant = timestamp.thenApply(Instant::ofEpochSecond);

Binding绑定 绑定是单子的一个关键特征,它允许我们在单子上下文中链接多个计算。换句话说,我们可以通过用绑定替换map()来避免双重嵌套。

嵌套单子 如果我们仅仅依靠函子来对操作进行排序,我们最终将得到嵌套容器。 让我们在这个例子中使用Project Reactor的Mono monad。

假设我们有两种方法可以让我们以反应方式获取Author和Book实体:

Mono<Author> findAuthorByName(String name) { /* ... */ }
Mono<Book> findLatestBookByAuthorId(Long authorId) { /* ... */ }

现在,如果我们从作者的姓名开始,我们可以使用第一种方法并获取他的详细信息。结果是Mono

void findLatestBookOfAuthor(String authorName) {
    Mono<Author> author = findAuthorByName(authorName);
    // ...
}
之后,我们可能会想使用 map () 方法将容器的内容 从Author更改为他最新的Book:

Mono<Mono<Book>> book = author.map(it -> findLatestBookByAuthorId(it.authorId());

但是,正如我们所看到的,这会产生一个嵌套的Mono容器。发生这种情况是因为findLatestBookByAuthorId()返回Mono而map()再次包装结果。

flatMap 然而,如果我们使用绑定来代替,我们就消除了额外的容器并使结构扁平化。绑定方法通常采用名称“flatMap” ,尽管有一些例外,其调用方式有所不同:

void findLatestBookOfAuthor(String authorName) {
    Mono<Author> author = findAuthorByName(authorName);
    Mono<Book> book = author.flatMap(it -> findLatestBookByAuthorId(it.authorId()));
    // ...
}
现在,我们可以通过内联操作来稍微简化代码,并引入一个从Author转换为其authorId的中间map():

void findLatestBookOfAuthor(String authorName) {
    Mono<Book> book = findAuthorByName(authorName)
      .map(Author::authorId)
      .flatMap(this::findLatestBookByAuthorId));
    // ...
}
正如我们所看到的,结合map()和flatMap()是一种使用monad的有效方法,允许我们以声明方式定义一系列转换。

实际用例 正如我们在前面的代码示例中所看到的,单子通过提供额外的抽象层来帮助我们处理效果。大多数时候,它们使我们能够专注于主要场景并处理主要逻辑之外的极端情况。

Railroad模式 像这样的绑定 monad 也称为“铁路”模式。我们可以通过想象一条铁路成一条直线来可视化主流。此外,如果发生意外情况,我们会从主要铁路切换到辅助并行铁路。

让我们考虑一下验证Book 对象。我们首先验证书籍的 ISBN,然后检查作者 ID,最后验证书籍的类型:

void validateBook(Book book) {
    if (!validIsbn(book.getIsbn())) {
        throw new IllegalArgumentException("Invalid ISBN");
    }
    Author author = authorRepository.findById(book.getAuthorId());
    if (author == null) {
        throw new AuthorNotFoundException("Author not found");
    }
    if (!author.genres().contains(book.genre())) {
        throw new IllegalArgumentException("Author does not write in this genre");
    }
}
我们可以使用vavr 的Try  monad并应用铁路模式将这些验证链接在一起:

void validateBook(Book bookToValidate) {
    Try.ofSupplier(() -> bookToValidate)
      .andThen(book -> validateIsbn(book.getIsbn()))
      .map(book -> fetchAuthor(book.getAuthorId()))
      .andThen(author -> validateBookGenre(bookToValidate.genre(), author))
      .get();
}
void validateIsbn(String isbn) { /* ... */ }
Author fetchAuthor(Long authorId) { /* ... */ }
void validateBookGenre(String genre, Author author) { /* ... */ }

正如我们所看到的,API 公开了像andThen() 这样的方法,对于我们不需要响应的函数非常有用。

它们的目的是检查故障,并在需要时切换到辅助通道。另一方面,map()和flatMap()等方法旨在进一步推动流程,创建一个新的Try<> monad 来包装函数的响应,在本例中为Author对象

现在我们已经了解了 monad 的工作原理以及如何使用铁路模式绑定它们,我们知道各种方法的实际名称是无关紧要的。相反,我们应该关注他们的目的。大多数来自 monad API 的方法:

  • 转换底层数据
  • 如果需要,在通道之间切换
例如,Optional monad 使用map()和flatMap()来转换其数据,分别使用 filter()和or()来潜在地在“空”和“存在”状态之间切换。 另一方面,CompletableFuture使用thenApply()和thenCombine()等方法而不是map()和flatMap() ,并允许我们通过exceptedly()从故障通道中恢复。