Java 和 Kotlin 泛型中的协变和逆变

2024/10/16 Java Kotlin 共 7276 字,约 21 分钟

前言

泛型定义:类型参数化,使代码适应不同类型。

泛型核心作用:类型安全。在编译时确保类型的一致性,防止运行时出现类型转换错误 ClassCastException

泛型其它作用:

  • 代码复用:编写适用于不同类型的接口、类或方法,不需要为每种类型编写不同的版本;
  • 灵活性:编写的算法不依赖特定的类型,适应多种类型;
  • 维护性:代码容易阅读和理解。

泛型协变和逆变的目的是:支持类的继承关系扩展到泛型类型上,让泛型更灵活的同时保证类型安全

在引入泛型之前,使用 Object 适应不同类型

public class Box {
    private Object object;

    public void set(Object object) { this.object = object; }
    
    public Object get() { return object; }
}

Box integerBox = new Box();
integerBox.set(1);

Box stringBox = new Box();
stringBox.set("string");

System.out.println((Integer)integerBox.get());
System.out.println((String)stringBox.get());

1
string

Box 既可以适应 Integer 类型也可以适应 String 类型,以及适应其它类型。但是必须知道是什么类型,然后才能强制转换使用。假设拿到一个 Box 对象,如果强制转换类型有问题,那么在编译时不会发现,在运行时才会出现 ClassCastException-为时已晚。

在引入泛型之后,使用 <T> 适应不同类型

public class Box<T> {
    private T object;

    public void set(T object) { this.object = object; }
    
    public T get() { return object; }
}

Box integerBox = new Box();
integerBox.set(1);

Box stringBox = new Box();
stringBox.set("string");

System.out.println(integerBox.get());
System.out.println(stringBox.get());

1
string

同样,Box 适应 Integer, String 和其它类型,但是使用时不用强制转换,且编译时也能发现类型转换问题,这就可以避免运行时出现ClassCastException-为时尚早。

协变

泛型的子类型关系

首先,类的继承关系并不自动扩展到泛型类型上。StringObject 的子类,但 List<String> 不是 List<Object> 的子类。

如果 List<String>List<Object> 的子类,那么就可以将子类引用指向父类:

List<String> strs = new ArrayList<>();
//编译器会报错
List<Object> objs = strs;

接下来:

//不安全操作,因为 objs 实际上是 List<String>
objs.add(1);

//抛出 ClassCastException
String s = strs.get(0);

所以允许将 List<String> 赋值给 List<Object> 会破坏类型安全。

那么,要保证类型安全,且允许子类型的泛型对象参与到泛型的使用中,就需要泛型协变。

Java 中的协变

在 Java 中,使用 ? extends T 来表示协变。? extends T 表示一个未知的类型,但它必须是TT的子类型。

List<String> strs = new ArrayList<>();
//编译器不会报错
List<extends Object> objs = strs;

此时,允许将List<String>引用指向List<?extends Object>引用。

但,对象 objs 不能添加新元素,因为 objs 不知道添加元素的确切类型。如果对象 objs 能添加新元素,就将破坏类型安全:

//不安全操作,因为 objs 实际上是 List<String>
objs.add(1);

//抛出 ClassCastException
String s = strs.get(0);

所以List<? extends T>只读:只能使用 get 方法,不能使用 add 或 set 方法。

List<String> strs = new ArrayList<>();
List<extends Object> objs = strs;
//只读,会报编译错误
objs.add("string");
//读出来是父类型对象,String -> Object
Object obj = objs.get(index);

同样,Box<? extends Objecct>也只读:只能使用 get 方法,不能使用 set 方法。

Box<String> stringBox = new Box<>();
Box<? extends Objecct> objectBox = stringBox;
//只读,会报编译错误
objectBox.set("string");

但是这里的只读并不是完全只读,存在例外

  1. 对象 objs 可以添加 null元素:objs.add(null),因为 null 是确切类型。
  2. 对象 objs 可以移除元素:objs.remove()

思考:为什么 Collection.addAll 方法参数设计成 Collection<? extends E>?为什么 Collection.addAll 方法会声明抛出@throws ClassCastException

