KotlinConf'25 语法前瞻:新错误类型系统 Rich Errors

1,122 阅读11分钟

去年的 KotlinConf 24 上,有很多前瞻性的 Kotlin 2.0+ 的新语法(可见我的另一篇 juejin.cn/post/737394…),而在今年的 Kotlin 25 上,部分语法已经变为现实,或者正在接近。本文聊一聊其中的一个:Rich Errors,或者去年称之为的 “联合错误类型”。它为 Kotlin 带来了全新的、强大的错误处理新体验

本文内容来自 Kotlin Conf 2025 主题演讲,至 2:14:00-2:59:30,原作者为 Kotlin 团队首席语言设计师(Lead Language Designer)。译者根据视频听译并做了整理

省流

  • 新提出了 Rich Errors,用于处理可恢复的(recoverable)错误,别的依旧用 Exception
  • 类似于 Nullable Value,提供了 ?. 和 !! 操作符
  • 可作为内置的、新的对 ResultOptional 范式的支持
  • 仍然在计划中,大约这个暑假里能见到 KEEP

引入

假设我们有如下两个函数,分别用于获取和解析用户信息, 且两个函数都有可能出错,我们怎么处理错误呢?

image.png

一个简单的方法是,让它们的返回值都可空,用 null 来表示出错。于是我们很容易写出下面的代码:

image.png

这很常见,而且也有很清晰的优势:

  • 可以链式调用
  • IDE 会提示可空
  • user != null 后 IDE 也会 smart cast

但它有个问题:如果出错了,我们无法具体知道问题出在哪一环——是获取的时候出错了呢,还是解析?或许我们可以使用密闭类来改进,增加每一种状态如下:

image.png

这样代码就可以针对不同的类来处理不同情况:

image.png

