Java 设计模式 Monads 的美丽世界

让我从免责声明开始。从函数式编程的角度来看,下面的解释绝不是精确的或绝对准确的。相反,我将重点解释的清晰和简单性上,以便让尽可能多的 Java 开发人员进入这个美丽的世界。

几年前,当我开始深入研究函数式编程时,我很快发现有大量的信息,但对于几乎完全具有命令式背景的普通 Java 开发人员来说,几乎无法理解。如今,情况正在慢慢改变。例如,有很多文章解释了例如基本的 FP 概念(参考: 实用函数式 Java (PFJ)简介)以及它们如何适用于 Java。或解释如何正确使用 Java 流的文章。但是 Monads 仍然不在这些文章的重点之外。我不知道为什么会发生这种情况,但我会努力填补这个空白。

那么,Monad 是什么?

Monad 是……一种设计模式。就这么简单。这种设计模式由两部分组成:

  • Monad 是一个值的容器。对于每个 Monad,都有一些方法可以将值包装到 Monad 中。
  • Monad 为内部包含的值实现了“控制反转”。为了实现这一点,Monad 提供了接受函数的方法。这些函数接受与 Monad 中存储的类型相同的值,并返回转换后的值。转换后的值被包装到与源值相同的 Monad 中。
    为了理解模式的第二部分,我们可以看看 Monad 的接口:
interface Monad<T> {
    <R> Monad<R> map(Function<T, R> mapper);

    <R> Monad<R> flatMap(Function<T, Monad<R>> mapper);
}

当然,特定的 Monad 通常有更丰富的接口,但这两个方法绝对应该存在。

乍一看,接受函数而不是访问值并没有太大区别。事实上,这使 Monad 能够完全控制如何以及何时应用转换功能。当您调用 getter 时,您希望立即获得值。在 Monad 转换的情况下可以立即应用或根本不应用,或者它的应用可以延迟。缺乏对内部值的直接访问使 monad 能够表示甚至尚不可用的值!

下面我将展示一些 Monad 的例子以及它们可以解决哪些问题。

Monad 缺失值或 Optional/Maybe 的场景

这个 Monad 有很多名字——Maybe、Option、Optional。最后一个听起来很熟悉,不是吗? 好吧,因为 Java 8 Optional 是 Java 平台的一部分。

不幸的是,Java Optional 实现过于尊崇传统的命令式方法,这使得它的用处不大。特别是 Optional 允许应用程序使用 .get() 方法获取值。如果缺少值,甚至会抛出 NPE。因此,Optional 的用法通常仅限于表示返回潜在的缺失值,尽管这只是潜在用法的一小部分。

也许 Monad 的目的是表示可能会丢失的值。传统上,Java 中的这个角色是为 null 保留的。不幸的是,这会导致许多不同的问题,包括著名的 NullPointerException

例如,如果您期望某些参数或某些返回值可以为 null,则应该在使用前检查它:

public UserProfileResponse getUserProfileHandler(final User.Id userId) {
    final User user = userService.findById(userId);
    if (user == null) {
    return UserProfileResponse.error(USER_NOT_FOUND);
    }
   
    final UserProfileDetails details = userProfileService.findById(userId);
   
    if (details == null) {
    return UserProfileResponse.of(user, UserProfileDetails.defaultDetails());
    }
   
    return UserProfileResponse.of(user, details);
}

看起来熟悉吗?当然了。

让我们看看 Option Monad 如何改变这一点(为简洁起见,使用一个静态导入):

    public UserProfileResponse getUserProfileHandler(final User.Id userId) {
        return ofNullable(userService.findById(userId))
                .map(user -> UserProfileResponse.of(user,
                        ofNullable(userProfileService.findById(userId)).orElseGet(UserProfileDetails::defaultDetails)))
                .orElseGet(() -> UserProfileResponse.error(USER_NOT_FOUND));
    }

请注意,代码更加简洁,对业务逻辑的“干扰”也更少。

这个例子展示了 monadic 的“控制反转”是多么方便:转换不需要检查 null,只有当值实际可用时才会调用它们。

“如果/当值可用时做某事”是开始方便地使用 Monads 的关键心态。

请注意,上面的示例保留了原始 API 的完整内容。但是更广泛地使用该方法并更改 API 是有意义的,因此它们将返回 Optional 而不是 null

    public Optional<UserProfileResponse> getUserProfileHandler4(final User.Id userId) {
        return optionalUserService.findById(userId).flatMap(
                user -> userProfileService.findById(userId).map(profile -> UserProfileResponse.of(user, profile)));
    }

一些观察:

  • 代码更简洁,包含几乎零样板。
  • 所有类型都是自动派生的。虽然并非总是如此,但在绝大多数情况下,类型是由编译器派生的---尽管与 Scala 相比,Java 中的类型推断较弱。
  • 没有明确的错误处理,而是我们可以专注于“快乐日子场景”。
  • 所有转换都方便地组合和链接,不会中断或干扰主要业务逻辑。
    事实上,上面的属性对于所有的 Monad 都是通用的。

抛还是不抛是个问题

事情并不总是如我们所愿,我们的应用程序生活在现实世界中,充满痛苦、错误和失误。有时我们可以和他们一起做点什么,有时不能。如果我们不能做任何事情,我们至少希望通知调用者事情并不像我们预期的那样进行。

在 Java 中,我们传统上有两种机制来通知调用者问题:

  • 返回特殊值(通常为空)
  • 抛出异常
    除了返回 null 我们还可以返回 Option Monad(见上文),但这通常是不够的,因为需要更多关于错误的详细信息。通常在这种情况下我们会抛出异常。

