Kotlin 系列文章详细计划-04-视频脚本

这是码上开学 Kotlin 系列第 4 集的视频脚本。

视频脚本不是文章结构要求,所以不属于「必读」;但建议参与文章编写的作者看一下视频脚本再去看「文章结构要求」和写文章,这样写出来的文章和视频会更容易打配合,更容易比较「搭」。

如果你想用视频脚本直接作为基准来扩展成文章,也没问题,写得好、容易读是唯一标准。

标题:Kotlin 的泛型

脚本:

开场白

大家好,我是扔物线朱凯。

这期是码上开学 Kotlin 系列的独立技术点部分的第一期,这期的内容是泛型。

in 和 out

说到 Kotlin 的泛型,很多人会想到 inout 这两个关键字,这是 Java 里没有的两个关键字。在 Kotlin 官方文档的泛型这一页里,这两个关键字占了很大的篇幅。其实我们作为 Java 工程师,这两个字很好理解,因为它们就是 Java 里带上界和下界的 ? 号通配符:

  1. var textViews: List<out TextView>

等价于:

  1. List<? extends TextView> textViews

以及:

  1. var textViews: List<in TextView>

等价于:

  1. List<? super TextView> textViews

也就是说,inout 在 Java 里是有等价物的。

如果你知道那两个带问号的东西是什么、怎么用,你已经可以开始快进了;但据我所知,大多数的 Android 工程师对泛型的了解并不多,也并不知道这俩是什么东西,所以虽然我们这是一个 Kotlin 的上手教程,但今天我必须在这里讲一点点 Java,讲一下这个问号是个啥。我们从~头~说~起~

Java 的 ? extends

我们在 Java 里用泛型一般都是什么场景?集合类,对吧?一般是这么写的:

  1. List<TextView> textViews = new ArrayList<TextView>();

这种写法在 Kotlin 里也是可以用的:

  1. var textViews: MutableList<TextView> = ArrayList<TextView>()

另外由于编译器会做类型推断,所以可以简化成这样:

  1. List<TextView> textViews = new ArrayList<>();
  1. var textViews: MutableList<TextView> = ArrayList()

不过你如果试一下你会发现,有一种看起来似乎没问题的代码是不被允许的:你不能把一个子类的 List 对象赋值给一个父类 List 的引用:

  1. List<TextView> textViews = new ArrayList<Button>();
  1. var textViews: MutableList<TextView> = ArrayList<Button>()

这个在第二期介绍 Kotlin 的数组和集合也提到过,但当时没有解释,现在我就解释一下。

这是 Java 泛型的一种性质,叫做 covariance,中文翻译做协变。协变其实是来自数学上的一个概念,它在泛型里的意思是:子类的泛型类型也属于泛型类型的子类。比较绕哈,其实就是说,你声明一个父类的 List,我给你赋值过来一个子类的 List 也是可以的。但是 Java 的泛型不具备这个性质,所以这里的代码会报错的。

Java 的这种限制是因为 Java 泛型在编译时的类型擦除。由于有类型擦除的存在,为了保证类型安全,Java 给泛型设置了这种限制。你可以试一下,在 Java 里用数组做类似的事情,是不会报错的,这是因为数组并没有在编译时擦除类型:

  1. TextView[] textViews = new TextView[10];

至于什么是类型擦除,什么是类型安全,为什么擦除类型会导致类型不安全,这就又是另一个话题了,它的理解会对你整体搞懂泛型很有帮助,但是我们不能无限展开,那就跑题了。总之,Java 不允许你把子类的泛型类型对象赋值给一个父类的泛型类型声明,Kotlin 也继承了这种限制。

但有时候,我们确实会需要把子类的泛型类型对象赋值给父类的泛型类型声明。所以 Java 也给我们提供了响应的语法来解除这种限制:通配符,也就是那个 ? 号:

  1. List<? extends TextView> textViews = new ArrayList<Button>();

在声明处的类型参数左边写上 ? extends,你就可以把子类的(泛型类型)对象赋值给父类的(泛型类型)声明了。不过这种写法虽然解除了赋值的限制,却会给你另外增加一个限制:在使用这个引用的时候,你不能调用它的参数包含类型参数的方法——类型参数就是尖括号里面的那个东西——,也不能给它的包含类型参数的字段赋值(空值除外):

  1. List<? extends TextView> textViews = new ArrayList<Button>();
  2. textViews.add(textView); // ❌

简单来说就是,你只能用它,不能修改它。只要记住这个准则,使用 ? extends 就可以突破泛型的这种「父类的泛型类型声明的实际值不能是子类的泛型类型对象」的限制。

愣住……

这有什么用啊?

有的有的,比如你有一个方法,它的功能是接受一个 TextViewList,遍历打印出它们的文字内容:

  1. public void printTexts(List<TextView> textViews) {
  2. for (TextView textView: textViews) {
  3. System.out.println(textView.getText());
  4. }
  5. }

按照 Java 的规矩,如果我传入的是一个类型参数为 Button 的 List 对象,编译器是不允许的:

  1. List<Button> buttons = new ArrayList<>();
  2. printTexts(buttons); // ❌

但我如果在方法参数里加上 ? extends

  1. public void printTexts(List<? extends TextView> textViews) {
  2. for (TextView textView: textViews) {
  3. System.out.println(textView.getText());
  4. }
  5. }

就不会报错了。

所以,当你遇到「只想使用,不需要修改」的情况,就可以用 ? extends 来让本来不具备 covariant 性质的 Java 支持 covariant,以此来扩大变量或者参数的接收范围,让程序更灵活。

Java 的 ? super

而跟 ? extends 相对的,Java 还有一个 ? super 的语法。它也是可以让变量或者参数的接受范围扩大,不过扩大的方向跟 ? extends 相反:它可以让你把父类的泛型类型对象赋值给子类的泛型类型参数:

  1. List<? super Button> textViews = new ArrayList<TextView>();

这种性质也有一个名字,叫 contravariant,逆变,或者叫反变。

哎?还能这样的?

嘿嘿,对,可以的。不过和 ? extends 一样,它也在解除赋值限制的同时,会有使用时的附加限制:它的附加限制和 ? extends 相反,你在使用这种变量的时候,不能调用返回值包含类型参数的方法,也不能获得包含类型参数的字段值:

  1. List<? super Button> textViews = new ArrayList<TextView>();
  2. textViews.get(0); // ❌ 不行

愣住……

这又有什么用啊?

有的有的,比如你有一个方法,它接受一个 TextView 的 List,然后把自己持有的一个 TextView 对象给它添加进去:

  1. public void addTextView(List<TextView> textViews) {
  2. textViews.add(textView);
  3. }

这样在使用的时候,我如果传过来一个 View 的 List,编译器会报错:

  1. List<View> views = new ArrayList<View>();
  2. addTextView(views);

但是其实我们在使用上是期望它不要报错的,为什么?我只是希望有一个能接受这个 TextViewList,让我能把它插进去;那么,我把它插进一个 View 的 List 里有什么问题吗?没问题的啊。所以为了解除这种限制,我们就可以在方法参数里加上 ? super

  1. public void addTextView(List<? super TextView> textViews) {
  2. textViews.add(textView);
  3. }

这样就不报错了。

所以,当你遇到「只想修改,不需要使用」的情况,就可以用 ? super 来让本来不具备 contravariant 性质的 Java 支持 contravariant,来反向地向上扩大变量或者参数的接受范围。

小结

这个就是 Java 的 ? extends? super,Java 的泛型直接使用的话,是不支持 covariant 和 contravariant 的,你可以使用它们来给变量或者方法参数添加这种支持;另外在得到这种支持的同时,这些变量或者参数会受到一些「只能读取不能修改」或者「只能修改不能读取」的限制。它俩的使用场景,也被人总结为 PECS 法则:Producer extends,Consumer super。但是时间限制,我们点到为止,不再多说。

Kotlin 的 out 和 in

现在我们拐回来说 Kotlin。在 Kotlin 里,? extends 换了一种写法,叫做 out? super 也换了一种写法,叫做 in

  1. var textViews: List<out TextView>
  2. var textViews: List<in TextView>

换了个写法,但作用是完全一样的。out 表示,我这个变量或者参数只用来输出,不用来输入,你只能读我不能写我;in 就反过来,表示它只用来输入,不用来输出,你只能写我不能读我。

完了。就这样。简单吧?

你看,我们 Android 工程师学不会 outin,其实并不是因为这两个关键字多难,而是因为我们应该先学学 Java 的泛型。是吧?

声明处的 out 和 in

另外 Kotlin 的 outin 不止可以用在变量和参数的声明里,还可以直接用在泛型类型声明时的类型参数上:

  1. interface Producer<out T> {
  2. fun produce(): T
  3. }
  1. interface Consumer<in T> {
  2. fun consume(product: T)
  3. }

这种写法的意义也非常直接:他表示我的这个类型就只用来输出或者只用来输入。什么叫这个类型只用来输出或者输入?说白了就是,它的作者根据它的功能判断出,它所有的使用场景,都是只用来输出或者只用来输入的。那么为了避免我在每个使用的位置都给变量或者参数写上 out 这么麻烦,我就直接在类型创建的地方写上,一劳永逸了。所以这其实是个简便写法。

不过语法总是最简单的东西,你要知道它们的本质是什么。在类型创建的时候加上 out 或者 in,就表示你给这个类型的定位就是只用来产出或者只用来消费的,所以你什么时候应该在类型声明的时候用 out 或者 in,完全取决于你对这个类型的定位。这就不是一个技术问题,而是一个设计思想的问题,当然前提是你知道什么是泛型。具体怎么用,要靠时间慢慢积累了。

