Java 泛型中的通配符:一篇文章彻底讲清楚 <? extends T> 和 <? super T>
Java 语言作为一种静态强类型语言,在设计过程中必须平衡类型安全、灵活性以及代码的可复用性。
泛型机制便是 Java 在 1.5 版本中引入的强大工具,旨在实现类型安全的集合操作及代码的通用化。
## 引入通配符的动机
在讨论通配符的动机之前,需要了解 Java 泛型的基本设计目标,即增强类型检查和减少强制类型转换。这一设计极大地提升了代码的类型安全性,但也带来了一个重要的问题:泛型的类型参数化一旦定义,就变得非常严格,从而限制了一些灵活性。在实际项目中,往往需要解决更具通用性的问题,这时就需要 Java 泛型的通配符来帮助处理这些类型的不确定性。
`<? extends T>` 和 `<? super T>` 的主要动机是为了应对协变与逆变的问题。
在编程中,尤其是在集合框架中,我们需要实现更加通用和灵活的代码,以支持不同类型之间的继承关系,而不损害类型安全性。
### 泛型协变和逆变的需求
为了理解泛型通配符的必要性,需要从两个简单的问题入手:
1. 能否将 `List<Cat>` 作为 `List<Animal>` 使用?
2. 在方法中能否传递 `List<Animal>` 来接收 `List<Cat>`?
这两个问题的答案是“不能直接这样做”,因为 Java 中 `List<Cat>` 并不是 `List<Animal>` 的子类型,尽管 `Cat` 是 `Animal` 的子类型。Java 泛型在继承关系方面并不支持这种协变关系。
通配符 `<? extends T>` 和 `<? super T>` 就是为了解决这类问题而设计的。
#### 现实生活中的例子
设想有一个场景,假设你经营着一个动物收容所,你手头有不同种类的动物,比如猫(`Cat`)和狗(`Dog`),而这些动物都是动物类(`Animal`)的子类。在 Java 中,如果你有一个方法用于给收容所内的所有动物添加一些相同的食物,你希望这个方法不仅适用于 `List<Cat>`,也能用于 `List<Dog>`,甚至是 `List<Animal>`。然而,普通泛型定义限制了这种操作的灵活性,因为 `List<Cat>` 不能被看作是 `List<Animal>`。
在这里,`<? extends T>` 和 `<? super T>` 正是为了这样的需求而诞生的,以在不同类型的继承体系中实现安全且灵活的类型处理。
## `<? extends T>` 和协变
`<? extends T>` 通配符表示某个类是 `T` 或 `T` 的子类,称为协变。协变的目的是允许我们`读取`该集合内的对象,但`不能安全地往集合中添加新对象`。
考虑下面的例子:
```java
class Animal {
void eat() {
System.out.println("Animal is eating");
}
}
class Cat extends Animal {
void meow() {
System.out.println("Cat is meowing");
}
}
public class CovariantExample {
public static void feedAnimals(List<? extends Animal> animals) {
for (Animal animal : animals) {
animal.eat();
}
// animals.add(new Cat()); // 编译错误
}
public static void main(String[] args) {
List<Cat> cats = new ArrayList<>();
cats.add(new Cat());
feedAnimals(cats); // List<Cat> 被接受了,因为 Cat extends Animal
}
}
```
在 `feedAnimals()` 方法中,参数是 `List<? extends Animal>`,意味着该列表可以是 `Animal` 或者 `Animal` 的任意子类。这样定义允许在方法中传递 `List<Cat>` 或 `List<Dog>`。但在方法中,你无法向 `animals` 集合中添加新元素。原因是 JVM 在编译期间不能确定 `animals` 具体是 `List<Cat>` 还是 `List<Dog>`,为了类型安全,编译器禁止往集合中插入元素。
在字节码层面,这种约束的本质是为了避免类型混淆。例如,如果在 `feedAnimals()` 中尝试添加 `Dog` 对象到 `List<Cat>` 中,这显然是不安全的。因此,在协变场景中,你可以安全地读取集合内的对象(知道它们至少是 `Animal`),但是不能往其中插入任何对象。
### 协变的应用场景
`<? extends T>` 适合用于只需要读取对象的场景。回到前面的动物收容所例子,当你只需要遍历集合并让所有动物进食时,使用协变是非常合理的,因为你并不关心集合中具体是哪种动物,只要它们都是 `Animal` 即可。
## `<? super T>` 和逆变
`<? super T>` 通配符表示某个类是 `T` 或 `T` 的父类,称为逆变。逆变的目的是允许我们往集合中添加对象,但读取对象时只能读取为 `Object` 类型,因为集合中的类型上界无法确定。
### 示例代码与解读
我们来看一个简单的例子:
```java
public class ContravariantExample {
public static void addCats(List<? super Cat> animals) {
animals.add(new Cat());
// Animal animal = animals.get(0); // 编译错误,只能返回 Object
}
public static void main(String[] args) {
List<Animal> animals = new ArrayList<>();
addCats(animals); // List<Animal> 被接受了,因为 Animal 是 Cat 的父类
}
}
```
在 `addCats()` 方法中,参数是 `List<? super Cat>`,意味着该列表可以是 `Cat` 或 `Cat` 的任何父类(例如 `Animal`)。这样定义允许你向集合中添加 `Cat` 对象,因为我们确信集合中至少能够容纳 `Cat`。然而,当你试图读取集合中的对象时,只能将它们读取为 `Object`,因为编译器无法确定集合中元素的具体类型,只能确定它们至少是 `Object`。
在字节码层面,这种设计确保了集合中元素的类型一致性,避免了向集合中插入不兼容类型的对象。例如,假设在 `addCats()` 中传入了 `List<Animal>`,如果允许读取为 `Cat` 类型,当集合中包含其他 `Animal` 的实例时,将导致类型转换异常。因此,编译器限制只能读取为 `Object`,以保证类型安全性。
### 逆变的应用场景
`<? super T>` 适合用于需要往集合中添加对象的场景。例如,回到动物收容所的例子,如果你有一个方法专门用于向收容所中添加猫,你可以使用 `List<? super Cat>` 作为参数,这样无论收容所收容的是动物(`List<Animal>`)还是猫(`List<Cat>`),都可以安全地将猫添加进去。
## 为什么 Java 泛型不能直接支持继承关系?
在理解了 `<? extends T>` 和 `<? super T>` 之后,一个自然的问题是:为什么 Java 不能直接让 `List<Cat>` 成为 `List<Animal>` 的子类?要回答这个问题,需要从类型安全和字节码设计的角度分析。
Java 中的泛型是通过类型擦除(Type Erasure)实现的。类型擦除意味着在编译期间,泛型信息会被移除,并在字节码中被替换为原始类型。例如,`List<Cat>` 和 `List<Animal>` 在编译后都会变成 `List`,泛型信息只在编译时起作用,而不会进入运行时。因此,如果 `List<Cat>` 和 `List<Animal>` 直接有继承关系,将会在类型擦除后产生歧义和类型安全问题。
假设 `List<Cat>` 可以作为 `List<Animal>` 使用,考虑如下代码:
```java
List<Cat> cats = new ArrayList<>();
List<Animal> animals = cats; // 假设这合法
animals.add(new Dog()); // 这样将导致 cats 中出现 Dog
Cat cat = cats.get(0); // 类型不匹配,出现运行时错误
```
如果允许这样的操作,在类型擦除后,集合 `cats` 中会包含 `Dog` 对象,而 `cats` 被定义为 `List<Cat>`,这将导致不可预测的行为。因此,为了保持类型安全,Java 不允许 `List<Cat>` 和 `List<Animal>` 之间存在直接的继承关系。
## JVM 和字节码层面的设计考量
从 JVM 和字节码的角度来看,Java 泛型的设计是基于保持向后兼容性的考量。在 Java 泛型引入之前,Java 集合可以存储任何对象,而类型安全完全依赖于强制类型转换。为了保持向后兼容性,Java 采用了类型擦除的方式来实现泛型,这使得在运行时,泛型集合与普通集合并无区别。
正是因为泛型信息在编译后被擦除,JVM 在运行时并不知道集合中元素的具体类型,因此也无法保证类型的正确性。为了在这种类型擦除的实现机制下仍然保持类型安全,Java 需要在编译期对泛型类型进行严格检查。这也就是为什么需要通过 `<? extends T>` 和 `<? super T>` 来在一定程度上解决灵活性与类型安全之间的矛盾。
## 泛型 PECS 原则:Producer Extends, Consumer Super
在实际应用中,有一个广泛使用的原则可以帮助决定何时使用 `<? extends T>` 和 `<? super T>`,即 PECS 原则:`Producer Extends, Consumer Super`。
- 如果需要从集合中读取类型 `T` 或其子类的对象,应该使用 `<? extends T>`。
- 如果需要将类型 `T` 或其子类的对象写入集合,应该使用 `<? super T>`。
这个原则很容易用一个现实的例子来理解。假设你有一个水果盘(`Fruit`),里面可以包含不同种类的水果(如苹果 `Apple` 或香蕉 `Banana`)。
- 如果你只是想吃水果,并不在意具体是哪种水果,使用 `<? extends Fruit>` 是合适的,因为你只需要从盘中取出水果,且不用担心具体是哪种水果。
- 如果你想向水果盘中添加水果,则应该使用 `<? super Fruit>`,这样可以保证你放入的对象至少是一个水果。
## 省流版
Java 引入 `<? extends T>` 和 `<? super T>` 的主要原因是为了在类型安全的前提下解决泛型的灵活性问题。通过这两种通配符,Java 可以更好地处理协变和逆变的问题,使得代码在处理集合类时既能保持灵活性,又不损害类型安全性。无论是在只读操作还是写入操作中,`<? extends T>` 和 `<? super T>` 都提供了一种安全的方式来处理类型的不确定性。
JVM 在字节码层面采用类型擦除的方式实现泛型,这也导致了泛型不能直接支持继承关系的设计限制。通过使用泛型通配符,Java 提供了一种在编译期保证类型安全而在运行时保持灵活性的机制,从而解决了泛型在复杂继承关系中的实际应用问题。
- 点赞
- 收藏
- 关注作者
评论(0)