Java泛型(三):协变、通配符

本篇文章将会着重介绍Java泛型的协变性及通配符,以使得我们更好的更方便的来使用它

abstract.jpeg

Covariance 协变

概念

在计算机科学中,具有继承关系的一对类型A、B,其中A≤B(即A继承于B或者说A是B的子类型),在类型构造器的映射下,分别变为类型A2、B2

  • 若A2≤B2,即A2继承于B2或者说A2是B2的子类型,则称该类型构造器是协变
  • 若A2≥B2,即B2继承于A2或者说B2是A2的子类型,则称该类型构造器是逆变
  • 若上述两种均不适用,则称该类型构造器是不变

数组的协变性

这里我们先定义3个普通的POJO类,其中继承关系: BlackCat≤Cat≤Animal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* 动物类
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Animal {
private String type;
private String name;
public String toString() {
String str = "(type="+getType()+",name="+getName()+")";
return str;
}
}
...
/**
* 猫类
*/
@Data
public class Cat extends Animal {
public Cat(String type, String name) {
super(type, name);
}
public String toString() {
String str = "(type="+getType()+",name="+getName()+")";
return str;
}
}
...
/**
* 黑猫类
*/
@Data
public class BlackCat extends Cat {
public BlackCat(String type, String name) {
super(type, name);
}

public String toString() {
String str = "(type="+getType()+",name="+getName()+")";
return str;
}
}

然后基于此,我们来通过下面测试方法来验证Java的数组是支持协变的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 数组的协变性测试
*/
public static void testArray() {
// 由于数组支持协变,故可以将Cat数组实例赋给Animal数组变量
Animal[] animalArray = new Cat[3];
System.out.println("Java 数组支持协变");

Animal animal1 = new Animal("Animal","动物");
Cat cat1 = new Cat("Cat", "猫");
BlackCat blackCat1 = new BlackCat("BlackCat","黑猫");

animalArray[1] = cat1;
animalArray[2] = blackCat1;
System.out.println( Arrays.toString(animalArray) );

// 编译虽然可以通过
// 但由于该数组的实际类型依然是Cat,故运行期会抛出异常
animalArray[0] = animal1;
System.out.println( Arrays.toString(animalArray) );
}

从其测试结果可以证明Java的数组是支持协变的,但由于animalArray数组的实际类型仍然是Cat类型,所以当我们将一个Animal类型的animal1赋值给该数组时,即会出现运行时异常。故Java中数组虽然支持协变,但却是不安全的

figure 1.jpeg

泛型的协变性

而对于泛型而言,其是不支持协变的。同样地,我们来实际验证测试下泛型的协变性

1
2
3
4
5
6
7
8
/**
* 泛型的协变性测试
*/
public static void testGeneric() {
LinkedList<BlackCat> blackCatList = new LinkedList<>();
// 由于泛型不支持协变,故会导致编译失败
LinkedList<Animal> animalList = blackCatList;
}

可以看到,由于泛型不支持协变,故直接编译报错失败

figure 2.jpeg

通配符

上面通过实际的测试代码,我们验证明确了Java的泛型是不支持协变的,而泛型的通配符就是为了解决泛型不支持协变而引入的

上界通配符 extends

上界通配符使用 ? extends [TypeName] 表示,表明其可接收泛型类型是指定的类型及其子类型。这里我们通过一个例子来演示其用法与作用,如下所示。我们利用上界通配符定义了list1变量,其接收LinkedList的类型上界为Animal,所以catList可以被正确地赋值给它;变量list2同理。但是对于list1、list2而言,我们只能对其进行只读操作。以list1变量为例进行说明,list1变量只知道其中的元素是Animal的子类但不知道具体的类型信息,故可以向上转型为Animal来读取。但是不可以向list1中添加元素等写操作。道理也很简单,list1不知道具体的元素类型,添加任意元素到list1中都是类型不安全的,这里list1中元素的实际类型为Cat,如果我们添加一个Animal类型的元素到其中,显然是错误的。故对于使用了上界通配符的变量来说,其只支持只读操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 通配符 extends 测试
*/
public static void testWildcard1() {
LinkedList<Cat> catList = new LinkedList<>();
Cat cat1 = new Cat("Cat", "猫1");
Cat cat2 = new Cat("Cat", "猫2");
Cat cat3 = new Cat("Cat", "猫3");
catList.add(cat1);
catList.add(cat2);
catList.add(cat3);

System.out.println("------ Demo1 ------");
// 该list1可以接收一个泛型类型为Animal及其子类的LinkedList
LinkedList<? extends Animal> list1 = catList;
list1.forEach(System.out::println);

System.out.println("------ Demo2 ------");
// 该list1可以接收一个泛型类型为Cat及其子类的LinkedList
LinkedList<? extends Cat> list2 = catList;
list2.forEach(System.out::println);
}

测试结果如下

figure 3.jpeg

下界通配符 super

下界通配符使用 ? super [TypeName] 表示,表明其可接收泛型类型是指定的类型及其父类型。这里我们通过一个例子来演示其用法与作用,如下所示。我们利用下界通配符定义了list1变量,其接收LinkedList的类型下界为BlackCat,而catList类型为Cat是BlackCat父类,故可以被正确地赋值给它;变量list2同理,其可以接收一个泛型类型为Cat及其父类的List,而如果该List的范型类型为BlackCat,则显然不可接收。这就体现了super通配符的下界作用

