也许罢工
#java #kotlin #scala

在上一章结尾处,我们说Kotlin的Any?是适当的联合类型。同样,作为标准库的一部分,也存在多个方便的操作员和与Any?一起使用的功能。因此,根据这两个陈述,Kotlin的null处理被推断为“最好的”。今天,我将回顾一下它的弱点。同样,我们将研究Haskell及其Maybe类型的灵感。许多语言设计师无论如何都这样做,为什么我们不能?

通用接口

听起来像是破纪录的风险,我要声明Maybe的主要力量来自Universal Monad类型类。 Maybe本身可能不是对无效问题的最佳答案,而是其界面同时回答了其他数十个问题。这导致统一用法,从而导致更可读和可重复使用的代码。从OOP开发人员的角度来看,这是完全合理的。我们都听到了流行的“代码到接口”的口头禅。 Haskell就是这样做的。

monad,bind的关键功能具有签名m a -> (a -> m b) -> m b。使用类似Java的类型重写此功能可能看起来更清晰:Monad<A> -> Function<A, Monad<B>> -> Monad<B>。甚至更javish:Monad<B> bind(Monad<A> m, Function<A, Monad<B>> m)。这看起来很熟悉吗?它应该。这是Java代码库中非常流行的方法,通常称为flatMap。但是这样流行的方法的问题在哪里?

让我们看看它的3个核心示例:

Optional<A>.flatMap(Function<A, Optional<B>>) -> Optional<B>

Stream<A>.flatMap(Function<A, Stream<B>>) -> Stream<B>

CompletableFuture<A>.thenCompose(Function<A, CompletableFuture<B>>) -> CompletableFuture<B>
  • 我在这里清理某种类型的噪音
  • 野外存在更多的例子,但要展示这些想法就足够了
  • CompletableFuture是我的最爱:更糟的是,它甚至都不试图符合通常的命名

类型的种类

我的OOP培训尖叫声使一个共同的父母从相同类型的签名中抽象出来。为了实现这一目标,我们需要一个看起来像以下界面:

interface Monad<A> {
    Monad<B> flatMap(Function<A, Monad<B>> f);
}

这里的问题是它不够通用。它将向我们返回我们想要OptionalStreams的Monad。例如,expression optional.flatMap(...)将具有父母的Monad<B>类型,而不是孩子的Optional<B>。因此,在这样的flatMap之后

如果我们回到Haskell:

class Monad m where
  (>>=) :: m a -> (a -> m b) -> m b

我们将发现Haskell将容器本身声明为通用类型参数(注意m a中的m)。 Java可以做同样的事情吗?我无法实现它。如果您成功,可以破坏本文的推理。请继续。

显示我尝试过的所有不同类型的签名组合没有意义。他们要么不编译,要么(如初始“ naive” Monad<A>)不返回儿童类型。最漂亮的例子,只是为了说明我的思想流程:

interface Monad<A, C extends Monad<A, C>>

可以通过:
扩展

sealed interface Maybe<A> extends Monad<A, Maybe<A>> permits Just, Nothing

将我们的Monad parent类型缩小到适当的Maybe儿童类型并使用java 17.当我们想从Maybe<A>方法返回Maybe<B>时出现问题。我们的Monad.flatMap看起来像以下内容(请注意,我们在班级级别成功使用了相同的D extends Monad技巧):

<B, D extends Monad<B, D>> D flatMap(Function<A, D> f)

,因此,Maybe.flatMap

<B> Maybe<B> flatMap(Function<A, Maybe<B>> f)

不编译以下内容:flatMap(Function<A, Maybe<B>>)' in 'Maybe' clashes with 'flatMap(Function<A, D>)' in 'Monad';。即使Maybe<B>扩展了Monad<B, Maybe<B>,编译器也不会像以前在班级上使用C一样代替D

一般而言,这是Java编译器(以及Kotlin)的已知限制。要获取有关此事的更多信息,请搜索较高的类型。有多个Java库试图添加HKT,但在写所有这些库中,所有这些库都将自己介绍为“不准备生产的”。同样,听起来HKT应该是一种语言本身的一部分,使其足够高效。因此,不幸的是,目前,我们在Java-World中陷入了“实施代码”。

势在必行的范式

