14

Optional 不管用的日子

 4 years ago
source link: https://lotabout.me/2020/When-Monad-Fails/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Java Optional 类代表的是 Monad/单子 的概念,在使用时通常会写成链式调用的代码,但实际使用时会发现:有很多场景无法用链式调用表示。

Optional 的蜜月期

Optional 提提供了 map , filter , flatMap 等方法来链式调用,例如下面代码:

Report getUsersLatestReport(long userId) {
  User user = findUserByUserId(userId);
  if (user == null) {
    return null;
  }

  String content = findReportContentById(user.getReportId());
  return JsonUtil.fromJson(content, Report.class);
}

如果 findUserByUserId 返回 Optional<User> ,其它方法也都返回 Optional ,则可以用链式调用表示,代码会简洁很多:

Optional<Report> getUsersLatestReport(long userId) {
  return findUserByUserId(userId)      // ①
      .map(User::getReportId)          // ②
      .flatMap(findReportContentById); // ③
      .flatMap(content -> JsonUtil::fromJson(content, Report.class)); // ④
}

能这么做的原因是 mapflatMap 隐式地处理了方法返回 Optional::empty 的情况,例如当 ① 返回为 empty 时, map 会短路,跳过 ② 的执行,同理跳过 ③、④的执行。因此 ②、③、④ 的方法调用中就不需要关心 user 返回为空的情形,使代码变得简单。

P.S. 下文会把 mapflatMap 里的逻辑称为“模块”。

链式调用的“短视”

链式调用有一个局限:一个模块中只能看到模块的输入,无法感知其它模块的信息。这点限制在写业务代码时容易成为掣肘,一个常见的需求是:输出日志时需要全局信息,例如:

Report getUsersLatestReport(long userId) {
  User user = findUserByUserId(userId);
  if (user == null) {
    return null;
  }

  long reportId = user.getReportId();
  String content = findReportContentById(reportId);
  Report report = JsonUtil.fromJson(content, Report.class);
  if (report == null) {
    log.error("Failed to deserialize report, userId: {}, reportId: {}", userId, reportId);
  }
  return report;
}

这里的 log 需要 userIdreportIduserId 是方法的入参,方便获得,但 reportId 是中间输出结果,用链式调用就很难写。

其中一个方法是将链式分段,这样能引用其它模块的输出:

Optional<Report> getUsersLatestReport(long userId) {
  Optional<Long> oReportId = findUserByUserId(userId)
      .map(User::getReportId);

  Optional<Report> oReport = oReportId
      .flatMap(findReportContentById);
      .flatMap(content -> JsonUtil::fromJson(content, Report.class));

  if (!oReport.isPresent()) {
    log.error("Failed to deserialize report, userId: {}, reportId: {}",
        userId, oReportId.get());
  }
  return oReport;
}

也可以用线程安全的变量存储。还可以用包装类(如 Guava 里的 Pair )将结果一路传到底,但这样中间的所有模块都需要处理这个额外的状态。

但不管是哪一类,都让代码显得不再“简洁”。

Monad 生态隔离

一个 Monad 代表一个生态,不同生态间是不能“平滑”互通的,需要显式转换。“Monad”这个概念对于不了解Category Theory 的同学会很陌生,这里也不想强行理论化。

举个例子,Java 中的 OptionalStream 都提供了 empty , map , flatMap 等方法,概念上它们就是 Monad。对于 OptionalStream 可以方便地链式调用,但是一条链里没有办法同时处理 OptionalStream

例如对于下面的代码,没有用 OptionalStream

Report getUsersLatestReport(long userId) {
  User user = findUserByUserId(userId);
  if (user == null) {
    return null;
  }

  List<Report> reports = findReports(user.getCategory());
  if (reports.isEmpty()) {
    return null;
  }

  reports.sort(Comparator.comparing(Report::getCreateTime).reversed());
  return reports.get(0);
}

而如果用 OptionalStream 可以这样实现:

Optional<Report> getUsersLatestReport(long userId) {
  return findUserByUserId(userId)
    .flatMap(user -> findReports(user.getCategory)
         .stream()      // ①
         .sorted(Comparator.comparing(Report::getCreateTime).reversed())
         .findFirst()); // ②
}

这份代码看着还是比不用链式调用简洁。但要注意两点:

  1. ① 处创建了 Stream 并且 Stream 的链式调用实际上都在 Optional 的同一个 flatMap 调用中
  2. Stream 能与 Optional 互通,多亏了 ② 中的 findFirst 方法创建了一个 Optional 对象

上面代码中的 Stream 生态,主动知晓了 Optional 生态,并提供了适配的方法( findFirst 返回了 Optional )。生态互通依赖主动适配,意味着自建的 Monad 实际上不容易融合到已有的生态中。

而理想的链式调用应该是“单层”:

Optional<Report> getUsersLatestReport(long userId) {
  return findUserByUserId(userId)
    .getStream(user -> findReports(user.getCategory).stream())  // getStream 方法实际不存在
    .sorted(Comparator.comparing(Report::getCreateTime).reversed())
    .findFirst();
}

分支条件无法化简

如果逻辑里出现分支条件,那么即使提供链式的机制,分支也不可避免要存在于链式的模块里。例如:

Report getUsersLatestReport(long userId) {
  User user = findUserByUserId(userId);
  if (user == null) {
    return null;
  }

  return user.getAge() > 50
        ? getReportFromOldSystem(user.getReportId())
        : getReportFromNewSystem(user.getReportId())
}

这段代码要如何“化简”成链式调用?也许只能化简 user == null 的部分了:

Optional<Report> getUsersLatestReport(long userId) {
  return findUserByUserId(userId)
      .flatMap(user -> user.getAge() > 50
        ? getReportFromOldSystem(user.getReportId())
        : getReportFromNewSystem(user.getReportId())
      );
}

而当分支条件多的时候,或者说链式调用里模块的逻辑复杂的时候,代码也不再“简洁”了。

小结

Java 中的 Optional 不仅仅是 null 的另一个实现,它与 Stream 一样在概念上是 MonadMonad 最直观的作用是允许我们通过 map , flatMap 等方法做链式调用,但在一些特定的情况下却并不好用,例如:

  • 某个模块依赖多个输入,而某些输入依赖其它模块的输出时
  • 当你需要创建自己的 Monad,处理多个 Monad 的生态互通时
  • 模块逻辑中含有分支条件时

许多“理想”的模式在实践中会有不少问题。例如笔者尝试用 WebFlux 写反应式编程,发现很长的链式调用可读性会降低,因为很难追踪中间操作的含义;遇到需要多输入,需要中间变量的操作时,很难组装成链式调用;在写 Rust 时发现用 Result 处理错误,不同的错误类型间的转换非常繁琐……

在了解了Expression Problem 受限于代码的编写维度后,就在想有些困难是不是受限于一些无法解决的客观事实。例如 Rust 里的错误处理总是让人诟病,应该是由于 Monad 生态隔离,导致需要很多手工的适配。而用 Monad 又是为了使用它的链式调用来让代码变得“简洁”。我们会觉得链式调用“简洁”,是不是因为它是线性的代码?而人比较难理解分支的逻辑。而这是不是又受限于人的短期记忆(比如只能记住7样事物)?毕竟很长的链式调用其实也很难理解。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK