程序员

Java和Kotlin泛型笔记

2021-01-22  本文已影响0人  AssIstne

在日常编程中, 我们经常会用到泛型, 用的时候感觉并不复杂, 然而最近在做Kotlin开发时, 被其中的逆变和协变搞得头大, 才发现自己对泛型的了解并不深, 因此系统地整理相关的知识, 希望能帮到遇到同样问题的你.

#0x00: 什么是泛型

在Java中

Java 泛型(generics)是 JDK 5 中引入的一个新特性, 泛型提供了编译时类型安全检测机制,该机制允许程序员在编译时检测到非法的类型。

在这个定义中, 我们需要注意:

0x01. 泛型是JDK5(2004年)才引入的

这意味着, Java中的泛型需要考虑向前兼容的问题, 因此在Java中允许忽略类型参数.

// 忽略类型参数仍可以通过编译, 运行时也不会报错
List list = new List();
list.add("string1")

在Kotlin中, 泛型的定义和作用和Java是一致的. 不过Kotlin没有历史包袱, 所以在Kotlin中, 使用泛型时, 类型参数是必须的. 不过这里说的"必须", 是指编译器必须知道类型参数, 而Kotlin可以进行类型推导, 所以在类型参数已经确定的场景下, 你仍可以省略类型参数.

val list: List<String> = ArrayList<String>() // 完整的写法
val list: List<String> = ArrayList() // 已知类型是String, 所以ArrayList可以忽略类型参数
val list = ArrayList<String>() // 类型未知, 不能忽略类型参数
val list = ArrayList() // !!不能通过编译

0x02. 泛型提供了类型安全监测机制

这说明

泛型的本质是参数化类型,也就是说所操作的数据类型被指定为一个参数。

换句话说, 泛型是用来给编译器提供额外的类型信息, 以此来确保你在写代码的时候不要犯傻的.

0x03. 它作用在编译期

这意味着, 泛型并不能在运行时(Runtime)提供额外的类型信息. 也就是我们经常可以看见的"类型擦除", 简单地说就是在运行时, JVM并不能看到泛型提供的类型信息. 例如List<String>或者List<Integer>在编译后都会变成List, StringInteger都会变成Object了, 所以说类型被擦除了.

类型擦除的一个副作用是我们不能检查泛型的类型, 不能T instanceOf String或者T is String

#0x10: 为什么需要泛型

先想想, 如果没有泛型, 我们实现针对StringIntegerCollection功能要怎么办? 一般有两种方案.

  1. 提供一个处理ObjectObjectCollection. 使用这种方法, 因为类型是Object, 所以你不能确定容器中的实际类型是String还是Integer, 使用过程你需要反复进行类型转换, 这很容易因为使用的错误的类型导致崩溃.
  2. 分别提供StringCollectionIntegerCollection. 这样我们可以确定使用的类型, 避免方法1的问题, 不过我们可以猜到, 这两个类之间肯定有很多重复的方法, 太不简洁了, 而且当需要的类型变多时, 类的数量也会变多, 这会让我们很难受.

那么, 如果可以在不能确定类型和完全确定类型之间折中处理, 让我们既可以在写代码的时候避免因为不知道类型而犯错, 又可以模糊类型信息, 达到复用代码的目的, 这样就完美了. 这也就是泛型要解决的问题.


折中处理类型信息

#0x20: 泛型的实现

其实在上面定义部分, 已经说明了泛型实现上述目标的方法.

  1. 提供额外的类型信息给编译器, 在写代码的过程中, 编译器能对泛型进行类型限制和类型转换, 防止我们犯错和省去类型转换. 相当于在编译期, 泛型处于类型确定的形态.
  2. 编译后则会进行类型擦除, 相当于在运行期, 泛型退化到类型不确定的形态, 此时可以处理任意类型, 以此来复用代码.


    泛型的变化

#0x30 泛型的问题

目前看起来很完美, 不过事情往往没有那么简单.
引入泛型后, 我们得到了一些好处, 不过也引入了新的问题需要处理.

