2. 密封类初探:Kotlin中的受限类型层次结构

说到密封类,我得先坦白一件事。我刚开始接触 Kotlin 时,看到 sealed 这个关键字,第一反应是:「这不就是个加强版的枚举吗?」

后来在项目里踩了坑才明白——密封类解决的是「类型安全」的问题,而枚举解决的是「值有限」的问题。两者压根不是一个维度的事。

2.1 什么是密封类?

密封类,说白了就是限制子类数量的类。你用 sealed 修饰一个类,编译器就知道:「哦,这个类的所有子类,我都得知道。」

为什么需要这个?

你想想看,在 Android 开发里,我们经常要处理各种状态:加载中、成功、失败、空数据……如果用普通类,when 表达式必须加 else 分支。加了 else,编译器就不帮你检查遗漏了。哪天你新增了一个状态,忘了改 when,运行时直接崩。

密封类就是来治这个病的。

核心定义:密封类是一种受限的类层次结构。它的所有子类必须在同一个文件(Kotlin 1.1+)或同一个模块(Kotlin 1.5+)中定义。

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 之后)
我的习惯:如果子类不需要携带数据,用 object;如果需要携带数据,用 data class。别混着用,容易乱。

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 知识结构图

下面这张图帮你理清密封类的核心脉络:

密封类 sealed class 定义方式 核心特性 vs 枚举 同一文件/模块 子类类型不限 穷尽性检查 类型安全 可携带数据 枚举:单例 密封类:多实例 典型应用场景 UI 状态管理 网络请求结果 事件/导航

2.7 实战建议

最后,分享几个我在项目中总结的经验:

  1. 优先用密封类,而不是枚举——除非你确定状态永远不会携带额外数据。我见过太多项目后期把枚举改成密封类,改得痛不欲生。
  2. 子类命名要清晰。别用 State1State2 这种名字。看一眼就知道是什么状态,比如 LoadingSuccessError
  3. 善用 object 子类。如果某个状态不需要携带数据,用 object 而不是 data class。省内存,语义也更清晰。
  4. 不要滥用嵌套。嵌套超过两层,代码可读性会急剧下降。如果状态太复杂,考虑拆成多个密封类。
一个小技巧:在 Android 的 ViewModel 里,我习惯把 UI 状态定义成一个密封类。这样 Activity/Fragment 里只需要一个 when 就能处理所有 UI 变化,再也不用写一堆 if-else 了。

密封类这东西,用好了是真香。它让编译器帮你干活,把运行时错误变成编译时错误。说白了,就是让 bug 在写代码时就暴露出来,而不是等用户发现。

下一节,我们会深入密封类的「兄弟」——内联类。它俩配合起来,能解决 Android 状态管理里很多头疼的问题。


公众号:蓝海资料掘金营,微信deep3321