Kotlin/Java 的协变和逆变由来,使用场景

391 阅读8分钟

类(Classe)类 Vs 类型(Type)

首先给出两者维基百科上的定义

类:在面向对象编程中,类是一个可扩展的程序代码模板,用于创建对象,为状态(成员变量)和行为(成员函数或方法)的实现提供初始值。(en.wikipedia.org/wiki/Class_…

类型:类型就是数据的分类.....决定了该类型可能的值,以及在该类型的值上可以完成的操作。(en.wikipedia.org/wiki/Data_t…)

有些语言(尤其是Java)模糊了类型和类之间的界限。您甚至可以在教程、文档等中看到它们互换使用。虽然它们是相关的概念,但它们不是同义词。

在Kotlin中

var x: String

声明了一个可以保存String类实例的变量;但是,同样的类也可以用来声明可空类型

var x: String?

这意味着每个Koltin类(Class)都可以构造至少2种类型(Type).

具体到范型类,情况会更加复杂;请看如下代码:

ArrayList a = new ArrayList<Integer>();

ArrayList a是ArrayList类的一个实例。但是它的类型不是ArrayList,而是ArrayList<Integer>。ArrayList<Integer>和ArrayList<String>是同一个类(ArrayList)的实例,但它们显然不是同一个类型——它们保存着完全不同类型的数据!

ArrayList不是类型(Type),而是类(class)。因为它本身接受类型形参。当我们写ArrayList时,实际上是指ArrayList<E>,其中E是一个类型参数。

子类型

任何时候,如果要使用 类型A 的值,都能用 类型B 的值作为替换(当做 A 的值),称 B 是 A 的子类型

从定义中可以看出,任何类型也是它自身的子类型。

把定义说的通俗一点就是 “小范围的类可以替换大范围的类”IntNumber的子类,是因为Int所代表的数的集合范围是Number所代表的子集。

“一个类型是否是另一个的子类型”?这个问题对于编译器来说很重要,因为每次给变量赋值或为函数传递实参时都要做这个检查。只有值的类型是变量的子类型时,才允许变量存储该值。

为什么要有范型

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

在没有泛型之前,从集合中读取到的每一个对象都必须进行类型转换,如果不小心插入了错误的类型对象,在运行时的转换处理就会出错。
比如:没有泛型的情况下使用集合:

public static void noGeneric() {
    // 编译正常通过,但是使用的时候可能转换处理出现问题
    ArrayList arr = new ArrayList();
    arr.add("加入一个字符串");
    arr.add(1);
    arr.add('a');
}

在 Java/Kotlin 中我们常用集合( ListSetMap 等等)来存储数据,而在集合中可能存储各种类型的数据,现在我们有四种数据类型 IntFloatDoubleNumber,假设没有泛型,我们需要创建四个集合类来存储对应的数据。

class IntList{ }
class Floatlist{}
class DoubleList{}
class NumberList{}

如果有更多的类型,就需要创建更多的集合类来保存对应的数据,这显示是不可能的,而泛型是一个 "万能的类型匹配器",同时有能让编译器保证类型安全。

泛型将具体的类型( IntFloatDouble 等等)声明的时候使用符号来代替,使用的时候,才指定具体的类型。

协变,逆变与不变型

有2个类型A,B;如果B时A的字类型,后续我们简称A > B.

如果类型 A > 类型 B,经过一个变化 trans 后得到的 trans(A) 与 trans(B) 依旧满足 trans(A) > trans(B),那么称为协变

逆变则刚好相反,如果类型 A > 类型 B,经过一个变化 trans 后得到的 trans(A) 与 trans(B) 满足 trans(B) > trans(A),称为逆变

假设 泛型 类A 包含 类型参数T,即class A<T>,而Type1Type2的子类,如果A<Type1>A<Type2>不存在父子关系,则称 类A 在类型参数上是不变型的

Java 的数组是协变的,假如 A > B,那么有 A[] > B[],所以 B[] 可以赋值给 A[]。举个例子:

Number[] nums = new Integer[] {1, 2, 3};
nums[2] = 2.0;

以上代码可以正常编译,但是会引发运行时错误。因为错误的将Float数据放入到Int类型数组中;

Kotlin 和 Java 中的类都是不变型的。

这样会造成一些限制,辛辛苦苦抽象了一个方法,它接收一个List<CharSequence>作为参数:handle(chars: List<CharSequence>),想当然地把List<String>传递进入时,编译器会报错;

ArrayList<Number> numbers = new ArrayList<Integer>();

image.png 以上代码编译报错,如果这段代码编译通过,必然会造成运行时异常。

为了保证安全,所有Java/Kotlin中泛型是不支持型变。

协变应用场景

B 是 A 的子类,那么往ArrayList<A>中添加些c数据,是个很通用的场景;此时Collection<B>作为Collection<A> 的子类,这是一个非常自然而然的想法!但由于Java 中的类都是不变型,因此直接裸类型是无法编译通过的。

  • Java 提供了<? extends T>使泛型拥有协变特性;

ArrayList addAll方法源码如下:

public boolean addAll(Collection<? extends E> c) {  
    Object[] a = c.toArray();  
    int numNew = a.length;  
    ensureCapacityInternal(size + numNew); // Increments modCount  
    System.arraycopy(a, 0, elementData, size, numNew);  
    size += numNew;  
    return numNew != 0;  
}
  • 在 Kotlin 中关键字 out 表示协变
interface ProduceExtends<out T> {
    val num: T          // 用于只读属性
    fun getItem(): T    // 用于函数的返回值
}

逆变应用场景

有类型A,B, C;加入A>B>C,那么将符合条件的B数据,添加到ArrayList<A>或者ArrayList<B>是个很通用的场景;那么声明一种trans(C)类型,可以接受ArrayList<A>,ArrayList<B>类型(用于接收C类型数据),即可通用处理这种业务逻辑;此场景下需要使用逆变;

  • 在 Java 中使用通配符 ? super 表示逆变
  • 在 Kotlin 中使用关键字 in 表示逆变

Koltin中Iterable 拓展方法filterTo

public inline fun <T, C : MutableCollection<in T>> Iterable<T>.filterTo(destination: C, predicate: (T) -> Boolean): C {
    for (element in this) if (predicate(element)) destination.add(element)
    return destination
}

例如ArrayList forEach方法源码如下:

public void forEach(Consumer<? super E> action) {
    Objects.requireNonNull(action);
    final int expectedModCount = modCount;
    final Object[] es = elementData;
    final int size = this.size;
    for (int i = 0; modCount == expectedModCount && i < size; i++)
        action.accept(elementAt(es, i));
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

协变应用缺陷

协变放宽了对数据类型的约束,但是放宽是有代价的,我们在花三秒钟思考一下,下面的代码是否可以正常编译。

// Koltin
val numbers: MutableList<out Number> = ArrayList<Int>()
numbers.add(1)

// Java
List<? extends Number> numbers = new ArrayList<Integer>();
numbers.add(1)

调用 add() 方法会编译失败,虽然协变放宽了对数据类型的约束,可以接受 Number 或者 Number 子类型为对象的集合,但是代价是 无法添加元素,只能获取元素,因此协变只能作为生产者,向外提供数据。

为什么无法添加元素

因为 ? 表示未知类型,所以编译器也不知道会往集合中添加什么类型的数据,因此索性不允许往集合中添加元素。

但是如果想让上面的代码编译通过,想往集合中添加元素,这就需要用到逆变了。

逆变应用缺陷

现在我们将上面的代码简单修改一下,在花三秒钟思考一下是否可以正常编译

// Kotlin
val numbers: MutableList<in Number> = ArrayList<Number>()
numbers.add(100)

// Java
List<? super Number> numbers = new ArrayList<Number>();
numbers.add(100);

答案可以正常编译,逆变通配符 ? super Number 或者关键字 in 将继承关系颠倒过来,主要用来限制未知类型的子类型,在上面的例子中,编译器知道子类型是 Number,因此只要是 Number 的子类都可以添加。

逆变可以往集合中添加元素,那么可以获取元素吗?我们花三秒钟时间思考一下,下面的代码是否可以正常编译。

// Kotlin
val numbers: MutableList<in Number> = ArrayList<Number>()
numbers.add(100)
numbers.get(0)

// Java
List<? super Number> numbers = new ArrayList<Number>();
numbers.add(100);
numbers.get(0);

无论调用 add() 方法还是调用 get() 方法,都可以正常编译通过,现在将上面的代码修改一下,思考一下是否可以正常编译通过。

// Kotlin
val numbers: MutableList<in Number> = ArrayList<Number>()
numbers.add(100)
val item: Int = numbers.get(0)

// Java
List<? super Number> numbers = new ArrayList<Number>();
numbers.add(100);
int item = numbers.get(0);

调用 get() 方法会编译失败,因为 numbers.get(0) 获取的的值是 Object 的类型,因此它不能直接赋值给 int 类型,逆变和协变一样,放宽了对数据类型的约束,但是代价是 不能按照泛型类型读取元素,也就是说往集合中添加 int 类型的数据,调用 get() 方法获取到的不是 int 类型的数据。

PECS

PECS(Producer-Extends, Consumer-Super)原则,即 ? extends 或者 out 作为生产者,? super 或者 in 作为消费者。遵循这个原则的好处是,可以在编译阶段保证代码安全,减少未知错误的发生。

协变只能读取数据,不能添加数据,所以只能作为生产者,向外提供数据,因此只能用来输出,不能用来输入。

逆变只能添加数据,不能按照泛型读取数据,所以只能作为消费者,因此只能用来输入,不能用来输出。

参考:

Kotlin 进阶 | 不变型、协变、逆变

Kotlin 和 Java 泛型的缺陷和应用场景

一文读懂 kotlin 的协变与逆变 -- 从 Java 说起

换个姿势,十分钟拿下Java/Kotlin泛型

Types vs. Classes

Java核心知识:泛型机制详解