Kotlin 1.5 新特性:密封接口比密封类强在哪?

3,811 阅读7分钟

这是我参与更文挑战的1天,活动详情查看: 更文挑战

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.coroutinesJob

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. 多继承的密封类

前两个密封接口的使用场景和密封类没有太多关系, 但其实密封接口也可以扩大密封类的使用场景:

image.png

比如上图中对编程语言的分类,就很难用单继承的密封类进行描述。

比如,当我们像下面这样定义密封类时

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 不能同时继承自 CompiledLangJvmLang ,所以无法在两个密封类中复用,需要重复定义。

此时可能有人会说,密封类是可以被继承的,可以让 JvmLang 继承 CompiledLang

sealed class JvmLang : CompiledLang

object Java : JvmLang()
object Kotlin : JvmLang()
object Groovy : JvmLang()

object Cpp : CompiledLang()

如上,Java 同时是 CompiledLangJvmLang 的子类,且没有违反单继承结构。

但这只是因为 Java 的语言特性还不够“复杂”罢了。

Groovy 除了是一个编译性语言,同时具有解释性语言的特性,可以同时归类为CompiledLangInterpretedLang, 此时单继承结构很难维系,需要解除接口实现多继承:

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 进一步解除了对密封类的使用限制,同时还引进了密封接口,为我们带来如下便利:

  1. 定义“final”的interface
  2. 定义“可嵌套”的枚举
  3. 帮助密封类实现多继承

未来,没有任何成员定义的密封类应该尽量使用密封接口替代,另外,当一个Library对外提供服务时,也可以更多地虑使用密封接口防止被外部滥用,可以预见密封接口的应用场景会越来越多。