Kotlin 中的 Sealed class 与 when

1. 总览

当一个值只能属于几个固定的情况(比如支付结果是成功、失败或待处理),你希望编译器清楚地知道这个列表。Kotlin 提供 sealed class 和 sealed interface 来锁定继承树。when 表达式可以检查你是否处理了所有分支。

读完这篇文章后,你将了解如何声明 sealed hierarchy 和编写穷尽的 when。你也将学会使用 guard condition、为这个逻辑编写测试,以及认识何时不应该使用 sealed class。

2. 开放继承树的问题

试试用普通的 abstract class 来建模支付结果:

abstract class PaymentResult

class Success(val transactionId: String) : PaymentResult()
class Failed(val reason: String) : PaymentResult()

fun describe(result: PaymentResult): String = when (result) {
    is Success -> "OK: ${result.transactionId}"
    is Failed -> "错误: ${result.reason}"
    else -> "未知"
}

else 分支是强制的。原因是任何人、在任何 module 中,都可以创建 PaymentResult 的新子类。编译器无法知道子类列表是否完整。

结果是当某人添加 class Refunded : PaymentResult() 时,代码仍然能正常编译。Refunded 值会悄悄地落入 "未知" 分支。这类错误通常只在生产环境才会被发现。

enum class 在某种程度上解决了这个问题,因为枚举常量的列表是固定的。但是每个枚举常量是一个唯一的实例。你不能为每个不同的成功支付附加不同的 transactionId。

3. 声明 sealed class

Sealed class 结合了两种方法的优点。子类列表像枚举一样固定,而每个子类仍然可以像普通类一样携带自己的数据。

3.1. 依赖

本文示例使用 Kotlin 2.2 和 JVM 21。测试部分使用 JUnit 5 和 AssertJ:

<dependencies>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-stdlib</artifactId>
        <version>2.2.20</version>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.11.4</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.27.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

你还需要配置 kotlin-maven-plugin 的相同版本来编译 Kotlin 源代码。

3.2. 一个简单的 sealed hierarchy

sealed class PaymentResult {
    data class Success(val transactionId: String, val amount: Long) : PaymentResult()
    data class Failed(val reason: String, val retryable: Boolean) : PaymentResult()
    data object Pending : PaymentResult()
}

上面代码中的每一部分都有其含义:

  • sealed class PaymentResult 是一个隐含的 abstract 类。你不能直接调用 PaymentResult()。它的 constructor 默认是 protected。
  • Success 和 Failed 是 data class。你会自动获得 equals、hashCode、toString 和 copy,这在编写测试时非常方便。
  • Pending 不携带数据,所以使用 data object。它是一个单例,其 toString() 输出 Pending 而不是 PaymentResult$Pending@1b6d3586 这样的形式。

最重要的规则是:所有直接子类必须与 sealed class 在同一个 package 和同一个 module 中。你可以将它们分散到同一个 package 中的多个文件。但是使用你的库的另一个 module 将无法添加新的子类。正是这个规则使得编译器能够确切地知道子类列表。

4. 穷尽的 when 表达式

4.1. 移除 else 分支

当 when 检查的对象类型是 sealed 时,你不再需要 else 分支:

fun describe(result: PaymentResult): String = when (result) {
    is PaymentResult.Success -> "已支付 ${result.amount} 元(编号 ${result.transactionId})"
    is PaymentResult.Failed -> "失败:${result.reason}"
    PaymentResult.Pending -> "等待处理中"
}

fun main() {
    println(describe(PaymentResult.Success("TX-1001", 250_000)))
    println(describe(PaymentResult.Failed("卡已过期", retryable = false)))
    println(describe(PaymentResult.Pending))
}

运行结果:

已支付 250000 元(编号 TX-1001)
失败:卡已过期
等待处理中

上面代码中有三个需要注意的地方:

  • Smart cast:在 is PaymentResult.Success 分支中,变量 result 会自动转型为 Success。因此你可以直接访问 result.amount,无需手动转型。
  • 对于 object,你直接用 PaymentResult.Pending 进行比较,不需要 is。写 is PaymentResult.Pending 也是正确的,但不是必需的。
  • 这里的 when 是一个表达式,它返回一个值并被用作函数体。表达式形式的 when 必须是穷尽的。

