阅读 3749
Kotlin Sealed 是什么?为什么 Google 都用

Kotlin Sealed 是什么?为什么 Google 都用

在上一篇文章 Google 推荐在项目中使用 Sealed 和 RemoteMediator 中介绍了如何使用 Sealed Classes 在 Flow 基础上对网络请求成功和失败进行处理,而这篇文章是对 Sealed Classes 更加深入的解析,结合函数式编程功能很强大,掌握并且灵活运用它,需要大量的实践。

通过这篇文章你将学习到以下内容:

  • Sealed Classes 原理分析?
  • 枚举和抽象类都有那些局限性?
  • 为什么枚举可以作为单例?枚举作为单例有那些优点?
  • 分别在什么情况下使用枚举和 Sealed Classes?
  • Sealed Classes 究竟是什么?
  • 为什么 Sealed Classes 用于表示受限制的类层次结构?
  • 为什么说 Sealed Classes 是枚举类的扩展?
  • Sealed Classes 的子类可以表示不同状态的实例,那么在项目中如何使用?
  • 禁止在 Sealed Classes 所定义的文件外使用, Kotlin 是如何做到的呢?

枚举和抽象类的局限性

在分析 Sealed Classes 之前,我们先来分析一下枚举和抽象类都有那些局限性,注意:这些局限性是相对于 Sealed Classes 而言的,但是相对于它们自身而言是优点,而 Sealed Classes 出现也正是为了解决这些问题。先来看一下枚举的局限性:

  • 限制枚举每个类型只允许有一个实例
  • 限制所有枚举常量使用相同的类型的值

限制枚举每个类型只允许有一个实例

enum class Color(val value: Int) {
    Red(1)
}

fun main(args: Array<String>) {
    val red1 = Color.Red
    val red2 = Color.Red
    println("${red1 == red2}") // true
}
复制代码

最后输出结果

red1 == red2 : true
复制代码

正如你看到的,我们定义了一个单元素的枚举类型,无论 Color.Red 有多少个对象,最终他们的实例都是一个,每个枚举常量仅作为一个实例存在,而一个密封类的子类可以有多个包含状态的实例,这既是枚举的局限性也是枚举的优点

枚举常量作为一个实例存在的优点: 枚举不仅能防止多次实例化,而且还可以防止反序列化,还能避免多线程同步问题,所以它也被列为实现单例方法之一。简单汇总一下。

是否只有一个实例是否反序列化是否是线程安全是否是懒加载

《Effective Java》 一书的作者 Josh Bloch 建议我们使用枚举作为单例,虽然使用枚举实现单例的方法还没有广泛采用,但是单元素的枚举类型已经成为实现 Singleton 的最佳方法。

我们来看一下如何用枚举实现一个单例(与 Java 的实现方式相同),这里不会深究其原理,因为这不是本文的重点内容,小伙伴们可以从掘金搜索,有很多分析这方面原理的文章。

interface ISingleton {
    fun doSomething()
}

enum class Singleton : ISingleton {
    INSTANCE {
        override fun doSomething() {
            // to do
        }
    };

    fun getInstance(): Singleton = Singleton.INSTANCE
}
复制代码

但是在实际项目中使用枚举作为单例的很少,我看了很多开源项目,将枚举作为单例的场景少之有少,很大部分原因是因为使用枚举的时候非常不方便。

我这有个建议如果涉及反序列化创建对象的时候,建议使用枚举,因为 Java 规定,枚举的序列化和反序列化是有特殊定制的,因此禁用编译器使用 writeObjectreadObjectreadObjectNoDatawriteReplacereadResolve 等方法。

限制所有枚举常量使用相同的类型的值

限制所有枚举常量使用相同的类型的值,也就是说每个枚举常量类型的值是相同的,我们还是用刚才的例子做个演示。

enum class Color(val value: Int) {
    Red(1),
    Green(2),
    Blue(3);
}
复制代码

