Think in Java 回顾之泛型

想去海边玩沙子

什么是泛型?

Java SE 5 开始引入了泛型的概念,泛型即参数化类型,利用泛型我们可以编写出更通用的代码(先不指定类型,使用时再指定类型)。泛型出现的最大的目的之一就是用来指定容器要持有的对象的类型,而且这种指定是由编译器来保证其正确性的。来看个例子:

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
class Holder<T> {
private T value;

public Holder() {
}

public Holder(T value) {
this.value = value;
}

public T get() {
return value;
}

public void set(T value) {
this.value = value;
}

public static void main(String[] args) {
// 可以不指定类型,类型参数 T 就是 Object,因此会被初始化为 null
Holder holder = new Holder();
System.out.println(holder.get());

Holder<String> strHolder = new Holder<>("Aaron");
System.out.println(strHolder.get());

strHolder.set(1); // Error
}
}

以上代码中定义了一个 Holder 类,使用类型参数 T 作为持有的对象的类型。T 可以表示任何对象,所以Holder 类也就具有了持有任何对象的能力。当我们使用它的时候,可以使用 <> 来确定 Holder 所持有的对象的类型,这样我们就可以保证持有对象的类型的正确性。

泛型方法

如果普通方法中定义了泛型参数,那么这就是一个泛型方法。泛型方法和与该类是否是泛型类无关,但是如果静态方法想要使用泛型参数,那么它就必须定义为泛型方法,因为静态方法无法访问泛型类中的泛型参数。

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
44
45
46
47
48
49
50
public class DemoGenericMethod<E> {

private E e;

public DemoGenericMethod() {
e = (E) new Object();
}

public DemoGenericMethod(E e) {
this.e = e;
}

/**
* 根据泛型类的类型变量返回相应类型的 List
*/
public List<E> getAsList(E e) {
System.out.println(e.getClass().getName());
return new ArrayList<>();
}

/**
* 普通泛型方法
*/
public <T> T printClassName(T t) {
System.out.println(t.getClass().getName());
return t;
}

public <T> void printSelfAndThis(T t) {
System.out.println("this = " + e.getClass().getName()
+ ", that = " + t.getClass().getName());
}

/**
* 静态方法一旦使用了泛型参数就必须定义为泛型方法。
* 因为静态方法是独立于类之外的,无法访问类中的泛型参数
*/
public static <T> void getName(T t) {
System.out.println(t.getClass().getName());
}

/**
* 可变参数列表与泛型方法
*/
public static <T> List<T> makeList(T... args) {
List<T> result = new ArrayList<>();
Collections.addAll(result, args);
return result;
}
}

擦除

在使用泛型时,你是无法通过代码获得任何有关泛型参数类型的信息的,这其实是因为擦除的存在。在泛型类或泛型方法中,关于泛型参数的任何具体的类型信息都被擦除了(wiped),所以我们只能把类型参数当作一个 Object 使用。

1
2
3
4
5
6
// ArrayList<String> 被擦除为 ArrayList
Class c1 = new ArrayList<String>().getClass();
// ArrayList<Integer> 被擦除为 ArrayList
Class c2 = new ArrayList<Integer>().getClass();
// 这两个 Class 对象都被擦除为 ArrayList了,所以是相等的
System.out.println("c1 == c2? " + (c1 == c2)); // true

擦除意味着无法使用 instanceofnew 或者转型等需要在运行时才能知道确切类型信息的操作。

边界

边界允许我们在参数类型上设置限制条件,这样就能部分抵消擦除带来的负面影响。来看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
interface HasColor {
Color getColor();
}

/**
* 用 extend 关键字指定类型参数的边界
*/
class Colored<T extends HasColor> {
T element;

Colored(T element) {
this.element = element;
}

T getElement() {
return element;
}

Color color() {
// 因为设置了边界,所以调用是安全的
return element.getColor();
}
}

可以看到,我们用 extend 关键字指定泛型边界。当参数类型继承多个边界时,定义的规则与类的继承相同,类在前,接口在后,类与多个接口的连接符用 &,比如:

1
class Solid<T extends Dimension & HasColor & Weight> {...}

通配符

通配符可以保证类型安全,允许我们更加自由地使用泛型类,使得泛型支持协变和逆变。

  1. 首先是通配符 + extends 关键字,用于确定泛型类的上边界。
1
2
// 该 List 的泛型类型是 ? extends List,表示"任何从 Number 类继承的类型"
List<? extends Number> numbers = new ArrayList<Integer>();

但是此时往 numbers 中添加任何元素都是不被允许的,因为只声明了上边界,编译器是无法确定捕获 (capture) 的类型到底是什么,所以无论添加任何对象都是类型不安全的。

  1. 通配符 + super 关键字,即超类型通配符,用于确定泛型类的下边界。
1
2
// 表示边界范围是 "Number 类的任何父类",也就是说至少 Number 类及其子类是可以安全添加的
List<? super Number> numbers = new ArrayList<>();

使用超类型通配符后,由于下边界确定,所以当我们向 list 中添加 Number 类及其子类的时候,才可能保证是类型安全的。不过,如果添加的是 Number 类的超类,也是不被允许的,因为编译器无法确定捕获的超类型到底是哪个超类。

