错误建模:从异常到 2.4 的 Rich Errors
Kotlin 取消了受检异常(checked exception)—— 这是它对 Java 最有争议的一次背叛。理由很充分(Java 的受检异常导致了满世界的 catch (e) { /* ignore */ }),但它留下了一个洞:「这个函数会怎么失败」,现在没有人回答了。签名 fun fetchUser(id): User 声称「给我 id,还你 User」,可它随时会掀桌子,而编译器连提醒都不提醒你一句。这一章走完 Kotlin 错误处理的三个时代,终点是 2.4 的 Rich Errors —— 它把失败直接写进了函数类型。
同一个函数的三种活法
下面这台机器让你切换「输入」和「风格」,看同一个 fetchUser 在三个时代里怎么写、怎么失败。重点看签名那一行 —— 它说了多少真话:
第一代:异常(签名在撒谎)
异常本身没有错,错的是用它来表达「预期内的失败」。区分这两件事:
| 意外(bug) | 预期内的失败 | |
|---|---|---|
| 例子 | 数组越界、空指针、状态非法(IllegalStateException) | 用户不存在、网络断了、密码错了、表单校验失败 |
| 该怎么处理 | 崩溃(或者兜底上报)—— 这是程序员的错,该被修复 | 必须被处理 —— 这是业务的一部分,UI 得给用户看点什么 |
| 该用什么 | 异常(require / check / error) | 返回值(下面两代) |
判据一句话:如果调用方「必须知道并处理」这个失败,那它就不该是异常 —— 它该是返回类型的一部分。因为异常不会出现在签名里,编译器不会强制你处理,于是漏一个分支 = 线上崩溃,而且没人提醒你。
runCatching 在协程里是个陷阱(第三次强调)suspend fun load() = runCatching { api.fetch() }.getOrNull() // ✗
runCatching 抓 Throwable —— 它会把 CancellationException 一起吞掉,于是协程取消不掉了(第 15 章)。它还会吞掉 OutOfMemoryError 这种你根本不该 catch 的东西。
在协程代码里,把 runCatching 当成禁用词。要么手写 try/catch 并放行 CancellationException,要么用下面两代的方案(Arrow 的 Either.catch 已经正确处理了取消异常)。
第二代:sealed + Result(今天的最佳实践)
sealed interface FetchResult {
data class Ok(val user: User) : FetchResult
data object NotFound : FetchResult
data class Offline(val cause: IOException) : FetchResult
}
fun fetchUser(id: String): FetchResult // 签名说了真话
when (val r = fetchUser(id)) { // 少一个分支 = 编译错误(第 7 章)
is Ok -> show(r.user)
NotFound -> showMissing()
is Offline -> showOffline()
}
错误从「运行时的意外」变成了「类型里的一个值」。失败模式印在签名上,穷尽 when 逼你处理每一种,新增一种失败时编译器会把所有调用点标红。
这就是本书第 7 章那套方法论用在错误上的样子,也是今天 Android 项目里的标准做法。它的代价是样板:
- 每个函数都要造一个 sealed 层级(或者共用一个巨大的
AppError,然后每个函数都能返回一堆它其实不可能返回的错误 —— 精确性又丢了)。 - 成功值被包了一层:想链式调用三个可能失败的操作,你得写三层嵌套的
when,或者自己实现map/flatMap。
那 Kotlin 自带的 Result<T> 呢?它的错误位被焊死成 Throwable:
Result<User> // 失败时只能给你一个 Throwable
// 于是你想区分「用户不存在」和「网络断了」,只能:
result.onFailure { e ->
when (e) { // ← 靠 异常类型 来分派,穷尽检查?没有的
is NotFoundException -> ...
is IOException -> ...
else -> ... // 你永远得写这个 else
}
}
这就是为什么 kotlin.Result 在业务代码里不好用(它设计出来主要是给协程库内部用的)。它连「错误的类型」都表达不了,只是把异常换了个地方存。
第三代:Rich Errors(2.4)
error class NotFound(val id: String)
error class Offline(val cause: IOException)
// 联合类型:成功和失败并列写在返回类型里
fun fetchUser(id: String): User | NotFound | Offline
when (val r = fetchUser(id)) {
is User -> show(r) // 成功值没有被包一层
is NotFound -> showMissing()
is Offline -> showOffline()
}
它把第二代的收益全部留下(失败印在签名、穷尽检查、新增失败会标红所有调用点),把样板扔掉:不用造包装类型、成功值不被包一层、错误类型可以在函数之间自由组合。
实验特性 —— 语法还可能变,别急着重构整个项目。但它值得你现在就理解,因为它宣告了一件事:「错误是值」这条路线赢了,而这条路线正是下一章的 Arrow 用库的方式先趟出来的。
为什么这个设计比 Java 的受检异常好?两个关键差别:
- 它是「值」,不是「控制流」。受检异常强迫你 try/catch(于是人们写
catch (e) {}来闭嘴);联合类型只是个返回值,你可以 when、可以传下去、可以存进列表、可以 map —— 它能组合。 - 它不传染。Java 的
throws会顺着调用链一路往上污染每个签名。而联合类型只是返回值的一部分,中间层如果不处理,可以直接把它当值传出去。
一份可执行的错误建模纪律
① 分清 bug 和业务失败。bug 用异常并让它崩(require/check/error);业务失败用返回值。
② 错误类型要精确到函数。别造一个包含 30 种错误的上帝 AppError —— 那样每个调用点都得处理一堆它根本遇不到的情况,穷尽检查的价值就稀释了。
③ 错误要带上下文。data class NotFound(val id: String) 比 object NotFound 有用得多 —— 前者的日志能告诉你哪个 id 找不到。
④ 在边界上转换。网络层的 IOException、数据库层的 SQLiteException,不该原样漏到 UI 层。每一层有自己的错误语言,跨层时翻译一次(这也是防止底层实现细节泄漏到上层的关键)。
那协程里的错误怎么办
把这一章和第 17 章接起来 —— 一个实用的分工:
- 业务失败(用户不存在、密码错误)→ 返回值(sealed / Rich Errors)。它们不该触发协程的失败传播,因为它们不是异常情况。
- 基础设施失败(网络断、超时)→ 在 Repository 层 catch 掉并转换成返回值,别让它一路冒泡到 Job 树上去炸掉整个作用域。
- 真正的 bug → 让它冒泡,让
CoroutineExceptionHandler上报,让它崩。崩溃是有价值的信息,掩盖它才是罪。 CancellationException→ 永远放行。
Java 的受检异常有一个正确的直觉(失败应该出现在签名里、应该被强制处理)和一个糟糕的实现:
- 它不能组合。lambda 里抛受检异常?编译不过(所以 Java 的 Stream API 里满是
UncheckedIOException的包装 —— 语言自己把自己逼到了墙角)。 - 它传染整条调用链。底层加一个
throws,二十个上层方法的签名都得改。 - 于是人们绕过它。
catch (Exception e) { throw new RuntimeException(e); }—— 这行代码在每个 Java 项目里都有几十处,它把受检异常的所有好处一次性抵消。
Kotlin 的判断是:与其做一个人人都在绕过的强制机制,不如取消它、然后用类型系统重做一遍。Rich Errors 就是那个「重做一遍」的成果 —— 迟到了十几年,但它做对了:失败是值,不是控制流。
1. 挑一个 Repository 方法,写下它「所有可能的失败」。然后看它的签名 —— 签名里有几种?大概率是零。把它改造成 sealed 结果类型。
2. 全项目搜 runCatching。协程代码里的每一处都在吞取消信号。(这是本书第三次让你搜它 —— 因为它真的那么普遍。)
3. 检查你的错误类型:是不是一个包含几十种情况的上帝 AppError?试着按模块拆开,让每个函数只声明它真正可能返回的那几种。
4. 找一处「底层异常直接漏到 UI 层」的代码(UI 里 catch 了 SQLiteException 或者 HttpException)—— 在边界上把它翻译成业务错误。
下一章:Arrow。它用库的方式,提前好几年做了 Rich Errors 想做的事 —— 以及一份诚实的清单:什么时候不该引入它。