在Haskell中,通用的Monadic接口使我们能够编写声明性和(令人惊讶的是纯粹的功能性语言)命令代码。让我们看一个简单的例子。从某些数据库中获取某些实体的两种方法,从某些外部API中填充了一些元数据,然后将结果映射到DTO。

声明:

fetchDto id = fetchEntityFromDB id >>= populateMeta <&> toDto

命令:

fetchDto id = do
  entity <- fetchEntityFromDB id
  entityWithMeta <- populateMeta entity
  let dto = toDto entityWithMeta
  return dto

哪种代码看起来更熟悉,因此对于没有功能背景的Java开发人员来说更易读(即Java 8之前的普通编码器)?如果您不熟悉Haskell,请花点时间理解和比较这两个示例,它们真的很简单。

所以?..对。第一个!至少,这就是我们在Java 8中所拥有的。我听到第二种方法的声音较弱吗?不用害怕,您并不孤单。在JVM上有急需处理Monad的例子。不在Java本身,而是Kotlin的suspend/await让您迫切地编写Monadic代码,就像Haskell中的do一样(公平地说,与许多其他async/await实现类似)。是的,我们正在从舒适的Maybe外壳中走出来,到达这里不同的Monad的更广阔的土地。但是我们会返回,保证。

Java 8:

CompletableFuture<Dto> fetchDto(String id) {
  return fetchEntityFromDb(id)
      .thenCompose(this::populateMeta)
      .thenApply(this::toDto);
}

kotlin:

suspend fun fetchDto(id: String): Dto {
    val entity = fetchEntityFromDb(id)
    val entityWithMeta = populateMeta(entity)
    val dto = toDto(entityWithMeta)
    return dto
}

在Kotlin中,我们可以使用扩展方法将此函数缩短到单线。这不是重点。关键是我们在异步逻辑的命令和声明性范式之间有选择。

这很酷,但是假设我们要增加对其他单调类型的支持。例如,我们心爱的T?。我们需要在Kotlin中更改多少代码?

fun fetchDto(id: String): Dto? {
    val entity = fetchEntityFromDb(id)
    val entityWithMeta = entity?.let { populateMeta(it) }
    val dto = entityWithMeta?.let { toDto(it) }
    return dto
}

嗯,一点也不干!零,我们需要在Haskell中更改多少行?编码到接口,还记得一个吗?

工会方式

我们已经知道,在Scala中,另一种数学上正确的无效类型位于Scala中。它是否遵守Monadic界面?我们可以实施一个吗?

scala缺乏单核的明确特征,但是有一个隐含的界面,该界面被伪造使用,这又与我们已经看到的Haskell的do块非常相似。这是联合Maybe类型的一种可能的实现:

extension[A] (m: A | Null)
  def map[B](f: A => B): B | Null = m match
    case null => null
    case _ => f(m.nn)

  def flatMap[B](f: (=> A) => B | Null): B | Null = m match
    case null => null
    case _ => f(m.nn)

  def withFilter(f: A => Boolean): A | Null = m match
    case null => null
    case _ => if f(m.nn) then m.nn else null

接下来是我们的良好样本功能:

def fetchDto(id: String) = for {
  entity <- fetchEntityFromDb(id)
  entityWithMeta <- populateMeta(entity)
  dto <- toDto(entityWithMeta)
} yield dto

猜猜是什么?我们可以将我们的Maybe Monad更改为实施相同合同的任何其他类型,此代码不会更改。

让我们从上一章重新检查向后的兼容性规则。在此示例中,我们可以将populateMeta签名中的返回类型从Entity -> EntityWithMeta | NullEntity -> EntityWithMeta缩小。正如预期的那样,客户端代码不会被打破。相同的函数编译并正常工作!

这实际上使我们达到了有趣的副作用。现在,我们可以使用Monadic管道来处理不可废的类型:

val i: Int = 5
val j: Int | Null = i.map(_ - 3)

我看到的A | Null类型的一个主要缺点是,如果我们想真正有用,我们需要重新发明轮子(即重新实现所有功能,例如ziporElse,从Option类型)。你能发现其他任何问题吗?


总的来说,这一刻,我们已经涵盖了从Java 7到Scala 3 Union类型的一个有趣的进化路径。从理论的角度来看,后者现在似乎优于可用替代方案(这并不意味着它是理想的,是吗?)。在下一章(如果发布的话)中,我计划更多地提出“缺乏价值”的概念。 Null太具体了。 “接口的代码”,仍然记得一个吗?