第068篇 sealed class:状态建模的利器

简介: `sealed class` 是 Kotlin 状态建模的核心工具,本质是“用类型系统表达封闭状态机”。它强制子类同文件/模块定义,使编译器可穷举所有分支,配合 `when` 实现**无 `else` 的完备匹配与类型安全**,彻底杜绝非法状态(如 `loading && data != null`),远超枚举与多字段布尔组合的表达力。

sealed class 在 Kotlin 里的地位,相当于"用类型系统表达状态机"。它的价值不在语法,而在一个很实际的痛点:当一个值有多种可能形态时,怎么保证"处理了这几种形态"这件事不会漏。 面试里问 sealed class,问的往往不是"是什么",而是"你用它解决过什么建模问题"和"它和枚举、when 表达式的关系"。

先把结论放在前面:sealed class(1.5 起可写 sealed interface) restricts 子类必须与父类同一文件内声明(1.5 后放宽到同一包/模块的编译单元),这让子类型集合是封闭且可穷举的。于是 when 表达式在 Kotlin 里被当作表达式处理——编译器知道所有分支,漏一个就编译报错,同时 when 有返回值。 核心用途是显式建模互斥状态,替代"多个可空字段 + 若干布尔标记"这种做法。

机制拆解

先说实现机制。sealed 类在字节码层面是 abstract 类加一个 PermittedSubclasses 属性(JVM 的 sealed 类特性),子类编译后带上 sealed 标记。 运行时子类集合是可枚举的,所以 Kotlin 编译器才能在 when 里做穷举性检查——如果一个 when 覆盖了所有已知子类且没有 else 分支,它被视为完整表达式,有返回值;漏掉任何一个子类,编译直接报错。 这就是它与枚举的本质差别:枚举的成员集合在编译期固定,sealed class 的成员集合在编译期可检查、在运行期是真实的继承体系。

再说它解决的建模问题。典型的"半渲染状态"问题:一个页面要显示加载中/有数据/出错/空数据,如果用三个可空字段表示(loading: Boolean、data: List<T>?、error: String?),就会出现 loading = true 且 data != null 这种非法组合,UI 层只能靠散落的 if 去防御,字段一多就难免出错。 换成 sealed class 后,状态是一个值,四种情况互斥,编译器保证处理完整——这就是"把非法状态变成不可表示"。

Android 语境下的三个高频落点:① 状态建模(LoadState、UiState);② 多态事件的类型安全表达(Channel/Flow 传的事件、onActivityResult 的多种结果);③ 替代"结果 + 异常"二元返回(Result<T> 就是 sealed class 的标准实现,Success/Failure)。

这些坑的正确绕法

最常见的坑是在 when 密封类分支里加了 else,新增子类时编译器不再提示遗漏分支。 表现是某次新增一个 Empty 状态,编译照过,但没有任何分支处理它,运行期走了某个旧分支的默认逻辑,界面状态错乱。根因是 else 把"编译器帮你检查完整性"这个保障关掉了。 修法很直接:不要写 else,让编译器逼你补分支;万一确实需要兜底(比如状态来自 Java 的平台类型、可能真有未知值),用 is 分支逐个列出已知类型,只在最后对 null 做处理,而不是对任意类型做兜底。

其次是把 sealed class 用成了"枚举的替代品",丢了它真正的价值。 表现是每个子类里只有一个常量、没有任何差异化行为,写起来比枚举还长。判据是:子类之间是否需要携带不同的数据或行为。需要,就用 sealed class;不需要(只是一组常量),就用 enum class 或 object 组合。 强行用 sealed class 会得到"一堆空壳类 + 长 when",反而不如枚举。

还有一个更隐蔽的坑:sealed 的"同文件"限制在重构时容易踩。 表现是把状态类挪到另一个文件(拆分类)后编译失败,或者为了绕过限制把 sealed 去掉,于是 when 的穷举检查失效,回到第一个坑。 对策是把一个状态的所有子类放在同一个文件里(这是 Kotlin 社区的通行约定,读代码时也更容易看全),实在要拆分就用 1.5 后的 sealed interface 并放在同一模块内。

代码里见真章

看一段能直接跑的代码,把上面的机制落到具体写法上:

// 1) 反例:多个可空字段能表示出非法组合
data class BadState(
    val loading: Boolean = false,
    val data: List<Item>? = null,
    val error: String? = null
)
// BadState(loading = true, data = listOf(item))  // 编译通过,逻辑矛盾

// 2) 正解:sealed interface 建模互斥状态,非法状态不可表示
sealed interface UiState<out T> {
   
    data object Loading : UiState<Nothing>                 // 无数据
    data class Content<T>(val items: List<T>) : UiState<T> // 有数据
    data class Failed(val msg: String) : UiState<Nothing>  // 失败
}
data object Empty : UiState<Nothing>                       // 空数据(与 Loading 区分)