* 号

Kotlin 还可以在变量声明时用 号来填写类型参数,它相当于 Java 的不带 extends 也不带 super 的 ? 号,术语上叫 unbounded wildcard。Java 里的纯 ? 号相当于 ? extends Object,Kotlin 里的 `号也差不多,相当于out Any;另外如果你的类型定义里已经有了out或者in,那这个限制在变量声明时也依然在,不会被*` 号去掉。

比如你的类型定义里是 out Number 的,那它加上 <*> 之后的效果就不是 out Any,而是 out Number

Kotlin 的官方文档里对于 号有个字数不多但却很复杂的介绍,最后给了个总结:相当于 Java 里的 raw type,不过它是安全的。这个是不对的,`号相当于 Java 的<?>` 号。Java 的 raw type 在 Kotlin 里消失了,没了,Kotlin 不允许 raw type。

where

Java 里的泛型定义的时候,可以通过 extends 设置上界:

  1. class Monster<T extends Animal> {
  2. }

——注意,这个是类型声明时的上界,和刚才说的声明变量和方法参数时候的那个带问号的上界不是一个东西哟。好我们继续。

这个上界可以设置多重的,用 & 符号连接:

  1. class Monster<T extends Animal & Food> {
  2. }

而在 Kotlin 里,上界的设置从 extends 变成了 : 号:

  1. class Monster<T : Animal> {
  2. }

这个很好理解,Kotlin 里各种 extends 都被换成了冒号;不过多重上界的设置,Kotlin 就从语法上都和 Java 不一样了,你不能再直接用 & 符号连接多个上界,而是在超过一个上界的时候,要把它们从尖括号里拿出来写,并且加上 where 前缀:

  1. class Monster<T> where T : Animal, T : Food {
  2. }

有人在看文档的时候觉得这个 where 是个新东西,但其实虽然 Java 里没有 where ,但它并没有带来新功能,只是把一个老功能换了个新写法。

reified 提两句

另外 Kotlin 里有一个 Java 里没有的,并且非常实用的关键字:reified。Java 泛型里的类型参数——也就是那个 T ——并不是一个真正的类,而是一个代号,所以你不能把当做一个普通的类型来用,比如你不能在方法里检查一个对象是不是一个 T 的实例。这个在 Kotlin 和 Java 里都一样:

  1. void <T> printIfTypeMatch(Object item) {
  2. if (item instanceof T) { // 报错
  3. System.out.println(item);
  4. }
  5. }
  1. fun <T> printIfTypeMatch(item: Any) {
  2. if (item is T) { // 报错
  3. println(item)
  4. }
  5. }

而在 Kotlin 里,你只要加上一个 reified 就可以解除这个限制:

  1. fun <reified T> printIfTypeMatch(item: Any) {
  2. if (item is T) { // 不报错
  3. println(item)
  4. }
  5. }

不过 reified 自身有个限制:只能用在 inline 函数上:

  1. inline fun <reified T> printIfTypeMatch(item: Any) {
  2. if (item is T) {
  3. println(item)
  4. }
  5. }

具体这是怎么回事,后面讲到 inline 的时候我会详细说清楚,这期就不做太多延伸了。

总结

Kotlin 的泛型大概就说到这里,东西其实不多。很多人觉得 Kotlin 的泛型难学,其实是因为对 Java 的泛型不够了解。泛型很有用,但大多数人只会简单使用泛型,真正的了解却很少,甚至连「泛型是什么」这个问题都不太清楚。这样,你在写代码的时候肯定是一脸懵逼的。

页面显示静态文字:

  • extendssuper 能用在哪些地方?分别是什么作用?

  • 我曾经遇到过 / 正在遇到 IDE 报泛型的错,不让我编译,我要怎么修改才能编译?

    • 以及,泛型导致的 IDE 报错都有哪些,分别应该怎么解决?
  • 有时候我需要写一些灵活的类关系,我隐约觉得我可以用泛型把代码写得更漂亮,但我不确定我应该不应该用泛型,并且不知道具体怎么写。我应该怎么判断和写?

所以如果你想学好 Kotlin 的泛型却觉得很难学不懂,你应该先去耐心学一下 Java 的泛型,千万不要抱着 Kotlin 泛型的那篇文档使劲啃。因为Kotlin 的泛型终究是基于 Java 泛型的,你先搞懂 Java 的泛型之后再去学 Kotlin,才能真正理解。如果你抛弃基础,坚持只学上层语法,效果就会很差。

好了,就这样了。下期内容是协程,这个好像很多人都期待很久了。如果你喜欢我们的内容,欢迎关注收藏留言分享在看。我们下期见!

告辞!