在上一章结尾处,我们说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);
}
这里的问题是它不够通用。它将向我们返回我们想要Optional
和Stream
s的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 | Null
到Entity -> EntityWithMeta
缩小。正如预期的那样,客户端代码不会被打破。相同的函数编译并正常工作!
这实际上使我们达到了有趣的副作用。现在,我们可以使用Monadic管道来处理不可废的类型:
val i: Int = 5
val j: Int | Null = i.map(_ - 3)
我看到的A | Null
类型的一个主要缺点是,如果我们想真正有用,我们需要重新发明轮子(即重新实现所有功能,例如zip
和orElse
,从Option
类型)。你能发现其他任何问题吗?
总的来说,这一刻,我们已经涵盖了从Java 7到Scala 3 Union类型的一个有趣的进化路径。从理论的角度来看,后者现在似乎优于可用替代方案(这并不意味着它是理想的,是吗?)。在下一章(如果发布的话)中,我计划更多地提出“缺乏价值”的概念。 Null
太具体了。 “接口的代码”,仍然记得一个吗?