Java中的泛型

一、泛型的概念

泛型是 Java SE5 出现的新特性,泛型的本质是类型参数化或参数化类型,在不创建新的类型的情况下,通过泛型指定的不同类型来控制形参具体限制的类型。

二、泛型的意义

一般的类和方法,只能使用具体的类型:要么是基本类型,要么是自定义的类。如果要编写可以应用于多种类型的代码,这种刻板的限制对代码的束缚就会很大。

Java 在引入泛型之前,表示可变对象,通常使用 Object 来实现,但是在进行类型强制转换时存在安全风险。有了泛型后:

编译期间确定类型,保证类型安全,放的是什么,取的也是什么,不用担心抛出 ClassCastException 异常。

提升可读性,从编码阶段就显式地知道泛型集合、泛型方法等处理的对象类型是什么。

泛型合并了同类型的处理代码提高代码的重用率,增加程序的通用灵活性。

Java中 ? extends T 和 ? super T 的理解

Producer Extends Consumer Super

上界<? extends T>不能往里存,只能往外取

extend 为上界, super为下界

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class test {
public static void main(String[] args) {
List<? extends Father> list = new LinkedList<>();
list.add(new Son());
}
}
class Human{
}
class Father extends Human{
}
class Son extends Father{
}
class LeiFeng extends Father {
}

list.add(new Son());这行会报错:The method put(Son) is undefined for the type List<capture#1-of ? extends Father>

1
2
List<? extends Father> list = new LinkedList<Son>();
list.add(new Son());

即使你指明了为Son类型,也不能用add方法添加一个Son对象。

list中为什么不能加入Father类和Father类的子类呢,我们来分析下。

List<? extends Father>表示上限是Father,下面这样的赋值都是合法的

1
2
3
List<? extends Father> list1 = new ArrayList<Father>();
List<? extends Father> list2 = new ArrayList<Son>();
List<? extends Father> list3 = new ArrayList<LeiFeng>();

如果List<? extends Father>支持add方法的话:

  • list1可以add Father和所有Father的子类;
  • list2可以add Son和所有Son的子类;
  • list3可以add LeiFeng和所有LeiFeng的子类。

下面代码是编译不通过的:

1
2
list1.add(new Father());//error
list1.add(new Son());//error

原因是编译器只知道容器内是Father或者它的派生类,但具体是什么类型不知道。可能是Father?可能是Son?也可能是LeiFeng,XiaoMing?编译器在看到后面用Father赋值以后,集合里并没有限定参数类型是“Father“。而是标上一个占位符:CAP#1,来表示捕获一个Father或Father的子类,具体是什么类不知道,代号CAP#1。然后无论是想往里插入Son或者LeiFeng或者Father编译器都不知道能不能和这个CAP#1匹配,所以就都不允许。

所以通配符<?>和类型参数的区别就在于,对编译器来说所有的T都代表同一种类型。比如下面这个泛型方法里,三个T都指代同一个类型,要么都是String,要么都是Integer。

1
public <T> List<T> fill(T... t);

但通配符<?>没有这种约束,List<?>单纯的就表示:集合里放了一个东西,是什么我不知道。

所以这里的错误就在这里,List<? extends Father>里什么都放不进去。

List<? extends Father> list不能进行add,但是,这种形式还是很有用的,虽然不能使用add方法,但是可以在初始化的时候一个Season指定不同的类型。比如:

1
List<? extends Father> list1 = getFatherList();//getFatherList方法会返回一个Father的子类的list

另外,由于我们已经保证了List中保存的是Father类或者他的某一个子类,所以,可以用get方法直接获得值:

1
2
3
4
5
List<? extends Father> list1 = new ArrayList<>();
Father father = list1.get(0);//读取出来的东西只能存放在Father或它的基类里。
Object object = list1.get(0);//读取出来的东西只能存放在Father或它的基类里。
Human human = list1.get(0);//读取出来的东西只能存放在Father或它的基类里。
Son son = (Son)list1.get(0);

下界<? super T>不影响往里存,但往外取只能放在Object对象里

下界用super进行声明,表示参数化的类型可能是所指定的类型,或者是此类型的父类型,直至Object。

