炉 II · 熔炼CH 07进度 07/26

sealed + when:让非法状态无法表示

你的 UiState 里有 isLoading: BooleanhasError: Booleandata: List?errorMsg: String?。这四个字段能组合出十六种状态,而你的业务只承认四种。剩下十二种 —— 「正在加载并且出错并且有数据」这种鬼东西 —— 全都是合法的、可构造的、你的 UI 必须防御的那十二种就是你 bug 的产地。这一章教你把它们从类型里删掉:不是靠约定,不是靠注释,是让它们压根无法被写出来

sealed穷尽 whenguard conditions状态建模

先数一数你的鬼状态

这台机器会枚举出所有组合,把「业务承认的」和「鬼状态」标出来。勾上你自己 UiState 里真实有的字段,看看数字:

五个字段 = 32 种可表示状态,业务只要 4 种。28 个鬼状态。它们的代价体现在三个地方:

  • UI 层的防御性代码if (isLoading && !hasError)data?.takeIf { !isLoading } —— 每一处都是在手工排除本不该存在的状态。
  • 永远写不完的测试:你测了 4 种正常路径,那 28 种呢?
  • 最贵的那种 bug:某次异步回调把 isLoading 置回 false 但忘了清 errorMsg,于是用户看到一个转圈的加载页叠着一个错误提示。而这在类型层面是完全合法的。

解法:和类型

问题的根源是你在用积类型(product type)建模一个和类型(sum type)的东西。

  • 积类型 = 「而且」data class 就是积 —— 一个 User 同时有 name 而且有 age。状态数是起来的(2 × 2 × 2 × 2 = 16)。
  • 和类型 = 「或者」sealed 就是和 —— 一个 UiState 要么是 Loading 要么是 Success。状态数是起来的(1 + 1 + 1 + 1 = 4)。

UI 状态天生是「或者」,你却用「而且」去表示它 —— 于是多出来的全是垃圾。换成和类型:

sealed interface UiState {
    data object Loading : UiState
    data class  Success(val items: List<Item>) : UiState   // 数据只住在这里
    data object Empty : UiState
    data class  Error(val msg: String) : UiState            // 错误只住在这里
}

注意那两句注释 —— 这是最关键的一条军规:

◆ 锭 · 状态建模三条军规

① 状态携带自己的数据。items 只存在于 Success 里,别的状态想读都读不到。于是「Loading 时 data 是什么?」这个问题从根上消失了 —— 而在旧写法里,你必须回答它(通常答案是「null,但别忘了判空」)。

② when 一定要当表达式用。见下一节 —— 这是 Kotlin 最容易漏掉的一层保护。

③ 重构时先算一遍乘法。把「旧代码能表示 32 种状态,业务只要 4 种」写进 PR 描述里。这比任何布道都有说服力,因为它是一个数字

穷尽检查:Kotlin 白送你的编译期覆盖率

fun render(state: UiState) = when (state) {   // 少写一个分支 → 编译错误
    Loading    -> spinner()
    is Success -> list(state.items)     // 这里 items 不可能是 null
    Empty      -> emptyView()
    is Error   -> toast(state.msg)
}

三个月后产品说「加一个 Refreshing 状态」。你在 sealed 里加一个子类型 —— 然后编译器立刻在整个项目里把所有需要处理它的地方标红

这就是 sealed 的真正价值:它不是「更优雅的写法」,它是一个会在未来替你干活的机器人。加一个状态,编译器就把所有遗漏的地方交给你。这在动辄几十个 UI 分支的大项目里,是救命的。

⚠ 炉渣 · 别写 else,那会把机器人关掉
when (state) {
    is Success -> render(state.items)
    else -> spinner()      // ← 你亲手关掉了穷尽检查
}

加了 else,将来新增 Refreshing 时编译器一声不吭,用户会看到一个永远转圈的加载页。这个 bug 会在测试里活下来,因为没人想到要测「新状态」。

规则:对 sealed / enum 的 when,永远不写 else。宁可多写几个分支 —— 那几行的成本,远低于一次线上事故。真的有一堆分支处理方式相同?用逗号合并:Loading, Empty -> spinner()

一个历史遗留的细节

老 Kotlin 里有个坑:只有 when 表达式(结果被赋值或返回)才做穷尽检查,when 语句不做。于是这段代码在几年前是合法的:

when (state) {          // 当语句用:老版本里不检查穷尽!
    Loading -> spinner()
    is Error -> toast(state.msg)
    // 漏了 Success 和 Empty,编译器一句话没有
}

好消息:这个坑已经填了。从 Kotlin 1.7 起,对 sealed / enum / Boolean 的 when 语句不穷尽也是编译错误。但如果你的团队里还有人抱着「when 一定要写成表达式才安全」的老经验 —— 那条经验现在只是风格建议了(当然它依然是好风格:表达式形态更难写出遗漏的副作用)。

✦ 新炉料 · guard conditions 2.1 转正

「Success 但列表是空的」这种情况,以前只能在分支里嵌一层 if —— 而那一层 if 是逃出穷尽检查视线的:

// 2.1 之前
when (state) {
    is Success -> if (state.items.isEmpty()) showEmpty()   // 嵌套 if,逻辑藏起来了
                  else showList(state.items)
    ...
}