Kotlin 中的协变

在 Kotlin 中,使用 out T 来表示协变。out T 表示一个未知的类型,但它必须是TT的子类型。

在 Java 中的协变有两个不优雅的地方:

  1. 声明:必须声明对象的类型 List<? extends Object> objs = ...
  2. 只读:却可以有 addremove 操作

Kotlin 在声明和使用协变时变得优雅。

声明协变

在声明协变时,使用 out 修饰,例如 Kotlin 提供的 List 接口:

interface List<out E>

此时,直接允许将List<String>引用指向List<Any>引用。

val strs: List<String> = arrayListOf()
//编译器不会报错
val objs: List<Any> = strs
//读出来是父类型对象,String -> Any
val obj: Any = objs.get(index)

且,对象 objs 没有 add 和 remove 操作,真正只读

所以 Kotlin 提供了interface List<out E> 只读,提供了interface MutableList<E>可读写。

同样,自己要声明协变只需:

class Box<out T>(val obj: T){}

val stringBox: Box<String> = Box("string")
//编译器不会报错
val objectBox: Box<Any> = stringBox

使用处协变:类型投影

通常情况下,类的类型参数不型变:

class Box<T>(var obj: T){}

interface MutableList<E> : List<E>, MutableCollection<E>

此时,不能将Box<String>引用指向 Box<Any>引用(不能将MutableList<String>引用指向 MutableList<Any>引用)。

但,如果想让 Box<String>引用指向 Box<Any>引用,可以:

val stringBox: Box<String> = Box()
val objectBox: Box<out Any> = stringBox
//只读,会报编译错误
objectBox.obj = "string"

通过objectBox: Box<out Any>让 Box 类在 T 类型上协变。

这就是类型投影:在不确定类型上,对类型进行限制。类在 T 类型上不型变,但能通过 out 进行协变,让 Box 只读。

小结

Java 中的泛型协变通过 ? extends T,让子类型的泛型对象参与到泛型的使用中:

Box<String> stringBox = new Box();
Box<? extedns Object> objectBox = stringBox;

Kotlin 中的泛型协变通过 out T,让子类型的泛型对象参与到泛型的使用中:

//声明
class Box<out T>{}
val stringBox: Box<String> = Box()
val objectBox: Box<Any> = stringBox
//类型投影
class Box<T>{}
val stringBox: Box<String> = Box()
val objectBox: Box<out Any> = stringBox

泛型协变使对象只读,但读取对象类型为 TT的子类型,由于是不确定具体的类型,所以编译时会将其视为 T 类型。

逆变

保证类型安全,且允许父类型的泛型对象参与到泛型的使用中,就需要泛型逆变。

Java 中的逆变

在 Java 中,使用 ? super T 来表示协变。? super T 表示一个未知的类型,但它必须是TT的父类型。

List<Object> objs = new ArrayList<>();
//编译器不会报错
List<super Integer> ints = objs;

此时,允许将List<Object>引用指向List<?super Integer>引用。

但,对象 ints 不可读取元素,因为 ints 不知道存储元素的确切类型。如果对象 ints 可读,就将破坏类型安全:

List<Object> objs = new ArrayList<>();
objs.add("string");
List<?super Integer> ints = objs;
ints.add(1); 
//不安全读取,读取时只能认为是 Object,无法确定它具体是什么类型
Object obs = ints.get(index);

所以List<? super T>只写:只能使用 add 或 set 方法,不能使用 get 方法。

List<Object> objs = new ArrayList<>();
List<super Number> nums = objs;
//写入整数类型
nums.add(1);
//写入浮点数类型
nums.add(1.0f);

List<? super T>只能写入 TT 的子类型。如果 nums 对象可以写入 Number 父类型:

//nums 是 ArrayList<Object>,理论能添加父类型:nums.add(new Object());
List<?super Number> nums = new ArrayList<Object>();
//nums 是 ArrayList<Number>,不能添加父类型:nums.add(new Object());
List<?super Number> nums = new ArrayList<Number>();

此时,编译器就不知道 nums 具体是什么类型。

