Kotlin 的 Sealed Interface 比 Sealed Class 好在哪
「Kotlin 的 Sealed Interface 比 Sealed Class 好在哪」
去年把项目里的状态机从 Sealed Class 迁到 Sealed Interface 时,踩了个编译器版本相关的坑,正好借这个机会把这两者的差异彻底理清楚。很多人以为 Sealed Interface 就是"可以多重继承的 Sealed Class",这个理解太浅了,实际用起来差别远不止这一处。
那个让我困惑的编译错误
事情发生在 Kotlin 1.9.20 升级之后。我们有个订单状态管理模块,原来是这样写的:
sealed class OrderState {
object Idle : OrderState()
data class Loading(val requestId: String) : OrderState()
data class Success(val order: Order) : OrderState()
data class Error(val throwable: Throwable) : OrderState()
}配合 MVI 架构,ViewModel 里用 StateFlow<OrderState> 暴露状态。后来产品要求加一个"局部刷新"的能力,Loading 状态要同时携带某个子模块的刷新标识。我第一反应是改成 Sealed Interface,让 Loading 同时实现两个契约:
sealed interface OrderState
sealed interface Refreshable {
val refreshKey: String
}
data class Loading(
val requestId: String,
override val refreshKey: String
) : OrderState, RefreshableIDE 没报错,但 CI 的编译挂了,提示 Inheritance from an interface with '@JvmDefault' members is only allowed with -Xjvm-default option。查了一圈才发现,团队有人混用了 Kotlin 1.9.10 和 1.9.20 的编译器,而 Sealed Interface 配合接口默认实现的字节码生成策略在 1.9.20 有调整。统一 Gradle 里的 kotlin-gradle-plugin 版本后解决,但这个插曲让我意识到:Sealed Interface 不是简单的语法糖,它背后牵扯到 JVM 字节码层面的接口默认方法、ACC_SUPER 标志位这些细节,和 Sealed Class 的类继承机制完全是两条路。
类文件结构的实际差异
用 javap -v 看了下两种写法生成的字节码,差异很明显。
Sealed Class 的情况:
public abstract class com/example/OrderState
minor version: 0
major version: 52
flags: (0x0421) ACC_PUBLIC, ACC_SUPER, ACC_ABSTRACT
this_class: #2 // com/example/OrderState
super_class: #4 // java/lang/ObjectSealed Interface 的情况:
public interface com/example/OrderState
minor version: 0
major version: 52
flags: (0x0601) ACC_PUBLIC, ACC_INTERFACE, ACC_ABSTRACT
this_class: #2 // com/example/OrderState
super_class: #4 // java/lang/Object
interfaces: 0, fields: 0, methods: 0关键在 PermittedSubclasses 属性。Java 17 的 JEP 409 引入的密封类,在字节码里用 PermittedSubclasses 记录允许继承的子类。Kotlin 的 Sealed Class 和 Sealed Interface 都走这个机制,但承载体不同——一个是 class 文件,一个是 interface 文件。这直接影响了后续若干行为。
比如反射获取子类的方式。Sealed Class 可以用 Class.getPermittedSubclasses(),返回的是类数组;Sealed Interface 同样支持这个方法,但返回的是接口数组。看似没区别,直到你遇到需要实例化的场景。Sealed Class 的子类如果是 object 声明,反射拿到的是单例实例;Sealed Interface 的实现类如果是 object,同样逻辑但走的是接口代理,某些依赖具体类类型的序列化框架(比如老版本的 Gson)会在这里栽跟头。我们项目里就遇到过 Moshi 的 Codegen 对 Sealed Interface 的 object 子类生成适配器时,把 INSTANCE 字段的访问权限处理错了,升级到 Moshi 1.15.0 才修复。
多重继承的真实代价
Sealed Interface 最吸引人的特性当然是让单个类实现多个密封接口。这个能力在领域建模时非常顺手。举个例子,我们有个消息中心模块,消息类型既要按业务域分类(订单消息、物流消息、营销消息),又要按交互形态分类(可点击跳转、可滑动删除、可标记已读)。用 Sealed Class 的话,这两个维度正交组合会产生笛卡尔积爆炸:
// 糟糕的写法:3 x 3 = 9 个类
sealed class Message
class OrderClickableMessage : Message()
class OrderSwipeableMessage : Message()
class OrderReadableMessage : Message()
class LogisticsClickableMessage : Message()
// ... 还有 5 个Sealed Interface 可以拆维度:
sealed interface MessageDomain
sealed interface MessageInteraction {
fun onInteract(): Unit
}
data class OrderMessage(
val orderId: String
) : MessageDomain
data class ClickableWrapper<T : MessageDomain>(
val delegate: T,
val deeplink: String
) : MessageDomain, MessageInteraction {
override fun onInteract() { /* 跳转 */ }
}但这里有个 Kotlin 编译器的限制,直到 1.9.0 才放宽。之前如果你写 sealed interface MessageDomain,然后让另一个 sealed interface MessageInteraction 继承它,再让某个类同时实现两者,编译器会报 This type is sealed, so it can be inherited by only its own nested classes and file-level classes。也就是说,跨文件的 Sealed Interface 继承链条有严格的可见性控制。1.9.0 的 Release Note 里明确提到 "Relax restrictions on sealed class/interface inheritance in some cases",实际测试下来,现在同一模块内不同文件的 Sealed Interface 可以互相继承,但跨模块仍然不行——这和 Sealed Class 的行为一致。
更隐蔽的问题是 when 表达式的穷尽性检查。Sealed Interface 的多重继承会让编译器的控制流分析复杂化。看这段代码:
sealed interface A
sealed interface B
class C : A, B
fun handle(obj: A) = when (obj) {
is B -> "both A and B"
else -> "only A"
}如果 A 和 B 都是密封的,且 C 同时实现两者,编译器需要确认 when 是否覆盖所有 A 的子类。Kotlin 1.8.20 之前有个 bug(KT-57209):当 Sealed Interface 存在菱形继承结构时,when 的穷尽性检查会错误地报告 "Non exhaustive 'when' statements"。我们项目里恰好踩中,临时方案是给 when 加 else 分支并抛异常,等 1.8.20 升级后才去掉。
与 Java 互操作的暗礁
团队里有部分模块还是 Java 写的,互操作场景避不开。Sealed Class 在 Java 侧的表现相对直观:Java 17+ 可以用 instanceof 的模式匹配,语法和 Kotlin 的 when 类似。但 Sealed Interface 有个坑——Java 的 switch 表达式对密封接口的穷尽性检查支持,比 Kotlin 晚了一个版本。
具体说,Kotlin 1.7.0 就支持 Sealed Interface 的 when 穷尽检查,但 Java 的 switch 要到 Java 21(JEP 441)才支持对密封接口的 pattern matching 并做穷尽分析。中间这段真空期,如果你在 Kotlin 里定义 Sealed Interface,然后在 Java 里用 switch 处理,编译器不会报错,但运行时会因为缺少 default 分支而抛 MatchException。这个异常类也是 Java 21 才引入的,Java 17-20 跑这段代码会直接崩溃在 IncompatibleClassChangeError。
我们的订单模块正好要暴露给 Java 写的旧网关层,当时做法是提供一个 Kotlin 侧的扩展函数做分发,而不是让 Java 代码直接 switch。迁移到 Sealed Interface 后,这个适配层反而变简单了——因为接口可以多实现,Java 侧可以用 instanceof 链式判断,虽然啰嗦但安全:
// Java 17 侧的安全写法,不依赖 switch 穷尽性
public String handle(OrderState state) {
if (state instanceof Loading) return "loading";
if (state instanceof Success) return "success";
if (state instanceof Error) return "error";
if (state instanceof Idle) return "idle";
throw new IllegalStateException("Unknown state: " + state.getClass());
}这里有个细节:Sealed Interface 的 PermittedSubclasses 在 Java 反射里可见,所以理论上可以写个通用工具类自动做分发。但实际试了发现,Java 的 Class.getPermittedSubclasses() 返回的顺序是不稳定的,不能依赖索引,必须按类型匹配。这个行为和 Kotlin 的 sealedSubclasses 扩展属性一致,后者在 KClass 上的文档明确写了 "The order of subclasses in the returned list is not specified"。
序列化与数据类的耦合
用 Sealed Interface 做网络响应模型时,Kotlinx Serialization 的处理逻辑和 Sealed Class 有微妙差别。我们的 API 响应结构是这样的:
@Serializable
sealed interface ApiResponse<out T>
@Serializable
@SerialName("success")
data class Success<T>(val data: T) : ApiResponse<T>
@SerialName("error")
@Serializable
data class Error(val code: Int, val message: String) : ApiResponse<Nothing>Sealed Class 时代,@SerialName 是加在类上的,序列化器通过多态分发找到对应子类。迁到 Sealed Interface 后,发现 Kotlinx Serialization 1.5.0 有个行为变更:对于 Sealed Interface,它会优先查找接口自身声明的 @SerialName,而不是实现类的。这导致我们的 Success 和 Error 突然无法被正确反序列化,因为接口 ApiResponse 上没有 @SerialName,编译器默认用了全限定类名作为 discriminator。
修复方案是给 Sealed Interface 显式声明多态序列化策略:
@Serializable
@Polymorphic
sealed interface ApiResponse<out T>或者在 Json 配置里指定 classDiscriminator 和 polymorphism 的具体行为。但这里又引出另一个问题:Sealed Interface 配合泛型时,Success<T> 的 T 类型擦除和 PolymorphicSerializer 的交互。Kotlinx Serialization 1.6.0 之前,ApiResponse<List<Order>> 这样的嵌套泛型,在 Sealed Interface 场景下会丢失 List 的内部类型信息,因为接口的多态分发用的是 PolymorphicSerializer,它只捕获顶层类型参数。Sealed Class 时代因为类继承的 GeneratedSerializer 路径不同,恰好能保留更多类型信息。这个 bug 跟踪在 KT-58089,1.6.0 修复后我们才敢全面迁移。
性能层面的实际测量
很多人关心 Sealed Interface 的多重继承是否有运行时开销。我做了个不太严谨的微基准,用 JMH 跑在 OpenJDK 17、Kotlin 1.9.20 环境下。
测试场景:定义一个密封层级,深度 3 层,每层 3 个分支,叶子节点实现一个额外接口。分别用 Sealed Class 和 Sealed Interface 实现,测试 when 表达式的分发性能。
Sealed Class 版本:
sealed class SC {
class A : SC()
class B : SC()
class C : SC()
}
// ... 嵌套三层Sealed Interface 版本:
sealed interface SI
sealed interface SIExt : SI
class A : SIExt
class B : SIExt
class C : SIExt
// ... 同样嵌套三层,叶子多实现一个标记接口 MarkerJMH 参数:-f 1 -wi 5 -i 5 -r 1s,测试方法是对随机生成的实例做 100 万次 when 判断。
结果有点意外:Sealed Interface 的 when 分发在 HotSpot C2 编译后,和 Sealed Class 几乎没有差距,都在 2.1-2.3 ns/op 级别。但解释执行阶段(-Xint),Sealed Interface 明显慢一截,大约 45 ns/op 对比 Sealed Class 的 32 ns/op。差距来自接口方法调用的 itable 查找,比类继承的 vtable 多一次间接寻址。
不过这个差距在实际应用里可以忽略。真正影响大的是对象内存布局。Sealed Interface 的实现类如果实现多个接口,对象头里的类型指针(Klass pointer)会指向自身类,但接口方法调用需要额外遍历 itable。用 JOL(Java Object Layout)看了下 64 位压缩指针环境下的实例大小,Sealed Interface 的叶子节点和同等字段数的 Sealed Class 子类,都是 16 bytes 对象头 + 字段对齐,没有额外开销。因为接口本身不携带状态,不会增加实例字段。
但有个反直觉的点:Sealed Interface 的 object 子类,在 Kotlin 里编译为单例时,生成的 INSTANCE 字段是 static final 的,和 Sealed Class 的 object 一致。然而如果 object 实现了多个 Sealed Interface,字节码里会生成多个桥接方法(bridge method),导致类文件的常量池和方法区占用略微增加。我们项目里有个状态机有 40 多个 object 状态,迁到 Sealed Interface 后 dex 文件增大了约 12KB。对现代 App 不算事,但在 dex 方法数逼近 65536 的老项目里需要留意。
一个被低估的特性:密封接口的函数式扩展
Sealed Interface 因为可以多实现,能玩出一些 Sealed Class 做不到的模式。我最喜欢的是给接口加"能力"(capability)标记,然后用扩展函数统一附加行为。
比如我们的 UI 组件体系,按钮、输入框、卡片都需要支持加载态,但继承自不同的基类。用 Sealed Interface 定义能力契约:
sealed interface Component
sealed interface Loadable {
val isLoading: Boolean
}
fun <T> T.withLoadingOverlay(): @Composable () -> Unit
where T : Component, T : Loadable = { /* ... */ }这里的 where 约束同时要求 T 是 Component 和 Loadable,这个类型交集(intersection type)在 Kotlin 的类型系统里,只有接口组合才能实现。Sealed Class 因为单继承,做不到让某个子类"既是 A 又是 B"的同时还保持密封性——你总不能让 Component 和 Loadable 都变成 Sealed Class 然后多重继承。
这个模式在 Compose 里特别顺手。我们的主题系统里,颜色、字体、间距是三个独立的 Sealed Interface,任何组件可以按需组合实现,然后写带约束的扩展函数统一处理主题切换动画。代码量相比 Sealed Class 时代的委托模式(delegation)减少了大概 30%,且没有运行时委托对象的开销。
但 Compose Compiler 1.5.0 之前有个坑:带 where 约束的泛型扩展函数,如果参数是 Sealed Interface 类型,重组(recomposition)的跳过逻辑会误判。因为编译器生成的稳定性判断(stability inference)对交集类型的处理不完善,把稳定的 Sealed Interface 实现类当成了不稳定,导致不必要的重组。升级到 Compose Compiler 1.5.2 后修复,对应 issue 是 b/290212279。
与泛型变型的交互
Sealed Interface 在泛型变型(variance)上的行为,和 Sealed Class 有细微但重要的差别。看这段:
sealed interface Producer<out T> {
fun produce(): T
}
sealed class ProducerImpl<T>(private val value: T) : Producer<T> {
override fun produce(): T = value
}Producer 是协变的(out T),作为接口它可以安全地这样用:
val stringProducer: Producer<String> = object : Producer<String> { ... }
val anyProducer: Producer<Any> = stringProducer // 合法,因为 out 投影但 ProducerImpl 作为类,如果声明为 sealed class ProducerImpl<out T>,Kotlin 编译器会限制构造函数参数的类型使用位置——out T 不能出现在 in 位置,而构造函数参数恰恰是 in 位置。所以 ProducerImpl 要么去掉 out 变型注解,要么把构造函数参数改成 val 并用 private 修饰(利用 Kotlin 的构造属性特殊规则)。
Sealed Interface 没有构造函数,天然避开了这个限制。接口的 out T 只约束方法的返回位置,实现类可以在自己的构造函数里自由接收参数,只要方法签名满足协变约束。这让设计 API 时更灵活,尤其是需要把实现细节(比如缓存策略、线程调度器)藏在构造函数里,但对外暴露协变接口的场景。
我们项目的 Repository 层重构时利用了这个特性。原来用 Sealed Class 的 Result 类型:
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val exception: Throwable) : Result<Nothing>()
}因为 Result 是类,想给它加一个带默认调度器的扩展构造能力很别扭。改成 Sealed Interface 后:
sealed interface Result<out T>
class Success<T> internal constructor(
val data: T,
private val dispatcher: CoroutineDispatcher
) : Result<T>
// 工厂函数不受接口变型约束
fun <T> Result(
block: suspend () -> T,
dispatcher: CoroutineDispatcher = Dispatchers.IO
): Result<T> = // ...Result 接口保持纯净的协变契约,工厂函数和实现类在模块内部自由组合。这个重构把原来分散在多个文件里的 Result 子类构造逻辑,收拢到了统一入口,同时不破坏接口的公开 API。
工具链的成熟度差异
最后说点工程层面的感受。IntelliJ IDEA 对 Sealed Interface 的代码辅助,比 Sealed Class 晚熟大约两个大版本。2022.1 的时候,"Add remaining branches" 的意图动作(intention action)对 Sealed Interface 的 when 表达式支持不完整,经常漏掉跨文件继承的子类。2023.1 之后才彻底修好。同样,"Hierarchy" 视图里显示 Sealed Interface 的继承关系,早期版本会把接口实现和类继承混在一起,现在才分开展示。
Detekt 静态检查也有段适应期。UnusedPrivateClass 规则曾经误判 Sealed Interface 的私有实现类,因为分析路径没走接口的 PermittedSubclasses。Detekt 1.23.0 修复,对应 PR #6294。我们 CI 里这个规则是强制失败的,当时只能先对 Sealed Interface 的实现类包加 @Suppress。
Kotlin Symbol Processing(KSP)的情况更复杂。KSP 1.0.0 对 KSClassDeclaration.isSealed() 的判断,在 Sealed Interface 上返回 true,但 getSealedSubclasses() 的实现有 bug,返回的序列里会包含接口自身(自引用),导致代码生成器死循环。KSP 1.0.10 修复,但 Room、Moshi 等依赖 KSP 的库各自又有适配延迟。我们项目里 Room 2.5.0 配合 KSP 1.0.9 时,Sealed Interface 作为 @Entity 的父类型会生成错误的迁移代码,升级到 Room 2.6.0 才正常。
这些工具链的磨合成本,是迁移前很难预估的。Sealed Class 从 Kotlin 1.1 就存在,生态磨合了多年;Sealed Interface 1.5 才引入,虽然语法层面稳定了,但周边工具的完整支持需要时间。如果项目高度依赖代码生成(KSP、KAPT)或者严格的静态检查,建议先在隔离模块试点,确认工具链版本兼容性后再铺开。
迁移决策的务实建议
说这么多,到底什么时候该用 Sealed Interface,什么时候坚持 Sealed Class?
我的实践标准:需要表达"是一个"的层级关系,且子类可能有多种角色组合时,用 Sealed Interface;需要强制子类携带特定状态或行为实现,且层级简单时,Sealed Class 更直接。具体到代码,如果子类里大量出现 class X : A, B, C 这种多接口实现,或者需要用 where T : A, T : B 的泛型约束,Sealed Interface 几乎是必选项。反之,如果子类只是单纯的数据载体,没有跨维度的组合需求,Sealed Class 的代码更短,工具链支持更成熟。
还有一个容易忽略的点:Sealed Class 可以有抽象方法和受保护成员,Sealed Interface 的方法默认都是 public 的(Java 接口的局限,Kotlin 虽然允许接口方法 private,但密封接口的设计意图是开放契约)。如果子类之间需要共享某些内部逻辑,Sealed Class 的 protected abstract fun 是更自然的表达,Sealed Interface 里只能退而求其次用内部扩展函数或者显式的委托对象。
我们项目现在的混合策略是:最顶层的类型用 Sealed Interface 保证灵活性,中间层需要状态共享的用抽象类(非密封,因为 Kotlin 的密封和 open 互斥),叶子节点根据组合复杂度选择实现接口还是继承类。这不是教科书式的整洁架构,但工程上最省心——毕竟 Kotlin 的密封机制最终要编译到 JVM 的 PermittedSubclasses,而 JVM 对类和接口的底层处理从来就不是对称的,强行在语言层追求统一反而会制造更多摩擦。