去年的 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,提供了 ?. 和 !! 操作符
- 可作为内置的、新的对
Result、Optional范式的支持 - 仍然在计划中,大约这个暑假里能见到 KEEP
引入
假设我们有如下两个函数,分别用于获取和解析用户信息, 且两个函数都有可能出错,我们怎么处理错误呢?
一个简单的方法是,让它们的返回值都可空,用 null 来表示出错。于是我们很容易写出下面的代码:
这很常见,而且也有很清晰的优势:
- 可以链式调用
- IDE 会提示可空
user != null后 IDE 也会 smart cast
但它有个问题:如果出错了,我们无法具体知道问题出在哪一环——是获取的时候出错了呢,还是解析?或许我们可以使用密闭类来改进,增加每一种状态如下:
这样代码就可以针对不同的类来处理不同情况:
看起来不错,但它显然也有一些问题:
- 可拓展性不高,比如 fetch 除了网络不好,还可能有其他错误
- 写起来很繁琐
- 结果是嵌套的(
result.user.name)
那如果像增强一下拓展性呢?我们干脆把 fetch 和 parse 都抽出来,然后各自写各自的结构
看起来能工作了,但这也太繁琐了,而且还没办法通用。那我们写个通用的 Result 呢?
Result 包装了一层,这显然通用性更强了,大家都返回 Result,然后再获取呗:
现在代码变成了这样:
好吧,通用性有了,但这看起来更繁琐了,而且还有一堆函数操作(onSuccess、onFailure),而且最后的 when 还得带一个烦人的 else(因为 IDE 不知道你已经穷举了所有情况)。
好吧,让我们回到最初的代码
我们只是执行函数、获得结果,仅此而已——但现在却写了一大坨。显然有什么不够好,有什么改进空间。为此,Kotlin 引进了新的 Rich Errors,作为对错误类型系统的改进:
上述代码有两个变化:
- 新的
error class/error object类型 - 新的语法:用
|操作符连接的联合错误类型
现在,代码将会变成这样:
几乎和 null 版本写法一致,非常简洁,并且也具有它的几个优点:
- 支持链式调用
- 直接调用
user.name会有 IDE 的错误提示 - Smart Cast
user.name无需额外包装一层(result.user.name)
相对于 null 的方式,此时 user 可能会有三种情况:User、NetworkError、Parsing Error,你甚至可以用 when 来穷举
你可能会想,这有点像部分语言中的联合类型;但 Kotlin 的只适用于特殊的错误类型(Int | String 这种目前是不支持的)。它有一个确定的主类型,联合上若干个错误类型(这有助于 Kotlin 编译器能做出确切的推导,我们将在下文提到这一点)
设计原则
- Errors 也将像“值”一样工作,它不需要在控制流里有特殊规则
Errors works as values. No special rules for control flow
不像 Exception,它可能会打断当前执行流程,并在某个其他位置恢复执行(可能是某个 catch,甚至也可能是全局的 ExceptionHandler),新的 Error 更像一种“错误的值”,程序可以接着运行,拿到结果(正常结果,或者什么预期内可能出现的错误)
- Errors 为“可继续执行”的情况设计
Errors are designed for recoverable cases
这一条主要是为了区分开原本的 Exception,比如对于下面的例子:
假设我们需要对用户名进行处理,我们预期用户名非空、且以字母开头。如果情况不满足,我们将没法处理。这种出错是“不可恢复的”,程序没法在这类错误下继续往下执行,而在这种情况下应当用原先的 Exception,重新抛出或者处理。
- 可穷尽性非常重要
Exhaustiveness is crucial
对于 IDE,应该可以在代码补全上能穷尽所有可能出错的情况,避免遗漏,以便对每一种情况进行处理
- 链式调用以及简写也很重要
Chain-calls and shortcuts when working with errors matters
类似于可空变量,Kotlin 也为 Rich Errors 引入了 ?. 和 !! 运算符,以简化调用
- 让类型保持清晰,推断算法为多项式复杂度
Keep the type system sane and polynomial
正如之前提及的,Rich Errors 不是联合类型,这不会让编译器的类型推导变成指数级别的复杂度
语法设计
新的 error 标识符
- error 可以标记
class和object,error class也可以正常有成员变量等 - 所有
Error Class必须为 final 的,无法被继承 - 不支持泛型:这有助于保持多项式复杂度,引入泛型将使得类型推导难度上升
- 可以使用 typealias 构建 “联合错误类型”
如果目前有下面的 sealed class 结构
使用 Error Class 后,将不再需要复杂的继承结构:直接使用不同的 Error 类/单例即可
Error 不是 Any?
Error Class 现在将成为独立于 Any? 体系之外的新结构,我们将在下文看到这样设计的好处。由于这个设计,任何接受 Any? 或者普通泛型的函数都没法传入 Error Class
上面的代码都将产生编译器错误。如果你希望某个函数可以接受 Error Class 作为参数,那需要传入 Error;如果是泛型,则也需要指定 <T: Error>
新的类型树看起来如下:
使用 | 连接不同错误类型
正如之前看到的,我们可以用 | 连接不同错误类型:至多一个主类型,若干个 Error 类型。得益于上面的语法设计,它能做到:
- 由于普通类型和 Error 类型处于两个体系下,因此能分开处理类型推断,不会混淆
- 由于 Error Class 均为 final 类,因此任何两个 Error Class 均没有交集(除了基类 Error 或者特殊的 Nothing 类型),编译器可以很容易的做并集操作( 比如,A = B | C ; B = D | E => A | B = C | D | E )
额外的小甜品
引入新的 Error 类型还带来了一些别的好处,比如
“某值是否存在”的标记
为方便理解,我们举个例子:比如在标准库函数 last 中,要找到符合条件的最后一个值。它的代码目前是这样的
考虑到 T 是可空的,因此除了记录下找到的最后一个 last,我们还必须判断 last 到底出没出现——这引入了一个额外的 found 变量;而且,为了处理没出现的情况,last 变量只能申明为 T?,这导致最后出现了一个丑陋的强转(last as T)。这根本问题在于,以前没有任何一个值能表示“此值不存在”(null也不行,因为我们可能就是在找 null)——但现在有了。引入 Error 类型系统后,新的代码可以写成这样:
如图所示,现在我们可以新建一个 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 就只能是二者的交集,比如
对于 A | B 类型,即使是 ?.,也只有他们共有的方法才能直接安全调用(比如 toString),foo 和 bar 都不能直接在 aOrB 上安全调用;但是 Rich Error 则不一样,由于只能有至多一个主类型,因此 ?. 时可以安全的调用主类型的方法,不会产生歧义
!! 运算符
当使用 !! 运算符时, T | Error 会被强转为 T,并在真的出错时调用 Error 的 throw()方法抛出异常,此方法默认抛出 KotlinException(Error),也可以自定义重写以满足业务需要
不提供 ?: 运算符
不像 Nullable Value,Rich Error 不提供 ?: 运算符。这基于下面的考量:Null 值可以忽略,但是错误应当处理。当然,鉴于 Error Class 是个类,因此如果想的话,也可以通过拓展函数来完成类似的操作:
将 Exception 转成 Error
Exception 将仍然可用,用于那些“不可恢复的”错误。如果想将老的 Exception 改为新的 Error,下面标准库的方法将很有帮助
比如用在 Java 的 Files API 上
现场提问
现场提的几个问题都非常好,本文也翻译如下。下文使用第一人称代指演讲者:
- 关于返回类型,我们必须要手写吗?编辑器可以自动推断吗?
啊,有的语言确实有,比如 Zig,它们的编译器尝试从函数体推断可能产生的错误,因此你能看到它写着返回 int 或者 error。不过……目前我们没这个计划,比如(zig)只是写了会返回 int 或错误(!u32),但没法穷举所有错误类型,因此调用处也还是得使用具体的错误类型
- 我们看到了 Error Class/Object,那么 Error Enum 呢?它们可以定义子错误,比如处理网络错误?
我们目前还不打算加入 Error Enum,但我们也看到了一些场景,需要用到有层次的错误类型的,也就差不多是 Error Enum。或许未来某一天我们确实会加入,但在那之前我们需要看看是否真的需要
- 映射到 Java 呢?Java 上的函数签名和最终的样子可能是什么?
好问题,不过答案可能没那么好。目前为了它的效率考虑,我们准备把它编译为普通 Java 对象。但我们也在考虑编译器插件,能够显示为独立的签名,比如 Optional 或者 Result 类。因此从 Java API 来说,你也可以调用这些函数,它会返回一个 Result。从 Java 侧的调用会有些运行时 overhead,不过 Kotlin 则不存在。
- 那么 Result 呢?它会被弃用吗?如果不,那么当 Rich Error 稳定后,它的用法又是什么呢?
Result 类不会被弃用,但我们相信它的使用会减少。Result 类的问题时,当我们创造它时,我们想让它足够严格,因此它只能接受一个泛型,并没有给错误留位置(这在其他语言很常见)。它目前在 Kotlinx Coroutine(协程)和别的一些场景有使用之处,不过我们相信 Rich Errors 会对用户更有用。所以,再次重申,尽管 Result 不会被废弃,但我们还是觉得 Rich Errors 在这类情况下是个更好的选择。
原文结束。无论如何是个很新的东西,大伙怎么看呢?