3.2 密封类状态建模与when穷举


3.2 密封类状态建模与 when 穷举

本节摘要:密封类与密封接口把类型层次限制在编译单元内,编译器因此知道"全部子类就这几个",when 表达式无需 else 即可穷举——新增子类时所有漏改的调用点直接编译失败。本节讲密封层次的语法、穷举 when 与智能转换的配合、枚举与密封类的选择标准、UI 状态机的安卓实战,以及"为什么穷举 when 里不该写 else"。

那个没有报错的线上事故

支付页要展示四种状态:加载中、支付成功、支付失败、等待确认。初版上线时一切正常。三个月后产品加了"分期支付",后端新增状态 INSTALLMENT_PENDING。前端是谁改的?没人改——Java 版的渲染代码长这样:

private void render(PayState state) { switch (state.getName()) { case "LOADING": showLoading(); break; case "SUCCESS": showSuccess(state.getReceipt()); break; case "FAILED": showError(state.getReason()); break; case "CONFIRMING": showConfirm(); break; // 没有default,新状态静默跳过 } }

新状态到达时,页面停在加载动画上,永远转圈。没有崩溃、没有日志,用户退单,客服录音里出现"你们页面卡了"。复盘时所有人都同意:问题不在写代码的人,在于语言没有任何机制把"新增了一个状态"广播给所有相关代码。字符串 switch 连"状态集合是什么"都没表达——那个集合只存在于前后端的一份口头约定里。

这就是第二类结构性事故:穷举遗漏。它比 NPE 更隐蔽,因为 NPE 至少会崩溃报警,穷举遗漏是静默的。

密封层次:把全集交给编译器

Kotlin 的密封(sealed)修饰符做一件事:声明"这个类型的子类只能在这一个文件(编译单元)里出现"。由此编译器掌握了类型的完整清单,穷举检查才有可能:

sealed interface PayState { data object Loading : PayState data class Success(val receipt: Receipt) : PayState data class Failed(val reason: String, val retryable: Boolean) : PayState data object Confirming : PayState data class InstallmentPending(val plan: List<Installment>) : PayState }

密封接口(Kotlin 1.5 起)与密封类(老写法 sealed class,子类要 : PayState() 继承)能力等价,新代码建议密封接口——子类还可以同时继承别的类型,限制更少。子类形态随意:data object 表达无数据单例(Kotlin 1.9 起推荐 data object,toString 更友好)、data class 表达带数据的状态、甚至普通对象。

渲染端写成没有 else 的穷举 when

fun render(state: PayState) = when (state) { PayState.Loading -> showLoading() is PayState.Success -> showSuccess(state.receipt) is PayState.Failed -> showError(state.reason, state.retryable) PayState.Confirming -> showConfirm() is PayState.InstallmentPending -> showInstallment(state.plan) }

两件事同时发生。其一,穷举检查:when 作为表达式(或 Kotlin 1.7 之后即便作为语句用穷举形式)时,编译器核对密封类型的全部子类是否都被覆盖,漏一个编译失败。其二,智能转换:is PayState.Success 分支内,state 的静态类型自动收窄为 PayState.Successstate.receipt 直接可访问——没有强转、没有 ClassCastException。对照上面的 Java 版本:类型全集从口头约定变成类型层次,遗漏从静默跳过变成编译错误,取数据从强转变成智能转换。三处事故面一次性拆除。

新增分支的连锁反应

现在重演三个月后的需求:加分期状态。在密封接口里加一个子类:

data class InstallmentPending(val plan: List<Installment>) : PayState

按下编译键的瞬间,全工程每一个穷举 this PayState 的 when 都标红——支付页渲染函数、埋点上报函数、深链路由函数,只要之前认真穷举过,现在全部要求补分支。这才是穷举检查的真正价值:不是防止第一次写漏(第一次写漏的概率不高),而是让类型的演化无法绕过它的消费者。产品经理可以随意加状态,编译器替你把所有需要改的地方列出来。

对比另外两种建模的连锁反应。字符串或 Int 常量:新增值零反应,全部遗漏静默。枚举:

enum class PayStateName { LOADING, SUCCESS, FAILED, CONFIRMING }

枚举也能穷举检查,但每个成员都是单例,携带不了各自不同的数据——SUCCESS 要带小票、FAILED 要带原因与可重试标志,枚举得靠伴生查表或额外 Map,数据与类型脱节。于是有了选择标准:成员只是常量用枚举,成员各带各的数据用密封类。安卓 UI 状态几乎总属于后者,这也是官方架构示例清一色密封接口的原因。

UI 状态机全景

下图画出一个典型列表页的密封状态机:五个状态、各自携带的数据、迁移路径,以及两处穷举消费点。

UI 状态机全景

为什么穷举 when 里不该写 else

穷举 when 有一条反直觉纪律:密封类型上尽量不写 else。原因看代码就知道:

// 反例:else 让穷举检查失效 fun track(state: PayState) = when (state) { PayState.Loading -> tracker.event("loading") is PayState.Success -> tracker.event("success") else -> tracker.event("other") // 新增 InstallmentPending 后 它被静默归入 other }

else 分支吞掉了穷举检查——新增子类不再标红,而是掉进 else 的黑洞。else 不是错,但它把密封类退化回了普通继承:全集信息被浪费。正确的写法是把每个子类列全;确实存在"其余全部"的业务语义(比如埋点只关心成功失败)时,至少用 is 分支把要区分的列出来,让 else 的覆盖面显式可见,并在注释里写明这是有意为之。编译器提供的是一张全网清单,else 是把清单烧掉

顺带一提 when 语句与表达式的差别:when 作为表达式(有返回值)时穷举是强制的;作为语句时,老版本 Kotlin 不强制穷举,1.7 起只要 subject 是密封类型且分支用了穷举形式,漏分支也会报错。写法上建议永远让 when 产生值(哪怕值是 Unit),把穷举检查焊死。

智能转换:强转的退休仪式

密封 when 里的 is 分支展示了智能转换的标准形态。它的适用面比密封类宽——任何"编译器能证明类型收窄"的场合都生效:

fun format(value: Any): String = when (value) { is Int -> "整数 ${value + 1}" // value 已是 Int is String -> "字符串 长度 ${value.length}" is List<*> -> "列表 共 ${value.size} 项" else -> "未知" }

Java 的对应物是 instanceof 加显式强转的二人转:

if (value instanceof Integer) { int i = ((Integer) value).intValue() + 1; // 判了类型 还要再转一次 return "整数 " + i; }

判完类型还要手动转换,是 Java 时代 ClassCastException 的固定产地之一——强转写错类型、泛型擦除后的裸转,都是运行时才爆。Kotlin 的智能转换把这步交给编译器:is 判断成立的那一刻,收窄就完成了。第 2 章说过它的失效条件(var 局部变量、自定义 getter 等编译器无法证明稳定的场合),失效时报错而不是静默退化——和空安全的智能转换同一套哲学。

与协程、状态流的接口

密封状态建模不是孤立技巧,它是第 8 章单向数据流的地基,这里先把接口预告清楚。状态持有者(第 8 章是 ViewModel 里的 StateFlow)发射密封类型的实例:

class PayViewModel : ViewModel() { private val _state = MutableStateFlow<PayState>(PayState.Loading) val state: StateFlow<PayState> = _state.asStateFlow() fun confirm(orderId: String) { viewModelScope.launch { _state.value = PayState.Confirming val result = runCatching { repo.confirm(orderId) } _state.value = result.fold( onSuccess = { PayState.Success(it) }, onFailure = { PayState.Failed(it.message ?: "未知错误", retryable = true) } ) } } }

UI 层 collect 这个流,对每个状态跑一次穷举渲染。状态的生产、转换、消费三段全部类型安全:生产端发射的必须是密封子类之一,转换端 copy 出新实例,消费端穷举全部可能。分页追加状态(LoadingMore)也是同一模式的变体——旧数据保留在新状态里,Data(items + newItems) 一行完成。到第 8 章你会看到这套组合的完整形态。

密封状态的测试策略

穷举检查保证"渲染函数覆盖了全部状态",但不保证"每个分支的行为正确"——后者靠测试。密封状态的测试有个讨巧的做法:枚举全部状态实例,逐个喂给渲染函数断言行为

class PayRenderTest { private val allStates: List<PayState> = listOf( PayState.Loading, PayState.Success(testReceipt()), PayState.Failed("网络错误", retryable = true), PayState.Failed("余额不足", retryable = false), PayState.Confirming, PayState.InstallmentPending(listOf(testInstallment())), ) @Test fun `每种状态都有对应的可见性组合`() { allStates.forEach { st -> render(st) // 喂给真实的渲染逻辑 assertVisibilityConsistent(st) // 断言控件可见性组合符合预期 } } }

这份测试的价值在演化中兑现:新增状态时,编译器标红全部穷举 when,补完分支后把这个状态加进 allStates 列表,渲染行为的回归自动被覆盖。比手工为每个状态写独立测试用例省力,且不会漏。状态对象工厂(testReceipt 这类辅助函数)放在测试包里共享,是测试密封层次的配套习惯。

常见问题

问:密封类的子类必须和密封类在同一个文件里,跨模块扩展怎么办?
"同编译单元"是密封的定义也是它的边界:正因为全集封闭,穷举检查才成立。跨模块要加子类时只有两条正路:把密封层次挪到子类所在的模块(调整架构归属,常见于状态类型下沉到公共 model 模块);或改用普通接口放弃穷举检查(用文档与评审代替编译器)。不要找"绕过同文件限制"的黑科技——那个限制正是特性本身。实际工程里,状态类型归属哪个模块,通常在第一次跨模块需求出现时才真正想清楚,前期把它放在离消费者最近的地方即可。

问:when 穷举了密封类型,但需求要求"未知状态显示兜底页",else 不写不就崩了?
先分清两种"未知"。编译期未知(未来版本的新子类)由穷举检查管理——升级依赖后编译失败,补分支就是了,这正是想要的保护。运行期"未知"(服务端下发一个前端不认识的状态码)发生在数据进密封层次之前:解析层把不认识的码映射成一个显式的 PayState.Unknown(val raw: String) 子类,渲染时给它一个真实分支(兜底页加原始码上报)。也就是说,运行期未知也是一种已知的状态,让它堂堂正正进密封层次,而不是用 else 把整个穷举体系降级。这个手法叫"封闭未知",是边界系统对接开放世界的通用桥。

问:状态类越加越多,一个密封接口下面十几个子类正常吗?
子类数量本身不是问题(穷举成本是线性的,编译器替你数),要看子类是否正交。如果发现子类之间出现组合爆炸的苗头(Loading 加 Error、Loading 加 Empty、Error 加 Retryable……),说明把多个独立维度压进了一个密封层次——应该拆成多个正交的状态流(页面状态一个流、错误一个流、加载进度一个流),或者用组合字段而不是子类表达次要维度。一个可用的阈值感:子类超过八个时审视维度,超过十二个几乎必然该拆。

本节要点回顾

  • 密封修饰符:子类全集限制在编译单元内,编译器因此能做穷举检查;新代码用密封接口;
  • 无 else 穷举 when:漏分支编译失败,新增子类全网标红——类型演化无法绕过消费者;
  • 智能转换:is 分支内自动收窄类型,强转与 ClassCastException 退休;
  • 枚举与密封类的分界:常量集合用枚举,成员各带数据用密封类,UI 状态几乎总是后者;
  • else 是穷举检查的烧毁开关:非写不可时用显式 is 分支缩小其覆盖面并注释意图;
  • when 永远写成表达式:把穷举检查焊死在编译期;
  • 与 StateFlow 组合:密封状态是单向数据流的地基,第 8 章展开完整形态。

数据建模与穷举防线立好了。下一章进入并发视角:val 与只读集合如何把"数据被谁改过"的搜索范围缩小到可以用 grep 完成的程度。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U