// 3) when 穷举检查:不写 else,漏分支编译报错
fun render(state: UiState<Item>) {
   
    when (state) {
                          // 作为表达式,有返回值
        UiState.Loading -> showSkeleton()
        is UiState.Content -> showList(state.items)
        is UiState.Failed -> showError(state.msg)
        Empty -> showBlank()
        // 新增一个状态而这里不补分支 → 编译报错,这是 sealed 的核心价值
    }
}

// 4) sealed class 也能带基类逻辑
sealed class Result2<out T> {
   
    data class Ok<T>(val value: T) : Result2<T>()
    data class Err(val code: Int, val msg: String) : Result2<Nothing>()
    // 基类可以定义统一行为
    val isOk: Boolean get() = this is Ok
}

// 5) 替代"结果 + 异常":让失败成为类型的一部分
fun load(): UiState<Item> = try {
   
    UiState.Content(repo.load())
} catch (e: IOException) {
   
    UiState.Failed(e.message ?: "网络异常")   // 失败被显式建模,不靠抛异常传控制流
}

// 6) 事件建模:多态事件也适合 sealed
sealed interface Event {
   
    data class Click(val id: String) : Event
    data class Scroll(val offset: Int) : Event
}

这段代码值得盯三处:第一处,BadState 允许构造出矛盾组合,而 UiState 从类型上排除了这种可能——这是本篇的核心论点;第二处,when 不写 else,让编译器强制补全分支,注释里明确写了"这是 sealed 的核心价值";第三处,Empty 独立成一个 data object,说明"加载中"与"加载完成但没数据"是两种不同状态,合并不了——这正是 sealed 建模比布尔字段强的地方。

这题在面试里怎么问、怎么答

"sealed class 和 enum class 有什么区别?"四点:①枚举成员集合编译期固定、sealed 允许每个子类携带不同数据;②枚举是单例、sealed 子类可以有多个实例;③sealed 支持继承层次(子类可再抽象),枚举不能;④sealed 的 when 穷举性由编译器检查(前提不写 else),枚举的 when 在有 else 时也不检查完整性。 选型判据:只是常量用 enum,要带数据用 sealed。

"sealed 为什么能穷举?"答:编译器知道所有直接子类(受"同文件/同模块"限制约束),所以能校验 when 的覆盖情况。1.5 之前限制在同文件,1.5 之后 sealed interface 可以有同包/同编译单元的实现。

"用 sealed 就不用 try-catch 了?"答:不能完全替代。sealed 解决的是"函数应当如何表达失败"这个接口设计问题——把失败写进返回类型,调用方必须处理。异常仍然存在(比如编程错误、底层库抛出),但应当用 sealed 把预期的、可预期的失败建模出来,把不该发生的留给异常。 Android 里 Coroutines 的 Result、Room 的返回值、Repository 的返回值都是这个思路。

"使用中遇到过什么问题?"案例一:状态用三个可空字段表示,加载逻辑散落各处,出现"转圈的同时显示了旧数据";定位到非法状态可表示;修复为改 sealed interface 并让 when 强制分支。案例二:when 写了 else -> {} 导致新增状态无处理,页面空白;定位到漏分支被 else 掩盖;修复为去掉 else,补齐每个分支。

再补一个工程上值得讲清的一点:sealed class 让"状态机的状态"与"状态转移的守卫条件"可以分别表达。 典型写法是:状态用 sealed 表达,转移用扩展函数表达(fun UiState<Item>.retry(): UiState<Item> = UiState.Loading)。 这样状态的取值集合与允许的转移路径都集中在一处,非法转移在 review 时就能看出来。Android 上这与单向数据流的实践一致——状态是数据,事件是意图,reducer 是纯函数,逐个可测。这类"把状态转移写成纯函数"的设计,是 sealed 能带来的最大工程收益,也是面试里最值得展开讲的一点。

给正在准备面试的你

把这题画成一张"从多字段到密封类"的迁移图:上半部分画 BadState 的三个可空字段,并在中间用红叉标出两条连线——"loading=true 且 data 非空"、"error 非空且 data 非空",表示这两种组合可被构造出来但逻辑非法。 下半部分画 UiState 的四个分支(Loading / Content / Failed / Empty),四个框互斥、用一条竖线分隔并标"编译器保证互斥"。两侧各配一条注释:"多字段:需要散落的 if 去防御" vs "密封类:非法状态不可表示"。图右边再画一个 when 方框,里面四个分支不写 else,旁边标一句"漏分支 = 编译错误"。 这张图能完整回答"你为什么用 sealed"。

再补工程案例与踩坑——应用落点是把项目里用多个可空/布尔字段表示状态的类逐个改造成 sealed interface,删掉所有 when 里的 else 分支,让编译器把遗漏点逐个暴露出来再补;状态转移统一写成扩展函数或 reducer 便于单测。

复习时别孤立刷题:data class 上——sealed 的子类通常就是 data class 或 data object,两者是"状态载体"与"状态集合"的关系。