0x31. 类型忽略问题

泛型给我们提供的额外的类型信息在大部分时候都是好事, 不过在使用泛型时, 在有些场景中, 我们可能并不关心所使用的泛型的具体类型参数, 甚至可能不知道类型信息.
例如我们现在要实现针对CollectionComparator接口, 假设我们只想比较容器的大小, 此时, Collection中的类型是什么, 我们是不关心的.
当然我们可以直接把Object当作类型参数, 不过在语义上会产生混淆, 你是希望这是一个Collection<Object>还是你不知道它的类型参数?
为了解决这个问题, Java提供了类型通配符(?), 以上例子可以这样写

public class CollectionComparator implements Comparator<Collection<?>> {

    @Override
    public int compare(Collection<?> c1, Collection<?> c2) {
        return c1.size() - c2.size();
    }
}

而Kotlin提供了星投影(*)解决这个问题, 相同的功能用Kotlin可以这样写

class CollectionComparator : Comparator<Collection<*>> {

    override fun compare(c1: Collection<*>, c2: Collection<*>): Int {
        return c1.size - c2.size
    }
}

不过, 如果忽略了类型参数, 那么编译器就不知道具体的类型了, 所以你就不能对泛型中的类型参数进行写操作了, 例如

// java
List<?> list = new ArrayList<String>();
list.add("string1"); // 类型不匹配, 编译错误

你仍可以读取泛型中的类型参数, 但是类型信息会丢失, Java中得到的就是Object类型, Kotlin中为Any?, 例如

// java
List<?> list = new ArrayList<String>()
Object e1 = list.get(); // 返回Object类型

0x32. 类型限制问题

我们在声明泛型的时候, 是不知道具体的类型的, 这导致我们在泛型内部没有办法使用类型的方法. 为了可以在内部把参数类型当作特定类型来使用, 我们需要告诉编译器, 这个参数类型的一些限制条件.

在Java中, 通过引入限定通配符来限制参数类型. 具体有上界限定通配符? extends E和下界限定通配符? super E来实现.

对于上界限定通配符, 可以实现调用该上界类型的方法. 例如以下例子

public class CharSequenceComparator<E extends CharSequence> implements Comparator<E> {

    @Override
    public int compare(E o1, E o2) {
        // 编译器会把E类型看作CharSequence, 所以可以调用CharSequence的方法
        return o1.length() - o2.length();
    }
}

而下界限定通配符, 则有些特别. 在子类型关系问题中我们再分析下它的作用.

我个人的理解: 下界限定通配符是单纯为了解决子类型关系问题的, 而上界限定通配符则兼有类型限制和子类型关系这两个目的, 这显得Java的泛型有些混乱. 通配符类型也是Java类型系统中最棘手的部分之一.

相比之下, Kotlin则只有一种类型限制方法, 为E : String, 对应Java上界限定通配符, 作用也一样. 不再赘述.

0x33. 子类型关系问题

到目前为止, 所说到的点都很好理解.
而泛型中最难梳理清楚的, 我个人觉得是由泛型引入的子类型关系问题. 也就是说List<Object>List<CharSequence>ArrayList<String>之间是什么关系?

1. 子类型和子类

深入这个问题前, 我们先看看, 子类型和子类的区别.

A是B的子类型, 表明A实例可以赋值给B类型的引用.

// A是B的子类型
A a = new A();
B b = a;

A是B的子类, 表明A类继承了B类.

class A extends B

在通常情况下, 它们表达了同样的意思, 不过当引入了泛型后, 情况就有所不同了.
List<Object>List<String>, 他们的原始类型都是List类, 而StringObject的子类(同时也是子类型), 而且根据直觉, 可以放进List<String>的实例, 也可以放进List<Object>中, 然而, 实际情况是, List<String>不是List<Object>的子类型, 所以以下代码不能编译.

List<String> stringList = new ArrayList<>();
List<Object> objectList = stringList;// !!类型错误, 不能编译

这是因为在Java中, 泛型是不型变的.

2. 不型变, 协变和逆变

要梳理子类型关系, 我们还要先理解不型变, 协变和逆变这3个概念.

从上面可以知道
虽然StringObject的子类型, 但是List<String>不是List<Object>的子类型, 那么可以称之为不型变.

如果通过某个方法, 让List<String>List<Object>的子类型, 那么称之为协变. 协变保留了子类型化关系.

如果通过某个方法, 让List<Object>List<String>的子类型, 那么称之为逆变. 逆变反转了子类型化关系.

可能你开始有点头痛, 不过这只是一些概念, 我们需要记住的是, 这些概念的目的只有一个

保证编译期的类型安全

这也是泛型的主要目的之一. 后面我们再具体说下如何保证类型安全.
关于型变, 在Java中
实现协变的方式为限定上界通配符, 即List<? extends String>List<Object>的子类型.
实现逆变的方式为限定下界通配符, 即List<Object>List<? super String>的子类型.

结合上面提及的类型限制问题, 可以看到extends的作用并不单一, 其实本质上的作用是一样的(确保类型安全的一种手段), 但是并不那么显而易见, 这导致理解Java泛型的时候略显复杂.

因此在Kotlin, 引入了类型投影的概念, 使用inout修饰符
实现协变使用out关键字, 即List<String>List<out Any>的子类型.
实现逆变使用in关键字, 即List<Any>List<in String>的子类型.

3. 消费者和生产者, 以及类型安全

对于协变和逆变, 大神有如下的解释

Joshua Bloch 称那些你只能从中读取的对象为生产者,并称那些你只能写入的对象为消费者。

在Java中, 有以下助记符

PECS 代表生产者-Extends、消费者-Super(Producer-Extends, Consumer-Super)。

而在Kotlin, 则有

消费者 in, 生产者 out!

实际我是越看越懵, 所以我自己会从类型安全的角度去看协变和逆变.

对于泛型的类型参数, 只有两种用法;
第一类, 赋值给类型参数, 例如

// 方法入参, 调用方法时会赋值给element变量
public void add(E element) {
    // 忽略...
}

第二类, 获取类型参数对应的实例, 例如

// 方法返回值
public E get(int index) {
    // 忽略...
}
逆变(super/in)可以确保赋值给类型参数时类型安全

先看如下Java例子

List<CharSequence> charSequenceListList = new ArrayList<>();
List<? super String> stringList = charSequenceListList;
stringList.add("string1"); // 赋值给类型参数, 类型安全, 可以编译通过
String c = stringList.get(0); // 获取类型参数对应的实例, 不能编译通过
Object obj = stringList.get(0); // Object是所有类的超类, 可以编译通过

首先, 根据逆变, List<CharSequence>List<? super String>的子类型, 对于List<? super String>, 表示元素是String的超类, 所以我们可以安全地把string1字符串赋值给元素. 但是我们调用List<? super String>#get时, 并不能确定返回的元素的类型, 因为它可能是String的任意一个超类, 把它当做String或者CharSequence都是类型不安全的, 所以只能认为它是一个Object.

对于Kotlin, 实际上本质是一样的, 只是in修饰符更加直接, List<in String>表示可以传String, 所以add方法可以传字符串, 但就不能取String了, 因此get方法只能取得一个Any?.

协变(extends/out)可以确保取出类型参数对应的实例时类型安全

协变和逆变行为是相反的, 所以也很容易类推.

List<String> stringList = new ArrayList<>();
List<? extends CharSequence> charSequenceListList = stringList;
charSequenceListList.add(""); // 类型不安全, 编译错误
CharSequence c = charSequenceListList.get(0); // 类型安全, 正常编译

对于List<? extends CharSequence>, 表示元素是CharSequence的子类型, 该对象实际是一个ArrayList<String>, 由此可见, 我们可以安全地把取出的元素(String)赋值给一个CharSequence. 但是我们调用List<? extends String>#add时, 并不能确定List保存的实际类型, 因为可能是CharSequence的任意一个子类型, 所以把任何实例放进List中都是类型不安全的, 因此会编译错误.

对于Kotlin, 使用out修饰符实现协变, List<out CharSequence>表示输出值是CharSequence, 所以get方法会返回CharSequence, 但就不能使用add进行赋值了.

以上两段是理解反省中, 子类型问题的关键, 而且有点绕, 需要反复琢磨.

#0x40 Kotlin中的泛型

Kotlin作为后来者, 针对Java做得不太好的地方做了写优化, 有些前面已经提到, 这里再次总结下.

1. 专门的上界限制语法

class CharSequenceList<T : CharSequence> : ArrayList<T>()

2. 专门的型变修饰符in, out

val inList: MutableList<in String> = ArrayList<Any>()// 逆变
val outList: MutableList<out Any> = ArrayList<String>() // 协变

根据官网的说法, 这两个关键字是参考C#

我们相信 in 和 out 两词是自解释的(因为它们已经在 C# 中成功使用很长时间了)

3. 专门忽略参数类型的星投影*

val list: List<*> = ArrayList<String>()

4. 声明处型变

引用Kotlin官网的例子

// Java
interface Source<T> {
  T nextT();
}

// Java
void demo(Source<String> strs) {
  Source<Object> objects = strs; // !!!在 Java 中不允许
}

对于Source类, 它不存在输入T参数的方法, 所以它天然是输出安全(协变)的, 也就是说把Source<String>当作Source<Object>的子类型是安全的, 但是Java编译器并不知道这点, 并且正常地禁止这样的直接赋值, 而需要明确使用Source<? extends Object>来表明协变, 在这种情况下, 这个extends的声明是多余的, 单纯是为了满足编译器.

为了优化这种情况, Kotlin引入了声明处型变来向编译器解释这种情况, 在声明类时即表明类型参数T仅会被返回.

interface Source<out T> {
    fun nextT(): T
}

fun demo(strs: Source<String>) {
    val objects: Source<Any> = strs // 这个没问题,因为 T 是一个 out-参数
}

4. 使用点型变

使用点型变是相对声明点型变而言, 实际上Java中所有型变都属于使用点型变, 因此和Java中对应的场景也就都属于使用点型变.
但是需要注意的是

在Java声明类的时候也可以使用superextends关键字, 如
public class CharSequenceComparator<E extends CharSequence> implements Comparator<E>
但此时应该把它们看作是限制泛型的类型, 而不是为了型变

这里的特殊说明, 也证明了Java在泛型的设计上确实有问题.

因为普通的泛型类都是不变型的, 包括Java中定义的所有泛型类, Kotlin中定义时没有使用inout的类, 但是在实际使用时, 在某一个方法内, 它们可能只会发挥一种作用(消费者/生产者), 所以此时, 使用点变型能够放宽可接受的类型的范围(协变和逆变确定的子类型关系), 让代码更加通用.

5. 类型投影

fun copy(from: Array<out Any>, to: Array<Any>) { …… }

在上述的方法中, from参数的类型是Array<out Any>, 这个声明阻止了copy方法写数据进from数组中, 这称作类型投影, from参数是一个投影的数组, 实际上就是一个受到限制的数组, 它不能写入数据.

6. inline函数中泛型的类型参数具体化(reified)

前面已经提到过, 类型参数是可擦除的, 所以我们不能调用T is String这样的判断, 但是在inline函数中, 情况有点不同, 因为inline函数本质上是复制的代码段, 也就是说, 它实际上是可以知道上下文中的对象的类型的, 因此Kotlin中使用reified关键字来处理这种情况.

如下

inline fun <reified T> Any.asType(): T? = if (this is T)
    this
else
    null

// 使用
val obj: Any = "abc"
val s = obj.asType<String>() // s的类型为String?, 实际值为abc

END

如有错漏, 欢迎指出讨论. 希望对大家有帮助.

上一篇下一篇

猜你喜欢

热点阅读