卷 II · CH 05 · 深度 05/18

让非法状态根本写不出来

三个 Boolean 能表示八种组合;如果业务只有三种状态,另外五种全是未来的 bug。

SEALEDVALUE CLASSGENERICS
▷ 先答一下

消息发送状态用哪种模型最能阻止矛盾组合?

  1. isLoadingisSenthasError 三个 Boolean
  2. 一个整数,0、1、2 各自代表状态
  3. 一个 sealed 类型,每个分支携带自己才需要的数据
  4. 一个字符串,由 UI 自己约定

和类型,不是旗子集合

业务说「待发、已发、失败三者之一」,这在类型论里是和类型:值只能从几个分支中选一个。sealed interface 正好表达这件事。每个分支还能带自己独有的数据:失败才有原因,已发才有服务端序号。

sealed interface Delivery {
    data class Pending(val clientId: ClientMessageId) : Delivery
    data class Sent(val serverId: ServerMessageId, val sequence: Long) : Delivery
    data class Failed(val clientId: ClientMessageId, val reason: Reason) : Delivery
}

与三面旗子相比,类型不只让代码更漂亮,还把无意义组合从运行时删掉。when 对 sealed 层级是穷尽的:将来加 Read,遗漏的渲染点会在编译期暴露。

可以带走的判断:如果几个字段之间存在「只有某些组合合法」的约束,先考虑把组合变成类型分支。

给相同底层值不同身份

conversationIdmessageId 可能都是 String,但传反以后编译器看不出来。@JvmInline value class 给它们不同的静态类型,在许多调用边界又能避免额外对象分配。

@JvmInline value class ConversationId(val value: String)
@JvmInline value class ClientMessageId(val value: String)
@JvmInline value class ServerMessageId(val value: String)

fun observeMessages(id: ConversationId): Flow<List<Message>> = TODO()

值类不是安全保险箱。它仍需在构造时校验格式;遇到泛型、可空、接口或 Java 边界还可能装箱。面试里把它说成「避免把同一种 primitive 语义混用」比说「零成本抽象」更准确。

泛型里的 in 与 out 是数据方向

只生产 T 的接口可以标 out T,让 Producer<Nurse> 能当 Producer<Clinician> 使用;只消费 T 的接口可以标 in T。口诀 PECS 的 Kotlin 版本仍是「生产者 out,消费者 in」。可变集合既读又写,因此通常不协变。

Android 面试不常要求证明型变,但会借接口设计来问:为什么 List<Dog> 可以安全当作只读的 List<Animal>,而可写列表不行?因为后者若允许,就能把 Cat 塞进真正的 Dog 列表。

⌨ 自己跑一遍

把三面旗子改成 sealed 状态。结果里没有「既成功又失败」的可能:

c-1:排队中
s-9:服务端序号 42
c-2:失败 offline
sealed interface Delivery {
    data class Pending(val clientId: String) : Delivery
    data class Sent(val serverId: String, val sequence: Long) : Delivery
    data class Failed(val clientId: String, val reason: String) : Delivery
}

fun label(value: Delivery): String = when (value) {
    is Delivery.Pending -> "${value.clientId}:排队中"
    is Delivery.Sent -> "${value.serverId}:服务端序号 ${value.sequence}"
    is Delivery.Failed -> "${value.clientId}:失败 ${value.reason}"
}

fun main() {
    listOf(
        Delivery.Pending("c-1"),
        Delivery.Sent("s-9", 42),
        Delivery.Failed("c-2", "offline")
    ).forEach { println(label(it)) }
}

Kotlin Playground 运行。再新增 Read 分支但不改 label,看编译器在哪些地方替你列出欠账。

▸ 在现实里

医疗产品里,「患者已同意」不应只是一个随处可改的 Boolean。可以把待上传附件分成 AwaitingConsentApproved(consentRecordId),让上传函数只接受后者。类型不能替代服务端授权,但能减少客户端误走路径。

✗ 这个直觉是错的

「sealed class 就是高级 enum。」enum 的每个常量结构相同;sealed 分支可以是对象,也可以带完全不同的数据与行为。

用 enum 表示同构的有限标签,用 sealed 层级表示异构的有限状态。先让类型匹配业务语法,再考虑方便。

◇ 面试收口

答案是第 3 项。整数与字符串把约束留给约定,三面旗子制造了五种无意义组合;sealed 类型让分支互斥,并让每个分支只携带合法字段。新增状态时,穷尽 when 还能生成编译期待办清单。

这一章的一句话

类型设计的目标不是描述所有数据,而是让错误组合没有构造入口。

下一章处理一个更顽固的误会:给函数加上 suspend 后,它依然可能在主线程把 UI 卡死;这个关键字只改变「怎么回来」,不决定「去哪儿做」。