正如你所见,我们在枚举 Color 中定义了三个常量 Red 、Green 、Blue,但是它们只能使用 Int 类型的值,不能使用其他类型的值,如果使用其它类型的值会怎么样?如下所示:

编译器会告诉你只接受 Int 类型的值,无法更改它的类型,也就是说你无法为枚举类型,添加额外的信息。

抽象类的局限性

对于一个抽象类我们可以用一些子类去继承它,但是子类不是固定的,它可以随意扩展,同时也失去枚举常量的受限性。

Sealed Classes 包含了抽象类和枚举的优势:抽象类表示的灵活性和枚举常量的受限性

到这里可能会有一个疑问,如果 Sealed Classes 没有枚举和抽象类的局限性,那么它能在实际项目中给我们带来哪些好处呢?在了解它能带来哪些好处之前,我们先来看看官方对 Sealed Classes 的解释。

Sealed Classes 是什么?

我们先来看一下官方对 Sealed Classes 的解释

我们将上面这段话,简单的总结一下:

  • Sealed Classes 用于表示受限制的类层次结构
  • 从某种意义上说,Sealed Classes 是枚举类的扩展
  • 枚举的不同之处在于,每个枚举常量仅作为单个实例存在,而 Sealed Classes 的子类可以表示不同状态的实例

那上面这三段话分别是什么意思呢?接下来我们围绕这三个方面来分析。

Sealed Classes 用于表示受限制的类层次结构

Sealed Classes 用于表示受限制的类层次结构,其实这句话可以拆成两句话来理解。

  • Sealed Classes 用于表示层级关系: 子类可以是任意的类, 数据类、Kotlin 对象、普通的类,甚至也可以是另一个 Sealed
  • Sealed Classes 受限制: 必须在同一文件中,或者在 Sealed Classes 类的内部中使用,在Kotlin 1.1 之前,规则更加严格,子类只能在 Sealed Classes 类的内部中使用

Sealed Classes 的用法也非常的简单,我们来看一下如何使用 Sealed Classes。

sealed class Color {
    class Red(val value: Int) : Color()
    class Green(val value: Int) : Color()
    class Blue(val name: String) : Color()
}

fun isInstance(color: Color) {
    when (color) {
        is Color.Red -> TODO()
        is Color.Green -> TODO()
        is Color.Blue -> TODO()
    }
}
复制代码

在这里推荐大家一个快捷键 Mac/Win/Linux:Alt + Enter 可以补全 when 语句下的所有分支,效果如下所示:

更多 AndroidStudio 快捷键,可以看之前的两篇文章

Sealed Classes 是枚举类的扩展

从某种意义上说,Sealed Classes 是枚举类的扩展,其实 Sealed Classes 和枚举很像,我们先来看一个例子。

sealed-enu

正如你所看到的,在 Sealed Classes 内部中,使用 object 声明时,我们可以重用它们,不需要每次创建一个新实例,当这样使用时候,它看起来和枚举非常相似。

注意:实际上很少有人会这么使用,而且也不建议这么用,因为在这种情况枚举比 Sealed Classes 更适合

在什么情况下使用枚举

如果你不需要多次实例化,也不需要不提供特殊行为,或者也不需要添加额外的信息,仅作为单个实例存在,这个时候使用枚举更加合适。

我们来看一下 Paging3 中是如何使用枚举的,一起来看一下 androidx.paging.LoadType 这个类的源码。

enum class LoadType {
    REFRESH,
    PREPEND,
    APPEND
}
复制代码
枚举常量作用
refresh在初始化刷新的使用
append在加载更多的时候使用
prepend在当前列表头部添加数据的时候使用

它们不需要多次实例化,也不需要添加任何额外的信息,仅仅表示某种状态,而且它在很多地方都会用到比如 RemoteMediator、PagingSource 等等,想了解更多关于 Paging3 原理和实战案例可以看之前写的几篇文章。

4.Sealed Classes 的子类可以表示不同状态的实例