可能文字比较难以理解,来看几个例子,更为直观:

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public static void main(String[] args) {

// Incompatible types,泛型默认不支持协变(返回值的类型是接收处的子类)
// List<Number> numberList = new ArrayList<Integer>(); // Incompatible types

System.out.println("----------确定泛型的上边界----------");
// 用 extends 表示这个集合中的对象是“继承了 Number 类的子类”
// 这样就为集合确定了上边界(Number),所以协变是安全的
List<? extends Number> numbers = new ArrayList<Integer>();

/*
* 但是这样的List是非常有局限性的,无法添加任何有意义的元素
* 因为List的参数类型为 ? extends Number,也就是任何继承自 Number 类的对象
* 编译器无法确定 List 所持有的类型,这样就无法保证类型安全性,所以不允许添加任何有意义的对象
* */
//numbers.add(1);
//numbers.add(new Object()); // 甚至连 Object 都无法添加
// 可以添加 null 进去,因为 null 可以表示任何对象,这也说明了此时添加某个对象是不安全的
numbers.add(null);

// 可以从中取出元素,因为至少可以确定这是一个 Number 类的对象
Number number = numbers.get(0);
System.out.println(number);

// 最主要的用途是在泛型方法中使用,此时由于上边界确定,所以可以安全调用
List<Apple> appleList = new ArrayList<>();
appleList.add(new Apple("Apple"));
showList(appleList); // 只能对集合进行读取,而无法写入


System.out.println("----------确定泛型的下边界----------");
// 用 super 关键字表示这个集合中对对象是 "Number 类的父类"
// 也就是下边界确定(Number),所以可以添加 Number 及其子类
List<? super Number> boundedNums = new ArrayList<>();
boundedNums.add(1);
boundedNums.add(1.0f);
boundedNums.add(1.33d);
// 无法添加父类对象,因为无法确定捕获到的是哪个父类
// boundedNums.add(new Object());
System.out.println(boundedNums);

// 此时列表中的元素类型是捕获类型,因此只能被读取为 Object
// Number n1 = boundedNums.get(0);
Object num1 = boundedNums.get(0);

// 同时也支持逆变
Integer number1 = (Integer) boundedNums.get(0);


System.out.println("----------无界通配符----------");
List<?> list = new ArrayList<String>();
// 捕获的参数类型是 capture<?>,无法应用到 String
//list.add("1");
}

static void showList(Collection<? extends Fruit> fruits) {
for (Fruit fruit : fruits) {
fruit.sayName();
}
}

总结一下,上界通配符使得泛型类只能被读取而无法修改,因此这种泛型类型也被称为生产者;而下界通配符使得泛型类只能被修改而无法被读取,因此这种泛型类型就是消费者。

无界通配符

上面的例子中有关于无界通配符的使用,第一次看到会觉得似乎难以理解,其实它表示的意思是”我可以持有任何类型“,它是更为泛化的参数化类型,但也因此无法像有界的泛型参数那样做更多的事。它最主要的用途就是捕获转换,即捕获未指定的通配符类型,然后将之转换为确切的某种类型。

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
public class DemoCaptureConversion {

static <T> void captureWithType(Holder<T> holder) {
System.out.println(holder.get().getClass().getSimpleName());
}

/**
* 未指定的通配符类型会被捕获并转换为类型参数来使用
*/
static void captureWithWildcard(Holder<?> holder) {
// 通配符可以捕获到类型参数
captureWithType(holder);
}

@SuppressWarnings("unchecked")
public static void main(String[] args) {
Holder raw = new Holder(1);
captureWithType(raw);
captureWithWildcard(raw);

// 同样发生了类型参数的捕获
Holder<?> wildcarded = new Holder<>(1.2f);
captureWithType(wildcarded);
captureWithWildcard(wildcarded);
}
}

以上代码中,第一个方法中的参数是确切的已知的,而第二个方法中使用了无界通配符,参数是未知的。在调用第二个方法的时候,可以捕获到类型参数并进行调用。

泛型存在的问题

  1. 基本类型无法作为类型参数,必须使用其包装类。
  2. 不能同时实现同一个泛型接口的两种变体,原因是接口的参数类型会被擦除,也就相当于同一个接口被实现了两次。
1
2
3
4
5
6
interface Pay<T> {}

class Employ implements Pay<Emp> {}

// cannot be inherited with different type arguments
class Hour extends Employ implements Pay<Hour>{}
  1. 对泛型转型有时会产生”unchecked cast“的警告,因为编译期无法确定转型是否安全。
  2. 无法使用泛型参数作为区分两个方法,也就是无法用类型参数作为重载的依据。
  3. 基类劫持接口,最常见的例子就是实现 Comparable 接口。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Pet 类把自己作为参数类型传到 Comparable 接口中
class Pet implements Comparable<Pet> {

private String name;
private int age;

public Pet(String name, int age) {
this.name = name;
this.age = age;
}

@Override
public int compareTo(@NotNull Pet pet) {
return Integer.compare(this.age, pet.age);
}
}

结语

在 Think in Java 中,除了以上问题外,还详细讲解了自限定的类型、动态类型安全、泛型在异常中的使用、混型、潜在类型机制的缺失及补偿、将函数对象用作策略等,想要深入了解泛型的,不妨仔细阅读下这部分内容,如果有新的感受记得留言交流哦~


参考资料:

  • Think in Java 第4版