密封类的继承:子类定义、位置限制与类型多样性
聊完了密封类的基本概念,咱们得深入看看它的核心机制——继承。说实话,密封类的继承规则,是它区别于普通抽象类或接口的关键所在。我刚开始用 Kotlin 时,也在这上面踩过坑,今天就把这些经验掰开揉碎讲给你听。
子类定义:到底该怎么写?
密封类的子类定义,说白了就两种方式:嵌套在密封类内部,或者定义在同一个文件里。嗯,这里有个细节很多人会忽略——子类必须直接继承密封类,不能隔代继承后再去实现。
看个例子你就明白了:
sealed class Result {
// 方式一:嵌套定义
class Success(val data: String) : Result()
class Error(val message: String) : Result()
}
// 方式二:同文件顶层定义
class Loading : Result() // 没问题,在同一个文件里
// 错误示范:不同文件定义
// class Timeout : Result() // 编译报错!密封类不允许跨文件继承
我个人习惯把子类直接嵌套在密封类内部。为什么?因为这样代码结构更清晰,别人一看就知道「哦,这些是 Result 的所有可能状态」。我在项目中遇到过好几次,团队新人把子类散落在不同文件里,结果编译直接报错,排查半天才发现是密封类的限制。
子类的位置限制:为什么这么严格?
你可能会问:「Kotlin 为什么非要限制子类的位置?」
其实原因很简单——为了穷尽性检查。你想想看,编译器只有在知道所有子类的情况下,才能帮你做 when 表达式的穷尽性校验。如果子类可以分散到任意文件,编译器就得扫描整个项目,性能开销大不说,还容易漏掉。
核心规则:密封类的直接子类,必须和密封类定义在同一个文件里。间接子类(子类的子类)则没有这个限制,可以放在任何地方。
举个例子:
sealed class NetworkState
class Connected(val ip: String) : NetworkState() // 直接子类,必须在同文件
class Disconnected : NetworkState() // 直接子类,必须在同文件
// 间接子类,可以放在其他文件
class WiFiConnected(ip: String, val ssid: String) : Connected(ip) // 没问题
我曾经犯过一个错误:把密封类的子类定义在另一个包的文件里,编译报错时我还以为是 Kotlin 版本问题。排查了半小时才发现是位置不对。所以这里给你个避坑指南——写密封类时,先把所有子类都放在同一个文件里,等结构稳定了再考虑要不要拆分子类的子类。
子类的类型多样性:不只是 class
密封类的子类,可不只是普通的 class。它支持多种类型,这也是密封类灵活的地方。我整理了一下,主要有这几种:
| 子类类型 | 说明 | 示例 |
|---|---|---|
| 普通 class | 最常用的方式,可以包含属性和方法 | class Success(val data: String) : Result() |
| data class | 自动生成 equals/hashCode/toString,适合做数据载体 | data class Error(val code: Int) : Result() |
| object | 单例,适合没有状态变化的子类 | object Loading : Result() |
| 嵌套 sealed class | 子类本身也可以是密封类,形成层级结构 | sealed class AuthResult : Result() |
这里我特别想说说 object 的使用场景。你想想看,像 Loading、Idle 这种状态,它们不需要携带任何数据,用 object 再合适不过了。我在项目中就见过有人用 class 定义 Loading,每次创建新实例,既浪费内存又没必要。
sealed class UiState {
object Idle : UiState() // 单例,无数据
object Loading : UiState() // 单例,无数据
data class Success(val data: List<Item>) : UiState() // 需要数据
data class Error(val message: String) : UiState() // 需要数据
}
小技巧:如果你的子类没有任何属性,优先用 object。这样在 when 表达式里可以直接用 UiState.Loading 匹配,不需要加括号,代码更简洁。
实际项目中的组合用法
说了这么多理论,咱们看个真实场景。我在做一个网络请求框架时,就用了密封类来管理请求状态:
sealed class RequestState<out T> {
object Idle : RequestState<Nothing>()
object Loading : RequestState<Nothing>()
data class Success<T>(val data: T) : RequestState<T>()
data class Error(val exception: Throwable) : RequestState<Nothing>()
}
// 使用时的穷尽性检查
fun handleState(state: RequestState<String>) {
when (state) {
is RequestState.Idle -> println("等待中")
is RequestState.Loading -> println("加载中")
is RequestState.Success -> println("数据: ${state.data}")
is RequestState.Error -> println("错误: ${state.exception.message}")
}
// 不需要 else 分支,编译器保证所有情况都已覆盖
}
你看,这里用了泛型、data class、object 的组合。为什么这么设计?因为 Idle 和 Loading 不需要携带数据,用 object 省内存;Success 需要携带不同类型的数据,用 data class 自动生成 equals 方法,方便测试时比较;Error 携带异常信息,也是 data class。
注意:密封类的子类如果用了泛型,记得加上 out 关键字声明协变,否则在某些场景下会报类型不匹配的错误。上面例子里的 RequestState<out T> 就是这么来的。
最后总结一下密封类继承的核心要点:
- 子类必须和密封类在同一个文件——这是为了穷尽性检查
- 子类可以是 class、data class、object 甚至嵌套密封类——根据需求选择最合适的类型
- 间接子类没有位置限制——子类的子类可以放在任何地方
- 优先用 object 表示无状态子类——代码更简洁,性能更好
嗯,这些规则看起来有点多,但用熟了就会发现,它们其实是在帮你写出更安全、更可维护的代码。下次写状态管理时,不妨试试密封类,你会爱上这种「编译器帮你兜底」的感觉。