4.2. 添加新情况时编译器会捕获错误

假设你添加了一个退款状态:

sealed class PaymentResult {
    data class Success(val transactionId: String, val amount: Long) : PaymentResult()
    data class Failed(val reason: String, val retryable: Boolean) : PaymentResult()
    data object Pending : PaymentResult()
    data class Refunded(val transactionId: String) : PaymentResult()
}

describe 函数会立即无法编译:

e: 'when' expression must be exhaustive. Add the 'is Refunded' branch or an 'else' branch.

这正是 sealed class 的核心价值。代码中使用 when 检查 PaymentResult 的每个地方都会被编译器指出。你不可能忘记任何地方。

4.3. 语句形式的 when

从 Kotlin 1.7 开始,即使 when 被用作语句(不获取返回值),如果 subject 的类型是 sealed、enum 或 Boolean,编译器仍然会报错如果分支不完整:

fun log(result: PaymentResult) {
    when (result) {
        is PaymentResult.Success -> println("INFO 支付 ${result.transactionId}")
        is PaymentResult.Failed -> println("WARN ${result.reason}")
        PaymentResult.Pending -> println("DEBUG 等待中")
        is PaymentResult.Refunded -> println("INFO 已退款 ${result.transactionId}")
    }
}

如果遗漏 Refunded 分支,你会得到编译错误,而不是旧版 Kotlin 中的警告。

5. Sealed interface

一个类只能继承一个父类,但可以实现多个接口。因此 sealed interface 在一个类型同时属于多个分组时更灵活:

sealed interface OrderEvent
sealed interface Notifiable {
    val customerEmail: String
}

data class OrderPlaced(val orderId: String, override val customerEmail: String) : OrderEvent, Notifiable
data class OrderShipped(val orderId: String, val trackingCode: String, override val customerEmail: String) : OrderEvent, Notifiable
data class StockReserved(val orderId: String, val sku: String) : OrderEvent

fun handle(event: OrderEvent): String = when (event) {
    is OrderPlaced -> "为 ${event.orderId} 创建发票"
    is OrderShipped -> "追踪代码 ${event.trackingCode}"
    is StockReserved -> "预留商品 ${event.sku}"
}

fun emailOf(target: Notifiable): String = when (target) {
    is OrderPlaced -> "发送订单确认到 ${target.customerEmail}"
    is OrderShipped -> "发送发货通知到 ${target.customerEmail}"
}

OrderPlaced 既是 OrderEvent,也是 Notifiable。emailOf 函数只需要两个分支,因为编译器知道 Notifiable 只有确切的两个实现类。

Sealed interface 也可以配合 enum。一个 enum class 可以实现 sealed interface,这种情况下每个枚举常量被当作 when 中的一个分支。

5.1. 多层嵌套

一个子类也可以是 sealed。这样你可以有更详细的分组:

sealed interface PaymentError {
    sealed interface Retryable : PaymentError
    data object Timeout : Retryable
    data object GatewayBusy : Retryable
    data class CardDeclined(val code: String) : PaymentError
}

fun shouldRetry(error: PaymentError): Boolean = when (error) {
    is PaymentError.Retryable -> true
    is PaymentError.CardDeclined -> false
}

分支 is PaymentError.Retryable 涵盖了 Timeout 和 GatewayBusy 两者,所以即使只有两个分支,when 仍然是穷尽的。

6. when 中的 guard condition

有时同一个类型的对象,你想根据内部数据进行不同的处理。Kotlin 2.2 正式支持 guard condition(在分支条件后使用 if 关键字):

fun nextAction(result: PaymentResult): String = when (result) {
    is PaymentResult.Success -> "发送发票"
    is PaymentResult.Failed if result.retryable -> "30 秒后重试"
    is PaymentResult.Failed -> "向客户报告错误"
    PaymentResult.Pending -> "等待支付网关的 webhook"
    is PaymentResult.Refunded -> "更新财务账簿"
}

带 guard 的分支只有在类型和 if 条件都满足时才会匹配。编译器不认为带 guard 的分支已经覆盖了 Failed。因此如果缺少不带 guard 的 is PaymentResult.Failed 分支,你仍会得到穷尽性错误。

注意:在 Kotlin 2.1 中,guard condition 还是预览功能,需要 -Xwhen-guards 标志。在使用前请检查你项目的 Kotlin 版本。

