2. 密封类初探:Kotlin中的受限类型层次结构
说到密封类,我得先坦白一件事。我刚开始接触 Kotlin 时,看到 sealed 这个关键字,第一反应是:「这不就是个加强版的枚举吗?」
后来在项目里踩了坑才明白——密封类解决的是「类型安全」的问题,而枚举解决的是「值有限」的问题。两者压根不是一个维度的事。
2.1 什么是密封类?
密封类,说白了就是限制子类数量的类。你用 sealed 修饰一个类,编译器就知道:「哦,这个类的所有子类,我都得知道。」
为什么需要这个?
你想想看,在 Android 开发里,我们经常要处理各种状态:加载中、成功、失败、空数据……如果用普通类,when 表达式必须加 else 分支。加了 else,编译器就不帮你检查遗漏了。哪天你新增了一个状态,忘了改 when,运行时直接崩。
密封类就是来治这个病的。
2.2 基本语法
先看一个最简单的例子。我在项目里处理网络请求结果时,经常这么写:
sealed class NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>()
data class Error(val message: String, val code: Int? = null) : NetworkResult<Nothing>()
object Loading : NetworkResult<Nothing>()
}
注意几个关键点:
- sealed 关键字放在 class 前面
- 子类可以是 data class、object、普通 class
- 泛型完全支持,但要注意协变/逆变
- 子类必须在同一文件(1.5 之前)或同一模块(1.5 之后)
2.3 密封类 vs 枚举
很多人刚学时容易混淆。我直接给你画个对比表:
| 特性 | 枚举 | 密封类 |
|---|---|---|
| 每个实例 | 单例(每个枚举常量只有一个实例) | 可以有多个实例(data class 每次创建新对象) |
| 携带数据 | 只能通过构造函数参数(所有实例结构相同) | 每个子类可以有不同的属性和方法 |
| 层次结构 | 扁平(所有常量在同一层级) | 可以有深度继承(子类还可以有子类) |
| when 表达式 | 需要 else 分支(除非覆盖所有常量) | 不需要 else 分支(编译器检查完整性) |
| 适用场景 | 状态有限且不需要额外数据 | 状态有限但需要携带不同类型的数据 |
举个例子你就明白了。如果只是表示「成功/失败」,枚举就够了。但如果成功时要带数据,失败时要带错误码和消息——那就得上密封类。
2.4 密封类的核心优势:穷尽性检查
这是密封类最让我舒服的地方。来看一段代码:
fun handleResult(result: NetworkResult<String>) {
when (result) {
is NetworkResult.Success -> showData(result.data)
is NetworkResult.Error -> showError(result.message)
is NetworkResult.Loading -> showLoading()
// 不需要 else 分支!
}
}
如果你新增了一个子类,比如 NetworkResult.Empty,编译器会直接报错:when' expression must be exhaustive。你不得不去处理这个新状态。
when 里处理 Loading 状态,结果用户点击按钮后界面卡死——因为加载动画没显示,请求也没发出去。用密封类 + 穷尽性检查,这种低级错误根本不会发生。
2.5 密封类的进阶用法
密封类不只是用来做状态管理的。我在项目里还用它来构建 DSL 和事件总线。
2.5.1 嵌套密封类
有时候状态需要分层。比如 UI 状态可以这样组织:
sealed class UiState {
object Idle : UiState()
object Loading : UiState()
sealed class Error : UiState() {
data class Network(val message: String) : Error()
data class Validation(val field: String, val message: String) : Error()
object Unknown : Error()
}
data class Success<T>(val data: T) : UiState()
}
这样处理错误时,可以精确区分是网络错误还是校验错误:
when (state) {
is UiState.Idle -> {}
is UiState.Loading -> {}
is UiState.Error.Network -> showNetworkError(state.message)
is UiState.Error.Validation -> highlightField(state.field, state.message)
is UiState.Error.Unknown -> showGenericError()
is UiState.Success -> showContent(state.data)
}
2.5.2 密封接口
Kotlin 1.5 之后,sealed 也可以修饰接口。这在需要多继承时很有用:
sealed interface Screen {
data class Home(val title: String) : Screen
data class Profile(val userId: String) : Screen
object Settings : Screen
}
sealed interface Dialog {
data class Confirm(val message: String) : Dialog
object Loading : Dialog
}
嗯,这里要注意:密封接口的子类可以来自不同的父接口,灵活性更高。但代价是穷尽性检查只在 when 覆盖所有分支时生效。
2.6 知识结构图
下面这张图帮你理清密封类的核心脉络:
2.7 实战建议
最后,分享几个我在项目中总结的经验:
- 优先用密封类,而不是枚举——除非你确定状态永远不会携带额外数据。我见过太多项目后期把枚举改成密封类,改得痛不欲生。
- 子类命名要清晰。别用
State1、State2这种名字。看一眼就知道是什么状态,比如Loading、Success、Error。 - 善用 object 子类。如果某个状态不需要携带数据,用 object 而不是 data class。省内存,语义也更清晰。
- 不要滥用嵌套。嵌套超过两层,代码可读性会急剧下降。如果状态太复杂,考虑拆成多个密封类。
when 就能处理所有 UI 变化,再也不用写一堆 if-else 了。
密封类这东西,用好了是真香。它让编译器帮你干活,把运行时错误变成编译时错误。说白了,就是让 bug 在写代码时就暴露出来,而不是等用户发现。
下一节,我们会深入密封类的「兄弟」——内联类。它俩配合起来,能解决 Android 状态管理里很多头疼的问题。