看起来不错,但它显然也有一些问题:

  • 可拓展性不高,比如 fetch 除了网络不好,还可能有其他错误
  • 写起来很繁琐
  • 结果是嵌套的(result.user.name

那如果像增强一下拓展性呢?我们干脆把 fetchparse 都抽出来,然后各自写各自的结构

image.png

看起来能工作了,但这也太繁琐了,而且还没办法通用。那我们写个通用的 Result 呢?

image.png

Result 包装了一层,这显然通用性更强了,大家都返回 Result,然后再获取呗:

image.png

现在代码变成了这样:

image.png

好吧,通用性有了,但这看起来更繁琐了,而且还有一堆函数操作(onSuccessonFailure),而且最后的 when 还得带一个烦人的 else(因为 IDE 不知道你已经穷举了所有情况)。

好吧,让我们回到最初的代码

image.png

我们只是执行函数、获得结果,仅此而已——但现在却写了一大坨。显然有什么不够好,有什么改进空间。为此,Kotlin 引进了新的 Rich Errors,作为对错误类型系统的改进:

image.png

上述代码有两个变化:

  • 新的 error class / error object 类型
  • 新的语法:用 | 操作符连接的联合错误类型

现在,代码将会变成这样:

image.png

几乎和 null 版本写法一致,非常简洁,并且也具有它的几个优点:

  • 支持链式调用
  • 直接调用 user.name 会有 IDE 的错误提示
  • Smart Cast
  • user.name 无需额外包装一层(result.user.name)

相对于 null 的方式,此时 user 可能会有三种情况:UserNetworkErrorParsing Error,你甚至可以用 when 来穷举

image.png

你可能会想,这有点像部分语言中的联合类型;但 Kotlin 的只适用于特殊的错误类型(Int | String 这种目前是不支持的)。它有一个确定的主类型,联合上若干个错误类型(这有助于 Kotlin 编译器能做出确切的推导,我们将在下文提到这一点)

设计原则

  1. Errors 也将像“值”一样工作,它不需要在控制流里有特殊规则

Errors works as values. No special rules for control flow

image.png

不像 Exception,它可能会打断当前执行流程,并在某个其他位置恢复执行(可能是某个 catch,甚至也可能是全局的 ExceptionHandler),新的 Error 更像一种“错误的值”,程序可以接着运行,拿到结果(正常结果,或者什么预期内可能出现的错误)

  1. Errors 为“可继续执行”的情况设计

Errors are designed for recoverable cases

这一条主要是为了区分开原本的 Exception,比如对于下面的例子:

image.png

假设我们需要对用户名进行处理,我们预期用户名非空、且以字母开头。如果情况不满足,我们将没法处理。这种出错是“不可恢复的”,程序没法在这类错误下继续往下执行,而在这种情况下应当用原先的 Exception,重新抛出或者处理。

  1. 可穷尽性非常重要

Exhaustiveness is crucial

对于 IDE,应该可以在代码补全上能穷尽所有可能出错的情况,避免遗漏,以便对每一种情况进行处理

image.png

  1. 链式调用以及简写也很重要

Chain-calls and shortcuts when working with errors matters

类似于可空变量,Kotlin 也为 Rich Errors 引入了 ?.!! 运算符,以简化调用

image.png

  1. 让类型保持清晰,推断算法为多项式复杂度

Keep the type system sane and polynomial

正如之前提及的,Rich Errors 不是联合类型,这不会让编译器的类型推导变成指数级别的复杂度

语法设计

新的 error 标识符

  • error 可以标记 classobjecterror class 也可以正常有成员变量等
  • 所有 Error Class 必须为 final 的,无法被继承
  • 不支持泛型:这有助于保持多项式复杂度,引入泛型将使得类型推导难度上升
  • 可以使用 typealias 构建 “联合错误类型”

如果目前有下面的 sealed class 结构

image.png

使用 Error Class 后,将不再需要复杂的继承结构:直接使用不同的 Error 类/单例即可

image.png

Error 不是 Any?

Error Class 现在将成为独立于 Any? 体系之外的新结构,我们将在下文看到这样设计的好处。由于这个设计,任何接受 Any? 或者普通泛型的函数都没法传入 Error Class

image.png

上面的代码都将产生编译器错误。如果你希望某个函数可以接受 Error Class 作为参数,那需要传入 Error;如果是泛型,则也需要指定 <T: Error>

image.png

新的类型树看起来如下:

image.png

使用 | 连接不同错误类型

正如之前看到的,我们可以用 | 连接不同错误类型:至多一个主类型,若干个 Error 类型。得益于上面的语法设计,它能做到:

  • 由于普通类型和 Error 类型处于两个体系下,因此能分开处理类型推断,不会混淆
  • 由于 Error Class 均为 final 类,因此任何两个 Error Class 均没有交集(除了基类 Error 或者特殊的 Nothing 类型),编译器可以很容易的做并集操作( 比如,A = B | C ; B = D | E => A | B = C | D | E )

额外的小甜品

引入新的 Error 类型还带来了一些别的好处,比如

“某值是否存在”的标记

为方便理解,我们举个例子:比如在标准库函数 last 中,要找到符合条件的最后一个值。它的代码目前是这样的

image.png

考虑到 T 是可空的,因此除了记录下找到的最后一个 last,我们还必须判断 last 到底出没出现——这引入了一个额外的 found 变量;而且,为了处理没出现的情况,last 变量只能申明为 T?,这导致最后出现了一个丑陋的强转(last as T)。这根本问题在于,以前没有任何一个值能表示“此值不存在”(null也不行,因为我们可能就是在找 null)——但现在有了。引入 Error 类型系统后,新的代码可以写成这样:

image.png

如图所示,现在我们可以新建一个 error object 来表示 “此值不存在”。由于 Error Class 和 T: Any? 不在同一个体系,这个表意就完全不会混淆,也就无需 found 变量记录;新的 last 可以声明为 T | NotFound,并在返回时 Smart Cast 为 T,这样最后的丑陋强转也不用了。

Optional Value

有一个故意在 Kotlin 中被省略的设计是:Optional。在诸如 Java、Rust、Swift、Haskell 中,都存在 Optional<T>(或者 Option<T>/Maybe),而在 Kotlin 中则更倾向于用 Nullable Value。但有些情况下这确实不太优雅,比如:

listOf(null, 42).fisrtOrNull()

上面的代码从效果上能实现 Optional<Int> 的效果,但是由于被 list 包了一层,并不直观;而且它无法处理 null 就是期望值的情况。但现在有了 Error Class,由于它和所有值都不是一个体系,因此它能够实现 Optional 的效果,也就是

Optional<T> -> T | NotFound

从语义上就表示:可能是 T(T也可空),或者不存在,比如:

fun <T> List<T>.first(): T | NotFound

这样看起来就清晰很多,也能消除【在可空列表中,寻找可空值时,返回null时】的歧义(到底 null 是寻找的结果,还是代表不存在)

关于运算符简写

如上文,Error Class 支持 ?.!! 两个操作费,不过相比可空值的对应符号,也会略有不同

譬如,对于表达式 exprA?.exprB?.exprC 时,当其返回 null 时,你没法知道到底是哪一块儿返回了 null;但是错误不一样,如果三个表达式可能返回的错误没有交集,那当错误产生时,你可以知道是谁错了(比如,假设 exprA/B/C 分别返回 errorA/B/C,那么当表达式结果为 errorB 时,你就能知道 exprB 错了,而 exprA 没问题)

对比联合类型

再次说明,Error 不是联合类型。下面的例子展示了,如果有联合类型,那么 Safe Call 就只能是二者的交集,比如

image.png

对于 A | B 类型,即使是 ?.,也只有他们共有的方法才能直接安全调用(比如 toString),foobar 都不能直接在 aOrB 上安全调用;但是 Rich Error 则不一样,由于只能有至多一个主类型,因此 ?. 时可以安全的调用主类型的方法,不会产生歧义

image.png

!! 运算符

当使用 !! 运算符时, T | Error 会被强转为 T,并在真的出错时调用 Error 的 throw()方法抛出异常,此方法默认抛出 KotlinException(Error),也可以自定义重写以满足业务需要

image.png

不提供 ?: 运算符

不像 Nullable Value,Rich Error 不提供 ?: 运算符。这基于下面的考量:Null 值可以忽略,但是错误应当处理。当然,鉴于 Error Class 是个类,因此如果想的话,也可以通过拓展函数来完成类似的操作:

image.png

将 Exception 转成 Error

Exception 将仍然可用,用于那些“不可恢复的”错误。如果想将老的 Exception 改为新的 Error,下面标准库的方法将很有帮助

image.png

比如用在 Java 的 Files API 上

image.png

现场提问

现场提的几个问题都非常好,本文也翻译如下。下文使用第一人称代指演讲者:

  1. 关于返回类型,我们必须要手写吗?编辑器可以自动推断吗?

啊,有的语言确实有,比如 Zig,它们的编译器尝试从函数体推断可能产生的错误,因此你能看到它写着返回 int 或者 error。不过……目前我们没这个计划,比如(zig)只是写了会返回 int 或错误(!u32),但没法穷举所有错误类型,因此调用处也还是得使用具体的错误类型

  1. 我们看到了 Error Class/Object,那么 Error Enum 呢?它们可以定义子错误,比如处理网络错误?

我们目前还不打算加入 Error Enum,但我们也看到了一些场景,需要用到有层次的错误类型的,也就差不多是 Error Enum。或许未来某一天我们确实会加入,但在那之前我们需要看看是否真的需要

  1. 映射到 Java 呢?Java 上的函数签名和最终的样子可能是什么?

好问题,不过答案可能没那么好。目前为了它的效率考虑,我们准备把它编译为普通 Java 对象。但我们也在考虑编译器插件,能够显示为独立的签名,比如 Optional 或者 Result 类。因此从 Java API 来说,你也可以调用这些函数,它会返回一个 Result。从 Java 侧的调用会有些运行时 overhead,不过 Kotlin 则不存在。

  1. 那么 Result 呢?它会被弃用吗?如果不,那么当 Rich Error 稳定后,它的用法又是什么呢?

Result 类不会被弃用,但我们相信它的使用会减少。Result 类的问题时,当我们创造它时,我们想让它足够严格,因此它只能接受一个泛型,并没有给错误留位置(这在其他语言很常见)。它目前在 Kotlinx Coroutine(协程)和别的一些场景有使用之处,不过我们相信 Rich Errors 会对用户更有用。所以,再次重申,尽管 Result 不会被废弃,但我们还是觉得 Rich Errors 在这类情况下是个更好的选择。


原文结束。无论如何是个很新的东西,大伙怎么看呢?