Java 泛型中的通配符:一篇文章彻底讲清楚 <? extends T> 和 <? super T>
Java 语言作为一种静态强类型语言,在设计过程中必须平衡类型安全、灵活性以及代码的可复用性。
泛型机制便是 Java 在 1.5 版本中引入的强大工具,旨在实现类型安全的集合操作及代码的通用化。
引入通配符的动机
在讨论通配符的动机之前,需要了解 Java 泛型的基本设计目标,即增强类型检查和减少强制类型转换。这一设计极大地提升了代码的类型安全性,但也带来了一个重要的问题:泛型的类型参数化一旦定义,就变得非常严格,从而限制了一些灵活性。在实际项目中,往往需要解决更具通用性的问题,这时就需要 Java 泛型的通配符来帮助处理这些类型的不确定性。
<? extends T>
和 <? super T>
的主要动机是为了应对协变与逆变的问题。
在编程中,尤其是在集合框架中,我们需要实现更加通用和灵活的代码,以支持不同类型之间的继承关系,而不损害类型安全性。
泛型协变和逆变的需求
为了理解泛型通配符的必要性,需要从两个简单的问题入手:
- 能否将
List<Cat>
作为List<Animal>
使用? - 在方法中能否传递
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
的子类,称为协变。协变的目的是允许我们读取
该集合内的对象,但不能安全地往集合中添加新对象
。
考虑下面的例子:
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
类型,因为集合中的类型上界无法确定。
示例代码与解读
我们来看一个简单的例子:
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>
使用,考虑如下代码:
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)