1
2
3
4
5
6
7
8
//super只能添加Father和Father的子类,不能添加Father的父类,读取出来的东西只能存放在Object类里
List<? super Father> list = new ArrayList<>();
list.add(new Father());
list.add(new Human());//compile error
list.add(new Son());
Father person1 = list.get(0);//compile error
Son son = list.get(0);//compile error
Object object1 = list.get(0);

因为下界规定了元素的最小粒度的下限,实际上是放松了容器元素的类型控制。既然元素是Father的基类,那往里存粒度比Father小的都可以。出于对类型安全的考虑,我们可以加入Father对象或者其任何子类(如Son)对象,但由于编译器并不知道List的内容究竟是Father的哪个超类,因此不允许加入特定的任何超类(如Human)。而当我们读取的时候,编译器在不知道是什么类型的情况下只能返回Object对象,因为Object是任何Java类的最终祖先类。但这样的话,元素的类型信息就全部丢失了。

PECS原则

最后看一下什么是PECS(Producer Extends Consumer Super)原则,已经很好理解了:

如果参数化类型表示一个生产者,就使用`<? extends T>;如果它表示一个消费者,就使用<? super T>

只读不可写时,使用List<? extends Fruit>:Producer
只写不可读时,使用List<? super Apple>:Consumer

总结

  • extends 可用于返回类型限定,不能用于参数类型限定(换句话说:? extends xxx 只能用于方法返回类型限定,jdk能够确定此类的最小继承边界为xxx,只要是这个类的父类都能接收,但是传入参数无法确定具体类型,只能接受null的传入)。
  • super 可用于参数类型限定,不能用于返回类型限定(换句话说:? supper xxx 只能用于方法传参,因为jdk能够确定传入为xxx的子类,返回只能用Object类接收)。
  • ? 既不能用于方法参数传入,也不能用于方法返回。

带有super超类型限定的通配符可以向泛型对象中写入,带有extends子类型限定的通配符可以向泛型对象读取。

什么是泛型擦除?

其实我们很常见这个问题,你甚至经常用,只是没有去注意罢了,但是很不碰巧这样的问题就容易被面试官抓住。下面先来看一段代码吧。

1
2
3
List list = new ArrayList();
List listString = new ArrayList<String>();
List listInteger = new ArrayList<Integer>();

这几段代码简单、粗暴、又带有很浓厚的熟悉感是吧。那我接下来要把一个数字1插入到这三段不一样的代码中了。

作为读者的你可能现在已经黑人问号了????你肯定有很多疑问,这明显不一样啊,怎么可能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Main {
public static void main(String[] args) {
List list = new ArrayList();
List listString = new ArrayList<String>();
List listInteger = new ArrayList<Integer>();

try {
list.getClass().getMethod("add", Object.class).invoke(list, 1);
listString.getClass().getMethod("add", Object.class).invoke(listString, 1);
// 给不服气的读者们的测试之处,你可以改成字符串来尝试。
listInteger.getClass().getMethod("add", Object.class).invoke(listInteger, 1);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("list size:" + list.size());
System.out.println("listString size:" + listString.size());
System.out.println("listInteger size:" + listInteger.size());
}
}

不好意思,有图有真相,我就是插进去了,要是你还不信,我还真没办法了。

探索真相

上述的就是泛型擦除的一种表现了,但是为了更好的理解,当然要更深入了是吧。虽然List很大,但却也不是不能看看。

两个关键点,来验证一下:

  1. 数据存储类型
  2. 数据获取
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 先来看看画了一个大饼的List
// 能够过很清楚的看到泛型E
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable{
// 第一个关键点
// 还没开始就出问题的存储类型
// 难道不应该也是一个泛型E?
transient Object[] elementData;


public E get(int index) {
rangeCheck(index);

return elementData(index); // 1---->
}

// 由1直接调用的函数
// 第二个关键点,强制转化得来的数据
E elementData(int index) {
return (E) elementData[index];
}
}

我想,其实你也能够懂了,这个所谓的泛型T最后会被转化为一个Object,最后又通过强制转化来进行一个转变。从这里我们也就能够知道为什么我们的数据从前面过来的时候,String类型数据能够直接被Integer进行接收了。

带来什么样的问题?

(1) 强制类型转化

这个问题的结果我们已经在上述文章中提及到了,通过反射的方式去进行插入的时候,我们的数据就会发生错误。

如果我们在一个List<Integer>中在不知情的情况下插入了一个String类型的数值,那这种重大错误,我们该找谁去说呢。

(2)引用传递问题

上面的问题中,我们已经说过了T将在后期被转义成Object,那我们对引用也进行一个转化,是否行得通呢?

1
2
List<String> listObject = new ArrayList<Object>();
List<Object> listObject = new ArrayList<String>();

如果你这样写,在我们的检查阶段,会报错。但是从逻辑意义上来说,其实你真的有错吗?

假设说我们的第一种方案是正确的,那么其实就是将一堆Object数据存入,然后再由上面所说的强制转化一般,转化成String类型,听起来完全ok,因为在List中本来存储数据的方式就是Object。但其实是会出现ClassCastException的问题,因为Object是万物的基类,但是强转是为子类向父类准备的措施。

再来假设说我们的第二种方案是正确的,这个时候,根据上方的数据String存入,但是有什么意义存在呢?最后都还是要成Object的,你还不如就直接是Object

解决方案

其实很简单,如果看过一些公开课想来就见过这样的用法。

1
2
3
4
5
6
7
8
9
10
11
12
public class Part<T extends Parent> {

private T val;

public T getVal() {
return val;
}

public void setVal(T val) {
this.val = val;
}
}

相比较于之前的Part而言,他多了<T extends Parent>的语句,其实这就是将基类重新规划的操作,就算被编译,虚拟机也会知道将数据转化为Parent而不是直接用Object来直接进行替代。

应用场景

这里需要感谢给我提出问题的大佬读者:挖掘机技术

该部分的思路来自于Java泛型中extends和super的区别?

上面我们说过了解决方案,使用<T extends Parent>。其实这只是一种方案,在不同的场景下,我们需要加入不同的使用方法。另外官方也是提倡使用这样的方法的,但是我们为了避免我们上述的错误,自然需要给出一些使用场景了。

基于的其实是两种场景,一个是扩展型super,一个是继承型extends。下面都用一个列表来举例子。

统一继承顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 承载者
class Plate<T>{
private T item;
public Plate(T t){item=t;}
public void set(T t){item=t;}
public T get(){return item;}
}

// Lev 1
class Food{}

// Lev 2
class Fruit extends Food{}
class Meat extends Food{}

//Lev 3
class Apple extends Fruit{}
class Banana extends Fruit{}
class Pork extends Meat{}
class Beef extends Meat{}

//Lev 4
class RedApple extends Apple{}
class GreenApple extends Apple{}

<T extends Parent>

继承型的用处是什么呢?

其实他期待的就是这整个列表的数据的基础都是来自我们的Parent,这样获取的数据全部人的父类其实都是来自于我们的Parent了,你可以叫这个列表为Parent家族。所以也可以说这是一个适合频繁读取的方案。

1
2
3
4
5
6
7
8
9
Plate<? extends Fruit> p1=new Plate<Apple>(new Apple());
Plate<? extends Fruit> p2=new Plate<Apple>(new Beef()); // 检查不通过

// 修改数据不通过
p1.set(new Banana());

// 数据获取一切正常
// 但是他只能精确到由我们定义的Fruit
Fruit result = p1.get();

<T super Parent>

扩展型的作用是什么呢?

你可以把它当成一种兼容工具,由super修饰,说明兼容这个类,通过这样的方式比较适用于去存放上面所说的Parent列表中的数据。这是一个适合频繁插入的方案。

1
2
3
4
5
6
7
// 填写Food的位置,级别一定要大于或等于Fruit
Plate<? super Fruit> p1=new Plate<Food>(new Apple());
// 和extends 不同可以进行存储
p1.set(new Banana());
// get方法
Banana result1 = p1.get(); // 会报错,一定要经过强制转化,因为返回的只是一个Object
Object result2 = p1.get(); // 返回一个Object数据我们已经属于快要丢失掉全部数据了,所以不适合读取

总结

PECS原则 是 针对list本身来说的
生产者要从list中取出来用

            fruit              apple

extends 左边类型 大于或等于 右边类型
Plate`<? extends Fruit> p1=new Plate(new Apple());
extends 利于取

                 fruit                       food