与枚举的不同之处在于,每个枚举常量仅作为单个实例存在,而 Sealed Classes 的子类可以表示不同状态的实例,我们来看个例子可能更容易理解这句话。

这里我们延用之前在 Google 推荐在项目中使用 sealed 和 RemoteMediator 这篇文章中用到的例子,在请求网络的时候需要对成功或者失败进行处理,我们来看一下用 Sealed Classes 如何进行封装。

sealed class PokemonResult<out T> {
    data class Success<out T>(val value: T) : PokemonResult<T>()

    data class Failure(val throwable: Throwable?) : PokemonResult<Nothing>()
}
复制代码

这里只贴出来部分代码,核心实现可以查看项目 PokemonGo GitHub 地址:https://github.com/hi-dhl/PokemonGo 代码路径:PokemonGo/app/.../com/hi/dhl/pokemon/data/remote/PokemonResult.kt

一起来看一下如何使用

when (result) {
    is PokemonResult.Failure -> {
        // 进行失败提示
    }
    is PokemonResult.Success -> {
        // 进行成功处理
    }
}
复制代码

我们在来看另外一个例子,在一个列表中可能会有不同类型的数据,比如图片、文本等等,那么用 Sealed Classes 如何表示。

sealed class ListItem {
    class Text(val title: String, val content: String) : ListItem()
    class Image(val url: String) : ListItem()
}
复制代码

这是两个比较常见的例子,当然 Sealed Classes 强大不止于此,还有更多场景,等着一起来挖掘。

我们来看一下大神 Antonio Leiva 在这篇文章 Sealed classes in Kotlin: enums with super-powers 分享的一个比较有趣的例子,对 View 进行的一系列操作可以封装在 Sealed Classes 中,我们来看一下会有什么样的效果。

sealed class UiOp {
    object Show: UiOp()
    object Hide: UiOp()
    class TranslateX(val px: Float): UiOp()
    class TranslateY(val px: Float): UiOp()
}

fun execute(view: View, op: UiOp) = when (op) {
    UiOp.Show -> view.visibility = View.VISIBLE
    UiOp.Hide -> view.visibility = View.GONE
    is UiOp.TranslateX -> view.translationX = op.px
    is UiOp.TranslateY -> view.translationY = op.px
}
复制代码

在 Sealed Classes 类中,我们定义了一系列 View 的操作 ShowHideTranslateXTranslateY ,现在我们创建一个类,将这些对视图的操作整合在一起。

class Ui(val uiOps: List = emptyList()) {
    operator fun plus(uiOp: UiOp) = Ui(uiOps + uiOp)
}
复制代码

在 Ui 这个类中声明了一个 List 存储了所有的操作,并重写了 plus 操作符,关于 plus 操作符可以看之前的文章 为数不多的人知道的 Kotlin 技巧以及 原理解析,通过 plus 操作符将这些对视图的操作拼接在一起,这样不仅可以提高代码的可读性,而且使用起来也非常的方便,都定义好之后,我们来看一下如何使用这个类。

val ui = Ui() +
        UiOp.Show +
        UiOp.TranslateX(20f) +
        UiOp.TranslateY(40f) +
        UiOp.Hide
        
run(view, ui)
复制代码

定义了一系列操作之后,然后通过 run 方法来执行这些操作,来看一下 run 方法的实现。

fun run(view: View, ui: Ui) {
    ui.uiOps.forEach { execute(view, it) }
}
复制代码

代码很简单,这里就不多做解释了,在 kotlin 中函数可以作为参数传递,可以将 run 方法传递给另一个函数或者一个类,并且这些操作完全可互换的,将它们结合在一起功能将非常强大。

Sealed Classes 强大不止于此,还有很多很多非常实用的场景,现在我对 Sealed Classes 的理解也非常有限,还不够灵活的使用它,我相信在更多项目,更多的场景,会看到更多实用的一些技巧。

Sealed Classes 原理

在这里我们还是使用上文中用到的例子,来分析 Sealed Classes 原理。

