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-里的单例
有任何问题欢迎在评论区留言交流。