7. 在 service 中的结合

在真实代码中,sealed class 通常用作 service 的返回类型,而不是抛出异常:

data class Order(val id: String, val amount: Long)

class PaymentService {
    fun charge(order: Order): PaymentResult = when {
        order.amount <= 0 -> PaymentResult.Failed("金额无效", retryable = false)
        order.amount > 50_000_000 -> PaymentResult.Pending
        else -> PaymentResult.Success("TX-${order.id}", order.amount)
    }
}

fun main() {
    val service = PaymentService()
    when (val result = service.charge(Order("A42", 120_000))) {
        is PaymentResult.Success -> println("交易编号:${result.transactionId}")
        else -> println(describe(result))
    }
}
交易编号:TX-A42

在 charge 函数中,when 没有 subject,所以需要 else。这里 else 是完全合理的,因为条件是任意的布尔表达式。

在 main 中,when (val result = ...) 既调用 service 又在 when 块中限定 result 的作用域。else 分支是有意的:你只关心成功的情况。第 9 节将说明这个选择的代价。

8. 编写测试

得益于 data class,你可以用 isEqualTo 非常简洁地比较结果:

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

class PaymentServiceTest {

    private val service = PaymentService()

    @Test
    fun `有效订单返回 Success`() {
        val result = service.charge(Order("A1", 99_000))

        assertThat(result).isEqualTo(PaymentResult.Success("TX-A1", 99_000))
    }

    @Test
    fun `负数金额返回不可重试的 Failed`() {
        val result = service.charge(Order("A2", -5))

        assertThat(result).isInstanceOf(PaymentResult.Failed::class.java)
        assertThat((result as PaymentResult.Failed).retryable).isFalse()
    }

    @Test
    fun `大订单转入 Pending`() {
        assertThat(service.charge(Order("A3", 60_000_000))).isSameAs(PaymentResult.Pending)
    }

    @Test
    fun `可重试的错误导致重试操作`() {
        val action = nextAction(PaymentResult.Failed("网关超时", retryable = true))

        assertThat(action).isEqualTo("30 秒后重试")
    }
}

这个测试套件检查了三个要点:

  • 第一个测试依赖 data class 生成的 equals 来比较整个数据。
  • isSameAs 用于 data object,因为 Pending 是单例,所以引用相等比较是足够的。
  • 最后的测试检查了带 guard condition 的分支。

9. 常见错误和何时不应该使用

在 sealed 类型的 when 中添加 else。 这是最常见的错误。一个“保险”的 else 分支会完全禁用穷尽性检查。当添加新的子类时,编译器会保持沉默。只有当你真的想合并所有剩余情况时才使用 else,就像第 7 节的例子一样。

使用没有 subject 的 when。 when { result is Success -> ... } 不会根据 sealed hierarchy 进行穷尽性检查。写成 when (result) 才能让编译器分析。

忘记为 object 添加 data。 一个普通的 object Pending 通常仍然能运行。但日志会打印出带哈希码的类名,调试时很难读。

将子类放在 module 外。 如果你在写一个库并希望用户自己扩展(比如插件),sealed class 是错误的选择。用普通的 interface 或 abstract class。

注意:不应该使用 sealed class 的情况:

  • 这些情况只是没有数据的常量,如 LOW、MEDIUM、HIGH。这时 enum class 更简洁,还有 entries 和 valueOf。
  • 子类列表经常变化且由多个 team 在多个 module 中贡献。
  • 行为应该存在对象本身(通过方法覆盖的多态),而不是分散在各处的 when。如果同一个 when 在十个地方重复出现,考虑把逻辑移到每个子类的方法中。

10. 结论

Sealed class 和 sealed interface 帮助你在一个 module 中锁定继承树。因此编译器知道所有可能的情况。结合带 subject 的 when,你获得了 smart cast、无需 else,以及当忘记处理新状态时立即得到编译错误。

对于携带数据的情况使用 data class,对于不携带数据的情况使用 data object。当一个类型需要属于多个分组时选择 sealed interface。Kotlin 2.2 的 guard condition 帮助你按内部数据分离分支。最重要的是避免不必要的 else 分支,因为它会夺走你所寻求的正是这个优势。

kotlinsealed classwhenpattern matchingtype safety