炉 V · 淬火CH 20进度 20/26

错误建模:从异常到 2.4 的 Rich Errors

Kotlin 取消了受检异常(checked exception)—— 这是它对 Java 最有争议的一次背叛。理由很充分(Java 的受检异常导致了满世界的 catch (e) { /* ignore */ }),但它留下了一个洞:「这个函数会怎么失败」,现在没有人回答了。签名 fun fetchUser(id): User 声称「给我 id,还你 User」,可它随时会掀桌子,而编译器连提醒都不提醒你一句。这一章走完 Kotlin 错误处理的三个时代,终点是 2.4 的 Rich Errors —— 它把失败直接写进了函数类型。

受检异常sealed 错误ResultRich Errors 2.4

同一个函数的三种活法

下面这台机器让你切换「输入」和「风格」,看同一个 fetchUser 在三个时代里怎么写、怎么失败。重点看签名那一行 —— 它说了多少真话

第一代:异常(签名在撒谎)

异常本身没有错,错的是用它来表达「预期内的失败」。区分这两件事:

意外(bug)预期内的失败
例子数组越界、空指针、状态非法(IllegalStateException用户不存在、网络断了、密码错了、表单校验失败
该怎么处理崩溃(或者兜底上报)—— 这是程序员的错,该被修复必须被处理 —— 这是业务的一部分,UI 得给用户看点什么
该用什么异常require / check / error返回值(下面两代)

判据一句话:如果调用方「必须知道并处理」这个失败,那它就不该是异常 —— 它该是返回类型的一部分。因为异常不会出现在签名里,编译器不会强制你处理,于是漏一个分支 = 线上崩溃,而且没人提醒你。

⚠ 炉渣 · runCatching 在协程里是个陷阱(第三次强调)
suspend fun load() = runCatching { api.fetch() }.getOrNull()   // ✗

runCatchingThrowable —— 它会把 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)

✦ 新炉料 · 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 想做的事 —— 以及一份诚实的清单:什么时候不该引入它。