Kotlin 1.5 推出了密封接口(Sealed Interface),这与密封类(Sealed Class)有什么区别呢?
在开始聊密封接口之前先回顾一下密封类的进化史。
密封类的进化史
密封类可以约束子类的类型,类似于枚举类,但相对于枚举更加灵活:
- Enum Class:每个枚举都是枚举类的实例,可以直接使用
- Sealed Class:密封类约束的子类只是一个类型,你可以为不同子类定义方法和属性,并对齐动态实例化
Kotlin 1.0
早期 Kotlin 1.0 中的密封类,子类型必须是密封类的内部类:
//编程语言
sealed class ProgrammingLang {
object Assembly : ProgrammingLang()
class Java(ver: String) : ProgrammingLang()
class JavaScript(ver: String) : ProgrammingLang()
}
这可以防止在在不编译密封类的前提下为其创建新的派生类。任何派生类的添加都必须重新编译密封类本身,外部调用方能时刻同步所有的子类类型,确保 when
语句的合法:
//获取指定语言的排名
val ranking = when (val item: ProgrammingLang = getProgramLang()) {
Assembly -> TODO()
is Java -> TODO()
is JavaScript -> TODO()
}
另一个潜在的好处是子类必须连同父类名字一起出现,例如 ProgrammingLang.Java
,这有助于明确其namespace。
Kotlin 1.1
Kotlin 1.1 取消了子类必须在密封类内部定义的约束,密封类的子类可以声明在文件的 Top-Level。但是为了保证编译的同步,仍然需要在同一文件内。
sealed class ProgrammingLang
object Assembly : ProgrammingLang()
class Java(ver: String) : ProgrammingLang()
class JavaScript(ver: String) : ProgrammingLang()
Kotlin 1.5
到了Kotlin 1.5,约束进一步放宽,允许子类定义在不同的文件中,只要保证子类和父类在同一个 Gradle module 且是同一个包名下即可。在一个 module 可以保证整个所有文件同时参与编译,仍然可以保证编译的同步。
// Lang.kt
sealed class ProgrammingLang
// Compiled.kt
class Java(ver: String) : ProgrammingLang()
class Cpp(ver: String) : ProgrammingLang()
// Interpreted.kt
class JavaScript(ver: String) : ProgrammingLang()
class Lua(ver: String) : ProgrammingLang()
// LowLevel.kt
object Assembly : ProgrammingLang()
放宽约束后,有利于子类按文件归类,同时,较长的子类拆分为单独文件也便于阅读。
如果违反了同Module、同包名的限制,编译会报错:
e: Inheritance of sealed classes or interfaces from different module is prohibited
e: Inheritor of sealed class or interface must be in package where base class is declared
密封接口 Sealed Interface
Kotlin 1.5 除了进一步放宽了对密封类的使用限制,还引入了密封接口。
通常引入接口最主要的目的无非就是对外隐藏实现,但是1.5的密封类已经可以通过分割文件隐藏子类了,密封接口存在的意义是什么?
在以下几个场景中密封接口可以弥补密封类的不足:
1. "final" 的 interface
有时,我们虽然对外暴露了interface,但是并不希望外界去实现它。比如kotlinx.coroutines
的 Job
public interface Job : CoroutineContext.Element {
...
public fun start(): Boolean
...
public fun cancel(): Unit
...
}
Job
作为一个接口,外界可以对它任意实现,但显然这不是 kotlinx.coroutines
希望出现的。因为未来随着协程功能的迭代,Job 中的共有属性和方法或许会出现变化和增减,如果外部有其派生类很容易出现二进制兼容问题。
如果把 Job
定义为一个密封接口,就可以很好地避免上述问题。
可以大胆猜测,未来某版本的协程中 Job 会以密封接口的形式出现。我们在自己的 library 中也可以考虑使用密封接口避免暴露的接口被随意实现。
2. “可嵌套”的枚举
枚举和密封类功能上很相近,除了文章开头介绍的一些区别外,还有一个容易被忽略的点就是枚举类无法继承其他类。
枚举类的本质都是 Enum
的子类:
enum class JvmLang {
Java, Kotlin, Scala
}
反编译 class 后会发现,JvmLang 继承自 Enum。
public final class JvmLang extends Enum{
private JvmLang(String s,int i){
super(s,i);
}
public static final JvmLang Java;
public static final JvmLang Kotlin;
public static final JvmLang Scala;
...
static{
Java = new Action("Java",0);
Kotlin = new Action("Kotlin",1);
Scala = new Action("Scala",2);
}
}
由于单继承的限制,枚举类无法继承 Enum 以外的其他 Class:
e: Enum class cannot inherit from classes
但有时候,、我们又需要枚举能实现嵌套以处理更复杂的分类逻辑。此时密封接口就成了唯一选择
sealed interface Language
enum class HighLevelLang : Language {
Java, Kotlin, CPP
}
enum class MachineLang : Language {
ARM, X86
}
object AssemblyLang : Language
如上,我们通过密封接口实际上定义了一组“可嵌套”的枚举。
之后就可以通过多级 when
语句进行分类处理了:
when (lang) {
is Machine ->
when (lang) {
MachineLang.ARM -> TODO()
MachineLang.X86 -> TODO()
}
is HighLevel ->
when (lang) {
HighLevelLang.CPP -> TODO()
HighLevelLang.Java -> TODO()
HighLevelLang.Kotlin -> TODO()
}
else -> TODO()
}
3. 多继承的密封类
前两个密封接口的使用场景和密封类没有太多关系, 但其实密封接口也可以扩大密封类的使用场景:
比如上图中对编程语言的分类,就很难用单继承的密封类进行描述。
比如,当我们像下面这样定义密封类时
sealed class JvmLang {
object Java : JvmLang()
object Kotlin : JvmLang()
object Groovy : JvmLang()
}
sealed class CompiledLang {
object Java : CompiledLang()
object Kotlin : CompiledLang()
object Groovy : CompiledLang()
object Cpp : CompiledLang()
}
Java
不能同时继承自 CompiledLang
与 JvmLang
,所以无法在两个密封类中复用,需要重复定义。
此时可能有人会说,密封类是可以被继承的,可以让 JvmLang
继承 CompiledLang
sealed class JvmLang : CompiledLang
object Java : JvmLang()
object Kotlin : JvmLang()
object Groovy : JvmLang()
object Cpp : CompiledLang()
如上,Java
同时是 CompiledLang
和 JvmLang
的子类,且没有违反单继承结构。
但这只是因为 Java
的语言特性还不够“复杂”罢了。
Groovy
除了是一个编译性语言,同时具有解释性语言的特性,可以同时归类为CompiledLang
和 InterpretedLang
, 此时单继承结构很难维系,需要解除接口实现多继承:
sealed interface CompiledLang
sealed interface InterpretedLang
sealed interface FunctionalLang
sealed interface JvmLang : CompiledLang
object Java : JvmLang
object Kotlin : JvmLang, FunctionalLang
object Groovy : JvmLang, FunctionalLang, InterpretedLang
object JavaScript: InterpretedLang
object Cpp : CompiledLang, FunctionalLang
//编程语言的市场份额
fun shareOfCompiledLang(lang: CompiledLang) = when(lang) {
Java -> TODO()
Kotlin -> TODO()
Groovy -> TODO()
Cpp -> TODO()
}
fun shareOfInterpretedLang(lang: InterpretedLang) = when(lang) {
JavaScript -> TODO()
Groovy -> TODO()
}
无论处理 InterpretedLang
还是 CompiledLang
, Groovy
只需要定义一次。
当然,为了更清晰的显示每种 Lang 的所有属性,可以将 interface 之间的继承关系下放:
sealed interface CompiledLang
sealed interface InterpretedLang
sealed interface FunctionalLang
sealed interface JvmLang
object Java : JvmLang, CompiledLang
object Kotlin : JvmLang, CompiledLang, FunctionalLang
object Groovy : JvmLang, CompiledLang, FunctionalLang, InterpretedLang
object JavaScript: InterpretedLang
object Cpp : CompiledLang, FunctionalLang
与 Java 的兼容性
JDK15 开始,Java 也引入了密封类和密封接口,所以 JDK15 以上,Kotlin 和 Java 之间的密封类和密封接口可以比较好的映射和互操作。
即使在 JDK15 以下,由于密封类在字节码中的构造函数加了 prevate
修饰,可以防止 Java 代码的继承
//kotlin
sealed class ProgrammingLang
//java
class Java extends ProgrammingLang
当试图在 Java 侧继承密封类 ProgrammingLang
时,编译器报错如下:
e: There is no default constructor available in 'ProgrammingLang'
Java class cannot be a part of Kotlin sealed hierarchy
但是对于密封接口,JDK15 以下,Java 代码可以随意实现,这个需要特别注意
还好 JetBrains 宣布在IDE层面会给与警告,如果使用 IntelliJ IDEA 系列的 IDE,当 Java侧实现密封接口时同样会给出编译报错:
e: Java class cannot be a part of Kotlin sealed hierarchy
不管怎样,还是建议尽量少在 Java 中访问带有 Kotlin 语法特性的相关代码。
总结
Kotlin 1.5 进一步解除了对密封类的使用限制,同时还引进了密封接口,为我们带来如下便利:
- 定义“final”的interface
- 定义“可嵌套”的枚举
- 帮助密封类实现多继承
未来,没有任何成员定义的密封类应该尽量使用密封接口替代,另外,当一个Library对外提供服务时,也可以更多地虑使用密封接口防止被外部滥用,可以预见密封接口的应用场景会越来越多。