对于list1、list2而言,我们只能对其进行只写操作。以list1变量为例进行说明,因为我们知道list1所接收列表的范型类型,必然是[TypeName]及其父类型,这一点是由通配符super的下界来保证的。但是容易引起误解混淆的地方就在于,当我们向list1添加元素时,却只能添加[TypeName]及其子类型,即BlackCat及其子类的实例。咦?这里是不是感觉怪怪的?前面我们看到不是说接收类型只能是[TypeName]及其父类型么?怎么现在正好反过来了?请大家注意,前面我们说的是接收赋值操作,而现在我们说的是添加写入操作。需要注意进行区分!当向list1写入添加 BlackCat及其子类的实例 元素时,其必然可以向上转型为BlackCat类型,故可以成功进行添加。所以综上所述,对于下届通配符super而言,其限定类型[TypeName]在接收赋值时是类型的下界,而在写入添加元素时却是元素类型的上界。这样即可保证 添加时的元素类型 一定是 实际接收的列表的范型类型 的子类型,通过向上转型实现写入添加操作

而如果我们从list1进行读操作,由于无法确定元素的实际类型,故即使读出来了也只能由Object类型来接收。故对于使用了下界通配符的变量来说,其只支持只写操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* 通配符 super 测试
*/
public static void testWildcard2() {
LinkedList<Cat> catList = new LinkedList<>();
Cat cat1 = new Cat("Cat", "猫1");
Cat cat2 = new Cat("Cat", "猫2");
Cat cat3 = new Cat("Cat", "猫3");
catList.add(cat1);
catList.add(cat2);
catList.add(cat3);

System.out.println("------ Demo1 ------");
// 该list1可以 接收 一个泛型类型为BlackCat及其父类的List
LinkedList<? super BlackCat> list1 = catList;
// 可以向list1中 添加 类型为BlackCat及其子类的元素
BlackCat blackCat4 = new BlackCat("BlackCat", "黑猫4");
list1.add(blackCat4);
catList.forEach(System.out::println);

System.out.println("------ Demo2 ------");
// 该list2可以 接收 一个泛型类型为Cat及其父类的List
LinkedList<? super Cat> list2 = catList;
// LinkedList<? super Cat> list2 = new LinkedList<BlackCat>(); // BlackCat不是Cat的子类,故不可接收。因为super限定了接受范型类型的下界
// 可以向list2中 添加 类型为Cat及其子类的元素
BlackCat blackCat5 = new BlackCat("BlackCat", "黑猫5");
Cat cat6 = new Cat("Cat", "猫6");
list2.add(blackCat5);
list2.add(cat6);
catList.forEach(System.out::println);

System.out.println("------ Demo3 ------");
// 读操作,无法确定类型,只能由Object类型来接收
Object o = list2.get(0);
System.out.println("o: " + o);
}

测试结果如下

figure 4.jpeg

无界通配符 ?

无界通配符使用 ? 表示,表明其可接收任意的泛型类型。这里我们通过一个例子来演示其用法与作用。比如我们期望提供一个打印List的方法printList。为使得该方法具备一定的通用性,故我们使用无界限定符。使得该方法的list形参可以接收任何泛型类型的List。显然使用无界通配符后,我们无法知道该list中元素的实际类型,故我们只能对该list进行只读操作然后向上转型为Object类型进行处理,而不能进行写操作。聪明的朋友可能已经发现如果我们将无界通配符直接去掉,将形参list的类型由List<?>改为List同样可以满足需求啊?的确如此,但是使用无界通配符后可以保证如果我们不小心意外地修改了该list会出现编译期错误,以提醒我们及时修复提供了程序的健壮性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 通配符 ? 测试
*/
public static void testWildcard3() {

System.out.println("------ Demo1 ------");
List<Integer> list1 = new LinkedList<>();
Collections.addAll(list1, 1,2,3,4);
printList(list1);

System.out.println("------ Demo2 ------");
List<Double> list2 = new LinkedList<>();
Collections.addAll(list2, 1.1,2.2,3.3);
printList(list2);
}

/**
* 打印列表
* @param list
*/
private static void printList(List<?> list) {
for (Object e : list) {
System.out.println(e);
}
}

测试结果如下

figure 5.png

Note

  • 上界通配符、无界通配符只能读不能写,但是有一点例外,就是可以写入null
  • 下界通配符只能写不能读,但是有一点例外,就是可以以Object类型读

PECS(Producer Extends, Consumer Super) 原则

PECS原则描述的是如何正确合理地应用泛型的上界通配符extends和下界通配符super。对于一个泛型容器、集合而言,如果该容器、集合的角色是作为一个Producer生产者,用来对外提供元素(即读操作)则应使用extends通配符;反之,如果该容器、集合的角色是作为一个Consumer消费者,用于接收外界提供的元素则应使用super通配符

在Java的集合API中大量应用了PECS原则。例如LinkedList的addAll方法作用是将集合c中的元素全部取出添加到主调者中,故集合c的角色是一个生产者,应使用extends上界通配符;而Collections的addAll方法则是将数组elements中的元素添加到集合c中,故集合c的角色是一个消费者,应使用super下界通配符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class LinkedList<E> {
public boolean addAll(Collection<? extends E> c) {
return addAll(size, c);
}
...
}
...
public class Collections {
public static <T> boolean addAll(Collection<? super T> c, T... elements) {
boolean result = false;
for (T element : elements)
result |= c.add(element);
return result;
}
...
}

下面即是上述规则的测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void testPECS() {

List<Integer> list1 = new LinkedList<>();
list1.add(1);
list1.add(2);
// list1 作为生产者提供元素,应用Producer Extends 原则
List<Integer> list2 = new LinkedList<>();
list2.addAll(list1);
System.out.println("list2: " + list2);

List<Integer> list3 = new LinkedList<>();
// list3 作为消费者接收元素, 应用 Consumer Super 原则
Collections.addAll(list3, 3,4);
System.out.println( "list3: " + list3);

}

测试结果如下

figure 6.png

参考文献

  1. Java核心技术·卷I 凯.S.霍斯特曼著
0%