只写也存在例外:

  1. 对象 nums 可以读取:Object o = nums.get(index);

思考:为什么 Collections.addAll 方法参数设计成 Collection<? super T>

Kotlin 中的逆变

在 Kotlin 中,使用 in T 来表示逆变。in T 表示一个未知的类型,但它必须是TT的父类型。

同样,在 Java 中的逆变有两个不优雅的地方:

  1. 声明:必须声明对象的类型 List<? super Integer> ints = ...
  2. 只写:却可以有 get 操作

Kotlin 在声明和使用逆变时变得优雅。

声明逆变

在声明逆变时,使用 in 修饰,例如 Kotlin 提供的 Comparable 接口:

interface Comparable<in T> {
    operator fun compareTo(other: T): Int
}

fun demo(x: Comparable<Number>) {
    x.compareTo(1.0) // 1.0 拥有类型 Double,它是 Number 的子类型
    // 因此,可以将 x 赋给类型为 Comparable <Double> 的变量
    val y: Comparable<Double> = x // OK!
}

使用处逆变:类型投影

通常情况下,类的类型参数不型变:

interface MutableList<E>

通过 nums: MutableList<in Number>,可以让 MutableList 类在 T 类型上逆变:

val objs: MutableList<Any> = mutableListOf()
val nums: MutableList<in Number> = objs
//写入整数类型
nums.add(1)
//写入浮点数类型
nums.add(1.0f)

小结

Java 中的泛型逆变通过 ? super T,让父类型的泛型对象参与到泛型的使用中:

Box<Object> objectBox = new Box();
Box<? super String> stringBox = objectBox;

Kotlin 中的泛型逆变通过 in T,让父类型的泛型对象参与到泛型的使用中:

//声明
class Box<in T>{}
val objectBox: Box<Any> = Box()
val stringBox: Box<String> = objectBox
//类型投影
class Box<T>{}
val objectBox: Box<Any> = Box()
val stringBox: Box<in String> = objectBox

泛型逆变使对象只写,但写入对象类型为 TT的子类型,由于是不确定具体的类型,所以会将其视为 T 类型。

总结

协变和逆变让类的继承关系扩展到泛型类型上,同时保证类型安全

特征协变逆变
方向从子类到父类从父类到子类
作用返回值类型输入参数类型
关键字? extends T 或 out T? super T 或 in T
含义返回值可以变得具体输入参数可以变得泛化

Java 中的泛型协变和逆变

协变

List<? extends Number> numbers = new ArrayList<Integer>();
//只读
Number number = numbers.get(index);

逆变

List<? super Number> numbers = new ArrayList<Object>();
//只写
numbers.add(Integer or Float or Double or Number)

Kotlin 中的泛型协变和逆变

协变

//声明
interface List<out T>
val numbers: List<Number> = arrayListOf<Int>()
//只读
val number: Number = numbers.get(index)


//类型投影
val numbers: MutableList<out Number> = mutableListOf<Int>()

逆变

//声明
interface Comparable<in T>


//类型投影
val numbers: MutableList<in Number> = mutableListOf<Any>()
//只写
numbers.add(Int or Float or Double or Number)

PECS 原则

PECS: Producer Extends, Consumer Super - 来自《Effective Java》

  • Producer Extends:如果只读取数据,使用 ? extends T
  • Consumer Super: 如果只写入数据,使用 ? super T

但在 Java 中,并不是只读,只写。List<? extends Number> objs可以 add(null)removeList<? super Number> objs 可以 get

在 Kotlin 中,弥补了这一点,是真正的只读,只写。

生产者-消费者模型:

interface Producer<out T> { 
    fun produce(): T 
} 

interface Consumer<in T> { 
    fun consume(t: T) 
}
  • 生产者:Producer 为类型 T 的生产者,T 用于返回值类型。使用协变(? extends T or out T)情况下,只读取数据,不会破坏类型安全。
  • 消费者:Consumer 为类型 T 的消费者,T 用于输入参数类型。使用逆变(? super T or in T)情况下,只写入数据,不会破坏类型安全。

文档信息

Search

    Table of Contents