划两句重点:sealed 的子类型封闭可穷举,when 不写 else 时编译器强制补全;多个可空字段能构造出非法组合,sealed 从类型上排除;只是一组常量用 enum,要带数据用 sealed;状态转移写成纯函数最受益。

下一篇聊 object 与 companion object:Kotlin 里的单例——沿着今天这条主线继续往前走。


如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。

「Android软件开发面试·从入门到精通」连载系列

上一篇:data-class:一行顶-Java-一百行

下一篇预告:object-与-companion-object:Kotlin-里的单例

有任何问题欢迎在评论区留言交流。

相关文章
|
23小时前
|
缓存 Java 数据库
第078篇 Sequence 与惰性求值:大数据量集合优化
`Sequence` 的核心是**惰性求值**(操作延迟至终端才执行)与**冷流语义**(每次遍历都重新计算,不缓存)。它省内存(无中间集合),但不支持多次遍历、随机访问;适用于大集合多级过滤或无限序列截断(如 `take`)。慎用于副作用操作、未截断的无限流及需重复消费场景——应物化(`toList()`)或内联使用。
30 0
|
1天前
|
缓存 安全 Java
第031篇 ConcurrentHashMap:从分段锁到 CAS 加 synchronized
ConcurrentHashMap(JDK8)通过CAS初始化空桶、桶头节点加synchronized锁实现细粒度并发写,get无锁依赖volatile可见性;size采用baseCount+CounterCell分片计数;禁止null键值,复合操作须用compute/merge等原子方法——兼顾高性能与线程安全。
18 0
|
1天前
|
缓存 Java API
第037篇 动态代理:AOP 与框架的基石
动态代理本质是运行期生成代理类,统一转发调用至InvocationHandler。JDK代理限于接口,CGLIB通过子类代理非final类。核心难点在于:代理对象与目标实例不同、方法体动态生成、this自调用绕过代理——这三点正是区分“读文档”与“写框架”的关键试金石。
18 0
|
1天前
|
安全 Java 编译器
第024篇 泛型基础:类型擦除到底擦了什么
本文深入剖析Java泛型本质:以“问题—擦除—运行时残留—错误表现”四步公式讲清原理;透彻解析类型擦除机制、桥接方法及常见坑(raw type、new T[]、instanceof泛型);结合可运行代码与Android实战场景,助你面试答出“为什么”,而非仅“怎么写”。
22 0
|
1天前
|
SQL 缓存 安全
第058篇 单例模式六种写法:线程安全与懒加载的平衡
单例模式是面试高频考点,六种写法各具特点:饿汉式线程安全但非懒加载;懒汉式需同步;DCL需`volatile`防重排;静态内部类最推荐(JVM类初始化锁保障懒加载与线程安全);枚举天然防反射与序列化。Android中优先用静态内部类,持Context时务必用`getApplicationContext()`,避免内存泄漏。
25 0
|
1天前
|
SQL 安全 Java
第073篇 字符串模板与原生字符串:多行文本的正确姿势
Kotlin字符串模板核心三点:①编译后转为`StringBuilder.append`链(全常量时优化为字面量);②原生字符串`&quot;&quot;&quot;...&quot;&quot;&quot;`保留缩进,必须用`trimIndent()`或`trimMargin()`清理;③Android中禁在循环内用模板拼接,否则触发O(n²)性能坑。安全上,模板不用于SQL/URL等需解析的场景。
23 0
|
1天前
|
Java 编译器 API
第053篇 Lambda 与函数式接口:Android 开发的日常语法
本文深入剖析Java Lambda与函数式接口的本质:从匿名内部类演进而来,依托`invokedynamic`与`LambdaMetafactory`实现运行时链接;详解四大核心接口(`Function`/`Consumer`/`Supplier`/`Predicate`)、捕获语义、泛型擦除及Android兼容坑点,强调“行为参数化”思想而非语法糖。
24 0
|
1天前
|
缓存 Java Android开发
第009篇 类与对象的内存布局:一个对象到底占多少字节
本文深度解析Java对象在堆中的内存布局:对象头(含标记字与类型指针)、实例数据(父类字段优先、按类型大小排列)、对齐填充(补至8字节整数倍),厘清“引用≠对象”本质,直击面试高频考点与工程内存优化痛点。
17 0
|
1天前
|
存储 Java 编译器
第002篇 运算符与表达式:整除、短路、位运算与优先级
Android面试高频考点:运算符与表达式。聚焦整除截断、溢出规避、短路求值、位运算(MeasureSpec/Intent flags)、浮点比较、优先级陷阱等真实坑点,结合源码案例讲透原理与避坑实践,助你夯实基础、展现真功夫。
19 0
|
1天前
|
Java 编译器 API
第008篇 面向对象三大特性:封装、继承、多态怎么讲才透
本文深度解析面向对象三大特性(封装、继承、多态)的面试核心:不止于定义,重在讲清“为何设计、有何代价、如何用对”。结合Java机制、代码实操与典型误区,助你构建扎实认知网,从容应对层层追问。
16 0

热门文章

最新文章