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 分支,因为它会夺走你所寻求的正是这个优势。