sealed class Color {
    object Red : Color()
    object Green : Color()
    object Blue : Color()
}
复制代码

一起来分析一下反编译后的 Java 代码都做了什么。PS:Tools → Kotlin → Show Kotlin Bytecode

... // 省略部分代码

@Metadata(
   mv = {1, 1, 13},
   bv = {1, 0, 3},
   k = 1,
   d1 = {"\u0000\u001a\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0002\b\u0004\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0000\b6\u0018\u00002\u00020\u0001:\u0003\u0003\u0004\u0005B\u0007\b\u0002¢\u0006\u0002\u0010\u0002\u0082\u0001\u0003\u0006\u0007\b¨\u0006\t"},
   d2 = {"Lcom/hidhl/leetcode/test/saledvsemun/Color;", "", "()V", "Blue", "Green", "Red", "Lcom/hidhl/leetcode/test/saledvsemun/Color$Red;", "Lcom/hidhl/leetcode/test/saledvsemun/Color$Green;", "Lcom/hidhl/leetcode/test/saledvsemun/Color$Blue;", "Java-kotlin"}
)
public abstract class Color {
   private Color() {
   }

   public Color(DefaultConstructorMarker $constructor_marker) {
      this();
   }
}
... // 省略部分代码
复制代码

@Metadata 这个注解会出现在 Kotlin 编译器生成的任何类文件中,可以通过反射的方式获取 @Metadata 信息。参数名称都非常短,可以帮助减少 class 文件的大小。

@Metadata 存储了 Kotlin 主要的语法信息例如扩展函数、typealias 等等,这些信息都是由 kotlinc 编译器,并以注解的形式存放在 Java 的字节码中的,如果元数据被丢弃掉,运行在 JVM 上会抛出异常,那么如何才能确定它们之间的对应关系呢,其实就是通过 @Metadata 这个注解提供的信息。

正因为元数据不能被丢掉,R8 带了新的优化,将元数据信息记录在 R8 的内部数据结构中,当 R8 完成对第三库或者应用程序的优化和收缩时,它会为所有 Kotlin 类合成新的正确的 Kotlin 元数据,其目的就是为了减少应用程序的大小,目前我也在研究中,日后会分享。

而在本例中 @Metadata 保存了一个子类的列表,编译器在使用的时候会用到这些信息。正如你看到的 Sealed class 被编译成了 abstract class,它本身是不能被实例化,只能用它的子类实例化对象。

抽象类 Color 默认的构造方法被私有化了,所以在 Kotlin 1.1 之前,子类必须嵌套在 Sealed Classes 类中,后来放宽了要求,禁止在 Sealed Classes 所定义的文件外使用, Kotlin 是如何做到的呢?如果我们在 Sealed Classes 所定义的文件外使用会怎么样?

正如你所看到,会导致编译错误,那么为什么 Sealed Classes 可以在同文件内使用呢?来看一下反编译后的代码。

// sealed
sealed class Color
// 同文件中使用 sealed class
class Red : Color()
    
    
// 以下是反编译代码 
  
... // 省略部分代码  
public final class Red extends Color {
   public Red() {
      super((DefaultConstructorMarker)null);
   }
}

... // 省略部分代码    
public abstract class Color {
   private Color() {
   }

   // $FF: synthetic method
   public Color(DefaultConstructorMarker $constructor_marker) {
      this();
   }
}
... // 省略部分代码
复制代码

可以看到 Red class 被编译成了 final class, Sealed class 被编译成了 abstract class,同时编译器生成了一个 公有 的构造方法,其他的类无法直接调用,只有 Kotlin 编译器可以使用,Red class 被编译成 final class,在其构造方法内调用了 Color class 公有 的构造方法,而这些都是 Kotlin 编译器帮我们做的。

  • 构造函数私有化,限制了子类必须嵌套在 Sealed Classes 类中
  • 编译器生成了一个 公有 的构造方法,在子类的构造方法中调用了父类 公有 的构造方法,而这些都是 Kotlin 编译器帮我们做的

