Xudong's Blog

Java中的协变与逆变

Word count: 1.9kReading time: 7 min
2019/12/10 Share

逆变Contravariance与协变Covariance

先用一张图解释下

Contravariance&Covariance

Java数组是协变的

1
2
3
4
Number[] numbers = new Number[3];
numbers[0] = new Integer(10);
numbers[1] = new Double(3.14);
numbers[2] = new Long(99L);

包装类Integer Double LongNumber的子类,numbers中的元素的类型可以是任何Number的子类。我们称Java数组是协变的 (Covariant)

不仅如此,下面的代码也是合法的:

1
2
3
Integer[] IntArray = {1,2,3,4};
Number[] NumberArray = IntArray;
Number n = NumberArray[0]; //从一个协变结构中读取元素

因为Integer[]Number[]的子类,所以“父类的引用可以指向子类”。

但是这会导致一个有趣的问题:

1
2
3
4
Integer[] IntArray = {1,2,3,4};
Number[] NumberArray = IntArray;
NumberArray[0] = 9;
NumberArray[0] = 3.14; //尝试污染一个Integer数组 runtime error

在编译时,上面的代码不会报错,但是运行时第4行代码会抛出ArrayStoreException。很明显,即使通过一个Number[]引用,你也不可能把一个浮点数放入一个事实上的Integer[]数组。可以骗过编译器的代码,在运行时类型系统下并不能正确通过。我们甚至可以向逆变数组总加入元素,因为Java的运行时系统知道这个数组的真实类型,所以第3行代码在添加元素时,是可以正常执行的。

Java泛型中的类型擦除

因为JDK1.5中才引入泛型机制,为了兼容旧的字节码,Java规范在编译时对泛型进行了类型擦除。也就是说我们使用的所有泛型仅仅存在于编译期间,当通过编译器检查后,泛型信息都会被删除。在运行时,JVM处理的都是没有携带泛型信息的类型。因此我们有下面的问题:

1
2
3
4
5
List<Integer> IntList = new ArrayList<Integer>();
IntList.add(1);
IntList.add(2);
List<Number> NumList = IntList; //compiler error
NumList.add(3.14);

如果第4行,编译器没有报错。顺其自然,第5行也会通过编译。那么到了运行时,由于泛型信息被删除了,第5行的代码也会被正常执行,一个本应该是储存IntegerList,却被添加了一个浮点数。为了避免类型系统被破坏,编译器必须阻止我们进行第4行这样的操作。在Java泛型中,List<Integer>不是List<Number>的子类

泛型擦除的机制消弱了多态的使用。如果我们有下面这个函数:

1
2
3
4
5
6
7
static long sum(Number[] numbers) {
long summation = 0;
for(Number number : numbers) {
summation += number.longValue();
}
return summation;
}

我们可以很自然的以下面的方式使用这个函数:

1
2
3
4
5
6
Integer[] IntArray = {1,2,3,4,5};
Long[] LongArray = {1L, 2L, 3L, 4L, 5L};
Double[] DoubleArray = {1.0, 2.0, 3.0, 4.0, 5.0};
System.out.println(sum(IntArray));
System.out.println(sum(LongArray));
System.out.println(sum(DoubleArray));

因为数组是协变的,所以把子类型实参传入副类型形参是完全没问题的。多态很正确的被运用了。

但是,如果我们的函数使用了泛型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static long sum(List<Number> numbers) {
long summation = 0;
for(Number number : numbers) {
summation += number.longValue();
}
return summation;
}

public static void Main(String[] args) {
List<Integer> IntArray = asList(1,2,3,4,5);
List<Long> LongArray = asList(1L, 2L, 3L, 4L, 5L);
List<Double> DoubleArray = asList(1.0, 2.0, 3.0, 4.0, 5.0);
System.out.println(sum(IntArray)); //compiler error
System.out.println(sum(LongArray)); //compiler error
System.out.println(sum(DoubleArray)); //compiler error
}

因为泛型擦除可能导致的List被污染的原因,我们不能把List<Integer>当成List<Number>的子类型,所以把Java的泛型称作是不可变的 (invariant)

很显然,这是一个问题,我们需要通过某种机制,让泛型也可以用在多态中。

泛型中的协变 Covariance

使用通配符? extends T,其中T是一个基类型,或者说父类。然后我们就可以实现如下代码:

1
2
3
List<? extends Number> NumList = new ArrayList<Integer>();
List<? extends Number> NumList = new ArrayList<Float>();
List<? extends Number> NumList = new ArrayList<Double>();

并且,我们可以从NumList中读取元素:

1
Number n = NumList.get(0);

因为我们可以确定,不管从NumList中拿到什么元素,都一定是Number的子类(泛型通配符? extends Number所规定的),所以父类Number的引用一定可以指向这个子类元素。

上面的出现的函数可以改写为:

1
static long sum(List<? extends Number> numbers) {...}

但是,我们不能向一个协变泛型的结构中加入任何元素。

1
NumList.add(45L); //compiler error

因为运行时的泛型擦除,而编译时的信息不足够确定NumList的真实类型,编译器并不知道添加什么类型的元素才是合法的。

所以如果一个容器是只读的,才能协变。不然很容易就能把一些特殊的容器协变到更一般的容器,再往里面添加进不应该储存的类型。协变结构可读,不可写

逆变 Contravariance

使用通配符? super T,其中T是一个基类型,或者说父类,我们可以向逆变结构中添加任何T的子类。逆变结构可写,不可读。

1
2
3
4
5
List<? super Number> NumList = new ArrayList<Object>();
NumList.add(new Integer(1));
NumList.add(new Double(3.14));
NumList.add(new Object()); // compiler error
Number n = NumList.get(0); // compiler error

一个储存ObjectList,自然可以放入Number类型的元素。逻辑上的List<Object>应该是List<Number>的父类,但父类被反过来赋值给子类的引用,所以称作逆变。

但是由于规定了 ? super Number的限制,这表明我们需要在容器中储存Number及其子类型,在第4行我们不能再向这个List中添加Object类型的元素。

从逆变结构中读取元素是不被允许的。在第5行,我们想要得到一个Number,但是这不会被编译器允许,因为不能保证这个List中均是Number类型的元素。另外说明一下,Object n = NumList.get(0)是允许的,但是这和原本的泛型类型<Object>没有任何关系,因为Object在Java中是所有类的父类,所以Java允许我们把任何类型当作Object对象,造成了这种可以从逆变容器中取出元素的特殊情况。

PECS原则

Producer Extends Consumer Super Principle

  • 当我们想从一个泛型结构中读取元素时(生产者协变),需要使用? extends T
  • 当我们想向一个泛型结构中写入元素时(消费者逆变),需要使用? super T
1
2
3
4
5
public static void copy(List<? extends Number> source, List<? super Number> destination) {
for(Number number : source) {
destination.add(number);
}
}
1
2
3
4
5
List<Integer> IntList = Arrays.asList(1,2,3,4); //Producer
List<Double> DoubleList = Arrays.asList(3.14, 6.28); //Producer
List<Object> ObjList = new ArrayList<Object>(); //Consumer
copy(IntList, ObjList);
copy(DoubleList, ObjList);

在泛型中使用逆变和协变,可以很好的克服泛型擦除带来的负面影响,让泛型和多态可以快乐地一起玩耍。

总结

当我们已经知道类型之间的继承关系,比如Number 类型是Integer类型的父类。我们就可以有以下结论:

  • 协变(covariance):List<Number>List<Integer>的父类,它们维持内部参数的关系不变。
  • 逆变(contravariance):List<Number>List<Integer>的子类,它们的关系被反转了。
  • 不变(invariance):两者没有任何子类型关系,不能互相替代。
CATALOG
  1. 1. 逆变Contravariance与协变Covariance
  2. 2. Java数组是协变的
  3. 3. Java泛型中的类型擦除
  4. 4. 泛型中的协变 Covariance
  5. 5. 逆变 Contravariance
  6. 6. PECS原则
  7. 7. 总结