super 左边的类型 小于或等于 右边类型
Plate`<? super Fruit> p1=new Plate(new Apple());
super利于存

泛型的协变和逆变

从面向对象说起

Java作为一门面相对象的语言,当然是支持面相对象的三大基本特性的,反手就蹦出三个词:封装、继承、多态。

我们假设有三个类,动物、猫、狗。父类是动物Animal,有两个子类猫Cat和狗Dog。

那在Java中或其它任何支持面相对象的语言中,子类可以把引用赋值给父类。下面这段代码没有任何问题:

1
2
Animal animalOne = new Cat();
Animal animalTwo = new Dog();

理论上来说,一只猫是一只动物,一只狗也是一只动物,所以这完全是可以理解的。其实,这也是SOLID原则中的“里氏替换原则”的一种体现。

数组的协变

如果一只猫是一只动物,那一群猫是一群动物吗?一群狗是一群动物吗?Java数组认为是的。于是你可以这样写:

1
Animal[] animals = new Cat[2];

这看起来也没有什么问题。但既然都是一群动物了,我往这一群动物中添加一只猫、一只狗,它还是一群动物,这应该是合理的对吧?来看看这段代码:

1
2
3
4
5
Animal[] animals = new Cat[2];
animals[0] = new Cat();
// 下面这行代码会抛运行时异常
animals[1] = new Dog();
Animal animal = animal[0];

很好,编译没有任何问题。但是一运行,会抛出一个运行时异常:ArrayStoreException。这个异常头顶的注释已经写得很明显了,如果你往数组中添加一个类型不对的对象,就会抛这个异常。它是从JDK 1.0就存在的一个异常。

这么一想,对啊,animals虽然门面上是一个Animal数组,但是它运行时的本质还是一个Cat数组啊,一个Cat数组怎么能添加一个Dog呢?但Java编译器并没有这么智能,而且上述代码在编

译器看来也是合理合法的,所以也就让它编译过了。

所以这种情况,编译器100%过,而运行时100%抛异常,这不是大写的BUG是啥?

如果Cat是Animal的子类型,那么Cat[]也是Animal[]的子类型,我们称这种性质为协变(covariance)。Java中,数组是协变的

泛型的不变性

在Java 1.5之前,是没有泛型的。那个时候从集合中存取对象都是Object类型,所以每次取出对象后必须进行强转:

1
2
3
4
5
6
7
List list = new LinkedList();
list.add(123);
list.add("123");

int a = (int)list.get(0);
// 下面这段代码会在运行时抛异常
int b = (int)list.get(1);

如果不小心存入集合中对象类型是错的,会在运行时报强转异常。而1.5提供泛型以后,可以让编译器自动帮助转换,并对代码进行检查,使程序更加安全。

在Java8又加入了泛型的类型推导功能,使用泛型以后,我们的代码看起来变得简洁又安全了:

1
2
3
4
5
6
List<Integer> list = new LinkedList<>();
list.add(123);
// 下面这局代码编译节点会报错
list.add("123");

int a = list.get(0);

《Effective Java》中,第28条(第三版)说,列表优先于数组。Java在使用列表+泛型时,吸取了上面数组的教训。前面提到,Java中数组是协变的,所以会有些问题。而Java中的泛型是不变(invariance)的,也就是说,List<Cat>并不是List<Animal>的子类型。所以像下面这样写,编译器会直接报错。

1
2
3
List<Cat> cats = new LinkedList<>();
// 编译器报错
List<Animal> animals = cats;

这样就可以在编译期对代码进行检查,防止它在运行期才发现错误抛异常。

不变不能解决所有问题

泛型是不变的,所以我们使用泛型的时候,能够更加安全。

但是在使用一门面向对象的语言中,我们难免会有需要集合也支持一些面向对象的特性的场景。我们可以简单地把它们分成生产场景和消费场景

消费场景的协变

比如,我希望有一个Animal的集合,我不用去管它里面存的具体类型是什么,但我每次从这个集合取出来的,一定是一个Animal或其子类。这是一种典型的消费场景,从集合中取出元素来消费。

在消费场景,Java提供了通配符和extends关键字来支持泛型的协变。来看看这段代码:

1
2
3
4
5
6
7
8
9
10
11
List<? extends Animal> animals = new LinkedList<Cat>();
// 以下四行代码都不能编译通过
// animals.add(new Dog());
// animals.add(new Cat());
// animals.add(new Animal());
// animals.add(new Object());
// 可以添加null,但没意义
animals.add(null);
// 可以安全地取出来
Animal animal = animals.get(0);

也就是说,虽然因为泛型的不变性,List<Cat>并不是List<Animal>的子类型,但Java通过其它方式来支持了泛型的协变,List<Cat>List<? extends Animal>的子类型。与此同时,Java在编译器层面通过禁止写入的方式,保证了协变下的安全性

为什么协变下不能写入呢?因为协变下写入是不安全的,想想文章最开头那个数组的协变的例子。

生产场景的逆变

我们希望有一个集合,可以往里面写入Animal及其子类。那可以通过super关键字来定义泛型集合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 下面这行代码编译不通过
// List<? super Animal> animals = new LinkedList<Cat>();
// 下面都是OK的写法
// List<? super Animal> animals = new LinkedList<Object>();
// List<? super Animal> animals = new LinkedList<Animal>();
// 等价于上面一行的写法
List<? super Animal> animals = new LinkedList<>();
animals.add(new Cat());
animals.add(new Dog());
// 取出来一定是Object
Object object = animals.get(0);

// 这样写是OK的
List<? super Cat> cats = new LinkedList<Animal>();

逆变(contravariance),也称逆协变,从名字可以看出来,它与协变的性质是相反的。也就是说,List<Animal>List<? super Cat>的子类型。

上界和下界

我们会在很多资料里看到对Java中泛型extends和super关键字的解读,说extends决定了上界,super决定了下界。

为什么这么说呢?其实看完上面两个小节,你会明白,这里的上界和下界,其实本质上指的是,在定义泛型的时候,子类型的边界。换句话说,在运行时真正的类型

我们用X来指代类型,看看下面两行代码:

1
2
3
4
// X可以是Animal及其子类,Animal是X的上界
List<? extends Animal> animals = new LinkedList<X>();
// X可以是Cat及其父类,Cat是X的下界
List<? super Cat> cats = new LinkedList<X>();

任意类型通配符

在Java代码中,你可能还看到这种写法:<?>,它代表任意类型通配符。老规矩,直接上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
List<?> anyOne = new LinkedList<Animal>();
List<?> anyTwo = new LinkedList<Cat>();
List<?> anyThree = new LinkedList<Object>();
// anyFour等价于anyThree的写法
List<?> anyFour = new LinkedList<>();
// 这种写法编译不通过
// List<?> anyFive = new LinkedList<?>();

// 具有extends和super的性质
// 这种写法编译不通过
// anyOne.add(new Cat());
// anyOne.add(new Object());
// 能取出来Object类型
Object o = anyOne.get(0);

也就是说,它是“无界”的,对于任意类型XList<X>都是List<?>的子类型。但List<?>不能add,get出来也是Object类型。它同时具有协变和逆变的两种性质,上界是Object,但不能调用add方法。

那它与List<Object>有什么区别呢?根据前面的推断,有两个比较明显的区别:

  • List<Object>可以调用add方法,但List<?>不能。
  • List<?>可以协变,上界是Object,但List<Object>不能协变。

Collection源码解读

看到这里你可能还有一些疑惑,什么时候应该用泛型的协变、逆变呢?我们来看看Collection接口的几个方法签名(JDK 1.8版本)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
boolean add(E e);
boolean addAll(Collection<? extends E> c);
boolean contains(Object o);
boolean containsAll(Collection<?> c);

default boolean removeIf(Predicate<? super E> filter) {
Objects.requireNonNull(filter);
boolean removed = false;
final Iterator<E> each = iterator();
while (each.hasNext()) {
if (filter.test(each.next())) {
each.remove();
removed = true;
}
}
return removed;
}

add和addAll

首先我们来看add和addAll方法。下面这段代码:

1
2
3
4
5
6
7
8
9
Collection<Animal> animals = new LinkedList<>();
animals.add(new Cat());
animals.add(new Animal());

Collection<Cat> cats = new LinkedList<>();
Collection<Object> objects = new LinkedList<>();
animals.addAll(cats);
// 以下代码编译不通过,因为不安全
animals.addAll(objects);

为什么这段代码可以编译通过且运行时安全?对于animals,它的泛型是<Animal>,根据里氏替换原则,add方法可以添加Animal及其子类对象。

而对于addAll方法来说,因为方法参数声明的是<? extends E>,而这里的E是我们声明Collection用的泛型Animal,所以其实addAll的方法参数类型是Collection<? extends Animal>

结合前文我们知道,这里应用了协变的特性,Collection<Cat>在参数传递的时候被转换成了Collection<? extends Animal>

而我们看源码可以发现,这里的参数传进来之后,是只读的,也就是只有消费场景,所以可以使用协变。而如果是allAll(Collection<E> c)这种方法参数的话,就不能支持上述代码,往其中添加一个cats了。

contains和containsAll

contains方法没有使用泛型,而是直接使用了一个Object对象,它可以在任何时候调用。那为什么contains方法不像add方法一样,使用泛型,是contains(T t)呢?

因为如果这样定义了的话,contains方法也会像add方法一样,受到协变的限制,声明为Collection<? extends Animal>的对象就不能使用contains方法了。尽管我们确信在contains方法内部并不会修改List中的对象(因此不会有类型安全的问题)。在Java中我们没有办法解决这个问题,因此,只能写成contains(Object o)

对于containsAll方法,先看看这段代码:

1
2
3
4
5
6
Collection<Animal> animals = new LinkedList<>();
Collection<Cat> cats = new LinkedList<>();
Collection<Object> objects = new LinkedList<>();

animals.containsAll(cats);
animals.containsAll(objects);

为什么containsAll的方法参数是Collection<?> c呢?

首先,不能用Collection<Object> c,因为这样的话,就不能协变了,上述代码animals.containsAll(cats)就会编译不通过,尽管我们知道这段代码是安全的。

然后,为什么不能像allAll方法那样,用协变Collection<? extends E> c呢?因为我们知道,containsAll方法对Collection没有副作用,而addAll有。所以我们不能animals.addAll(objects),但可以animals.containsAll(objects)

最后,为什么又不能用逆变Collection<? super E> c呢?因为这样的话,就不能让animals.containsAll(cats)编译通过了。

所以只能选择Collection<?> c。它是无界的,且具有协变性质,且取出来是Object对象,刚好内部实现也是循环去调用contains方法,与contains方法的参数类型Object一致。

同理,remove和removeAll和这两个方法是类似的写法,这里就不过多描述了。

removeIf

这个方法的参数是一个Predicate。用过Java 8的都知道,这是一个函数式接口。在这里使用了逆变,Predicate<? super E> filter定义了filter的下界。对于Predicate来说,这里是一个生产场景,所以应该使用逆变。

这里为什么要用逆变其实也很简单,因为在调用removeIf的时候,我们只能保证animals里面的元素是Animal,但我们并不知道具体的子类型。所以下面这种代码是不安全的,

1
2
3
4
Collection<Animal> animals = new LinkedList<>();
Predicate<Cat> catPredicate = cat -> true;
// 因为removeIf逆变的限制,所以下面这行代码编译不通过
animals.removeIf(catPredicate);

对我们日常工作有什么用?

看到这里,可能有的朋友已经开始吐槽了,我有必要了解这些吗?面试造火箭,工作拧螺丝?

其实不然,泛型是Java乃至很多面向对象语言的一种最基本的语言特性,所以知道它为什么这么设计是非常重要的。平时我们看源码的时候,看到这样的代码才会心中有数。

另一方面,随着编程水平的提高,难免有一些比较复杂的代码设计,或多或少会使用到泛型。合理地使用泛型、结合泛型的协变和逆变的特性能够让我们的代码变得更安全,比如上面Collection中用到的Predicate,就用了逆变的性质。

简单总结一下,Java的数组是协变的,泛型是不变的。但泛型可以通过extends关键字实现协变,通过super关键字实现逆变,分别应用于不同的场景。协变应用于消费场景,定义了上界。逆变应用于生产场景,定义了下界。