但是这种方法有一个问题。事实上,甚至很少有问题。

  • 异常中断执行流程

  • 异常增加了很多心理开销
    异常引起的心理开销取决于异常的类型:

  • 检查异常迫使你要么在这里处理它们,要么在签名中声明它们并将麻烦转移到调用者身上

  • 未经检查的异常会导致相同级别的问题,但编译器不支持
    不知道哪个更差。

Either Monad 来了

让我们先分析一下这个问题。我们想要返回的是一些特殊值,它可以是两种可能的事情之一:结果值(成功时)或错误(失败时)。请注意,这些东西是相互排斥的——如果我们返回值,则不需要携带错误,反之亦然。

以上是对Either Monad 的几乎准确描述:任何给定的实例都只包含一个值,并且该值具有两种可能类型之一。

任何 Monad 的接口都可以这样描述:

interface Either<L, R> {
    <T> Either<T, R> mapLeft(Function<L, T> mapper);

    <T> Either<T, R> flatMapLeft(Function<L, Either<T, R>> mapper);

    <T> Either<L, T> mapLeft(Function<T, R> mapper);

    <T> Either<L, T> flatMapLeft(Function<R, Either<L, T>> mapper);
}

该接口相当冗长,因为它在左右值方面是对称的。对于更窄的用例,当我们需要传递成功或错误时,这意味着我们需要就某种约定达成一致——哪种类型(第一种或第二种)将保存错误,哪种将保存值。

在这种情况下,Either 的对称性质使其更容易出错,因为很容易无意中交换代码中的错误和成功值。

虽然这个问题很可能会被编译器捕获,但最好为这个特定用例量身定制。如果我们修复其中一种类型,就可以做到这一点。显然,修复错误类型更方便,因为 Java 程序员已经习惯于从单个 Throwable 类型派生所有错误和异常。

Result Monad — 专门用于错误处理和传播的 Either Monad

所以,让我们假设所有错误都实现相同的接口,我们称之为失败。现在我们可以简化和减少接口:

interface Result<T> {
    <R> Result<R> map(Function<T, R> mapper);

    <R> Result<R> flatMap(Function<T, Result<R>> mapper);
}

Result Monad API 看起来与 Maybe Monad 的 API 非常相似。

使用这个 Monad,我们可以重写前面的例子:

    public Result<UserProfileResponse> getUserProfileHandler(final User.Id userId) {
        return resultUserService.findById(userId).flatMap(user -> resultUserProfileService.findById(userId)
                .map(profile -> UserProfileResponse.of(user, profile)));
    }

好吧,它与上面的示例基本相同,唯一的变化是 Monad — Result 而不是 Optional。与前面的例子不同,我们有关于错误的完整信息,所以我们可以在上层做一些事情。但是,尽管完整的错误处理代码仍然很简单并且专注于业务逻辑。

“承诺是一个很重要的词。它要么成就了什么,要么破坏了什么。”

我想展示的下一个 Monad 将是 Promise Monad。

必须承认,对于 Promise 是否是 monad,我还没有找到权威的答案。不同的作者对此有不同的看法。我纯粹是从实用的角度来看它的:它的外观和行为与其他 monad 非常相似,所以我认为它们是一个 monad。

Promise Monad 代表一个(可能还不可用的)值。从某种意义上说,它与 Maybe Monad 非常相似。

Promise Monad 可用于表示譬如对外部服务或数据库的请求结果、文件读取或写入等。基本上它可以表示任何需要 I/O 和时间来执行它的东西。Promise 支持与我们在其他 Monad 中观察到的相同的思维方式——“如果/当价值可用时做某事”。

请注意,由于无法预测操作是否成功,因此让 Promise 表示的不是 value 本身而是 Result 内部带有 value 是很方便的。

要了解它是如何工作的,让我们看一下下面的示例:

...
public interface ArticleService {
    // Returns list of articles for specified topics posted by specified users
    Promise<Collection<Article>> userFeed(final Collection<Topic.Id> topics, final Collection<User.Id> users);
}
...
public interface TopicService {
    // Returns list of topics created by user
    Promise<Collection<Topic>> topicsByUser(final User.Id userId, final Order order);
}
...
public class UserTopicHandler {
    private final ArticleService articleService;
    private final TopicService topicService;

    public UserTopicHandler(final ArticleService articleService, final TopicService topicService) {
        this.articleService = articleService;
        this.topicService = topicService;
    }

    public Promise<Collection<Article>> userTopicHandler(final User.Id userId) {
        return topicService.topicsByUser(userId, Order.ANY)
                .flatMap(topicsList -> articleService.articlesByUserTopics(userId, topicsList.map(Topic::id)));
    }
}

为了提供整个上下文,我包含了两个必要的接口,但实际上有趣的部分是 userTopicHandler() 方法。尽管这种方法的简单性令人怀疑:

  • 调用 TopicService 并检索由提供的用户创建的主题列表
  • 成功获取主题列表后,该方法提取主题 ID,然后调用 ArticleService,获取用户为指定主题创建的文章列表
  • 执行端到端的错误处理

后记

Monads 是非常强大和方便的工具。使用“当价值可用时做”的思维方式编写代码需要一些时间来习惯,但是一旦你开始使用它,它将让你的生活变得更加简单。它允许将大量的心理开销卸载给编译器,并使许多错误在编译时而不是在运行时变得不可能或可检测到。


本文译自:Beautiful World of Monads - DEV Community

posted @ 2021-11-10 20:32  码者无疆  阅读(43)  评论(0编辑  收藏  举报