// 2.1 起:在 is 分支后面直接挂守卫
when (state) {
    is Success if state.items.isEmpty() -> showEmpty()
    is Success                          -> showList(state.items)
    is Error   if state.isRetryable     -> retry()
    is Error                            -> toast(state.msg)
    Loading                             -> spinner()
}

所有分派逻辑回到同一个平面上,一眼看完,而且穷尽检查照常生效。这是从 Haskell/Rust 那边搬过来的守卫(guard)—— 一个 Java 至今没有的东西(Java 21 的 switch 有 when 子句,其实是同一个概念,只是名字反过来了)。

✦ 新炉料 · 上下文敏感解析:省掉那串限定名 2.2 引入 · 2.3 改进

以前 when 分支里必须写全 UiState.Loading,长得让人想哭。现在在期望类型已知的位置,编译器能自己补上限定名:

when (state) {
    Loading -> spinner()          // 不用再写 UiState.Loading
    is Success -> ...
}

纯粹的可读性改进,属于「升级即得、什么都不用改」的那类好事。

sealed class 还是 sealed interface?

默认用 sealed interface。理由:

  • 它允许多重继承。一个类型可以同时属于两个 sealed 层级 —— 比如 NetworkError 既是 FetchError 又是 Retryable。用 sealed class(单继承)就做不到。
  • 它不带构造函数和状态,更干净。

什么时候用 sealed class?当所有子类型确实共享状态时(比如所有的 Error 都有一个 timestamp)—— 但即使这种情况,通常也可以用接口里的抽象 val 解决。

顺带两个语法点:

  • data object(1.9 起):没有数据的状态用它,白送一个好看的 toString()Loading 而不是 com.foo.UiState$Loading@3f2a1b)和正确的 equals。别再用 object 了。
  • sealed 的封闭范围:子类型必须和父类型在同一个模块、同一个包里(1.5 起放宽到这个粒度,以前必须在同一文件)。这就是「sealed(密封)」的含义 —— 编译器能看全所有可能,才敢做穷尽检查。

sealed vs enum:什么时候用哪个

enumsealed
实例数量固定的几个单例子类型固定,但 Success(items) 可以有无数个实例
能携带数据吗只能带相同结构的数据(每个枚举值都有那几个字段)每个子类型带自己的数据,结构可以完全不同
穷尽 when
典型场景真正的枚举:星期、性别、日志级别、排序方向状态、结果、事件、错误 —— 任何「分支之间携带不同数据」的东西

判据一句话:分支之间需要带不同的数据吗?需要 → sealed;不需要 → enum。

落到你的项目里

Android 上,这套建模有三个立刻能用的落点:

① UI 状态(上面讲的)。② 用户意图 / 事件

sealed interface Intent {
    data class Search(val q: String) : Intent
    data object Refresh : Intent
    data class Delete(val id: ItemId) : Intent
}

// reducer 是一个纯函数,穷尽 when 保证你处理了每一种意图
fun reduce(state: UiState, intent: Intent): UiState = when (intent) { ... }

如果你在用 MVI 或者 Compose 的 state hoisting —— 恭喜,你已经在这个模式里了。sealed 给它上了编译器的保险。

③ 错误sealed interface FetchError —— 让「这个函数会怎么失败」写在类型里,而不是藏在文档里。这是第 20 章的正题。

▸ 旧模具 · Java 21 追上来了(真的)

这是 Java 这几年最实在的一次进步:record(积类型)+ sealed interface(和类型)+ switch 模式匹配,Kotlin 的三件套在 Java 21 里凑齐了:

sealed interface UiState permits Loading, Success, Error {}
record Success(List<Item> items) implements UiState {}
record Error(String msg) implements UiState {}

String render(UiState s) {
    return switch (s) {                        // 穷尽检查 ✓
        case Success v when v.items().isEmpty() -> "empty";   // 守卫 ✓
        case Success v -> list(v.items());
        case Error e   -> toast(e.msg());
        case Loading l -> "spinner";
    };
}

连解构(record pattern:case Success(List items))都有。所以本章的思想不是 Kotlin 专属的 —— 如果你的项目里还有 Java 模块,同样的建模照样能做。

Kotlin 剩下的优势是写起来的分量data object Loading : UiState 一行,Java 要三行;data classcopy() Java 没有。差距不再是「能不能」,而是「顺不顺手」—— 而在你每天要写几十遍的东西上,顺手就是生产力。

✚ 动手锻打

1. 本周就做这一件事:找出项目里最脏的那个 UI 状态类(那个有 isLoading + error + 可空 data 的),先数一数它能表示多少种状态,再重构成 sealed。把「32 → 4」这个数字写进 PR 描述。

2. 重构后统计两个数字:UI 层删掉了多少处判空和条件组合?测试少写了多少个 case?—— 这两个数字是你说服团队的弹药。

3. 全项目搜 else ->。每一处都问:主语是 sealed 或 enum 吗?如果是,删掉 else 补全分支 —— 你可能会当场发现一两个漏处理的状态。

4. 如果项目已经升到 2.1+:找一处「is X 里嵌了 if」的 when,改写成 guard condition,体会一下分派逻辑回到同一平面的清爽。

下一章是熔炼炉的最后一站:by —— 那个你只用过 by lazy 的关键字,其实是 Kotlin 最被低估的扩展点。