总结

枚举的局限性

  • 限制枚举每个类型只允许有一个实例
  • 限制所有枚举常量使用相同的类型的值

抽象类的局限性

对于一个抽象类我们可以用一些子类去继承它,但是子类不是固定的,它可以随意扩展,同时也失去枚举常量受限性。

枚举作为单例的优点

是否只有一个实例是否反序列化是否是线程安全是否是懒加载

Sealed Classes 是什么?

Sealed 是一个 abstract 类,它本身是不能被实例化,只能用它的子类实例化对象。Sealed 的构造方法私有化,禁止在 Sealed 所定义的文件外使用。

  • Sealed Classes 用于表示受限制的类层次结构
  • 从某种意义上说,Sealed Classes 是枚举类的扩展
  • 枚举的不同之处在于,每个枚举常量仅作为单个实例存在,而 Sealed Classes 的子类可以表示不同状态的实例。

在什么情况下使用枚举或者 Sealed?

  • 如果涉及反序列化创建对象的时候,建议使用枚举,因为 Java 规定,枚举的序列化和反序列化是有特殊定制的,因此禁用编译器使用 writeObjectreadObjectreadObjectNoDatawriteReplacereadResolve 等方法。
  • 如果你不需要多次实例化,也不需要不提供特殊行为,或者也不需要添加额外的信息,仅作为单个实例存在,这个时候使用枚举更加合适。
  • 其他情况下使用 Sealed Classes,在一定程度上可以使用 Sealed Classes 代替枚举

自动补全 when 语句下的所有分支

推荐给大家一个快捷键 Mac/Win/Linux:Alt + Enter 可以补全 when 语句下的所有分支,效果如下所示:

参考文献

结语

公众号开通了:ByteCode , 欢迎小伙伴们前去查看 Android 10 系列源码,Jetpack ,Kotlin ,译文,LeetCode / 剑指 Offer / 国内外大厂算法题 等等一系列文章,如果对你有帮助,请帮我点个 star,感谢!!!,欢迎一起来学习,在技术的道路上一起前进。

正在建立一个最全、最新的 AndroidX Jetpack 相关组件的实战项目 以及 相关组件原理分析文章,目前已经包含了 App Startup、Paging3、Hilt 等等,正在逐渐增加其他 Jetpack 新成员,仓库持续更新,可以前去查看:AndroidX-Jetpack-Practice, 如果这个仓库对你有帮助,请仓库右上角帮我点个赞。

算法

由于 LeetCode 的题库庞大,每个分类都能筛选出数百道题,由于每个人的精力有限,不可能刷完所有题目,因此我按照经典类型题目去分类、和题目的难易程度去排序。

  • 数据结构: 数组、栈、队列、字符串、链表、树……
  • 算法: 查找算法、搜索算法、位运算、排序、数学、……

每道题目都会用 Java 和 kotlin 去实现,并且每道题目都有解题思路、时间复杂度和空间复杂度,如果你同我一样喜欢算法、LeetCode,可以关注我 GitHub 上的 LeetCode 题解:Leetcode-Solutions-with-Java-And-Kotlin,一起来学习,期待与你一起成长。

Android 10 源码系列

正在写一系列的 Android 10 源码分析的文章,了解系统源码,不仅有助于分析问题,在面试过程中,对我们也是非常有帮助的,如果你同我一样喜欢研究 Android 源码,可以关注我 GitHub 上的 Android10-Source-Analysis,文章都会同步到这个仓库。

Android 应用系列

精选译文

目前正在整理和翻译一系列精选国外的技术文章,不仅仅是翻译,很多优秀的英文技术文章提供了很好思路和方法,每篇文章都会有译者思考部分,对原文的更加深入的解读,可以关注我 GitHub 上的 Technical-Article-Translation,文章都会同步到这个仓库。

工具系列

文章分类
Android
文章标签