简单的泛型理解见上一篇文章:https://blog.csdn.net/2302_78439400/article/details/153181138?spm=1001.2014.3001.5501
接下来继续解读一下通配符与PECS法则
我们先从一个问题开始。假设我们有一个动物园,里面有各种动物。
java
class Animal { void eat() { System.out.println("Animal eats"); } }
class Dog extends Animal { void bark() { System.out.println("Dog barks"); } }
class Cat extends Animal { void meow() { System.out.println("Cat meows"); } }
现在,我想写一个方法,用来遍历任何一个"装有动物的盘子"(List),并让它们都"吃饭"(调用 eat() 方法)。
你可能会很自然地写出这样的代码:
java
public void feedAll(List<Animal> animals) {
for (Animal animal : animals) {
animal.eat();
}
}
这看起来没问题。但当你想用它来喂一盘狗的时候,问题就来了:
java
List<Dog> dogs = new ArrayList<>();
dogs.add(new Dog());
feedAll(dogs); // 编译直接报错!
为什么会报错?
这是泛型的一个核心原则:List<Dog> 不是 List<Animal> 的子类!
打个比方:一个"装狗的盘子" (List<Dog>) 肯定不是 一个"装任何动物的盘子" (List<Animal>)。为什么?因为你不能往"装狗的盘子"里放一只猫,但"装任何动物的盘子"应该是可以放猫的。为了保证这种类型安全,Java 干脆规定它俩没有任何继承关系。
这就很尴尬了。我的 feedAll 方法难道要为 List<Dog>、List<Cat> 各自写一个重载版本吗?那也太蠢了。
为了解决这个问题,通配符 ? 闪亮登场。
1. 上界通配符:? extends T (读取/生产者)
我们可以把方法改成这样:
java
public void feedAll(List<? extends Animal> animals) {
for (Animal animal : animals) {
animal.eat();
}
// animals.add(new Dog()); // 编译报错!
}
List<? extends Animal> 这句话翻译过来就是:"一个列表,它里面的元素类型是未知的(?),但这个未知类型肯定是 Animal 或者 Animal 的某个子类"。
-
List<Dog>符合这个描述吗?符合,Dog是Animal的子类。 -
List<Cat>符合这个描述吗?符合,Cat是Animal的子类。 -
List<Animal>符合这个描述吗?符合,Animal是Animal本身。
这样一来,feedAll(dogs) 就能编译通过了。
但是,它也带来一个限制:你不能往 List<? extends Animal> 里添加任何东西 (null 除外)。
为什么?因为编译器只知道这个列表里装的是"某种 Animal 的子类",但它不确定到底是哪一种 。它可能是 List<Dog>,也可能是 List<Cat>。如果它允许你 add(new Dog()),万一这个列表实际上是 List<Cat> 呢?那就出错了。所以为了绝对安全,编译器干脆禁止了一切添加操作。
总结 ? extends T:
-
它让方法变得更通用,可以接收
T及其所有子类的集合。 -
它通常用于读取数据 的场景(我们从
animals列表里get元素出来消费)。我们称这种集合为生产者 (Producer),因为它为我们生产(提供)数据。
2. 下界通配符:? super T (写入/消费者)
我们再看另一个场景。假设我想写一个方法,可以把一只新出生的小猫,添加到任何一个"能装猫的盘子"里。
这个"能装猫的盘子"可能是 List<Cat>,也可能是 List<Animal>,甚至是 List<Object> (因为 Cat 也是 Object)。
如果我们写成 public void addCat(List<Cat> cats),那 List<Animal> 就传不进来了。这时就需要下界通配符:
java
public void addCat(List<? super Cat> cats) {
cats.add(new Cat()); // 完全没问题!
// Object item = cats.get(0); // 取出来只能当 Object 用
}
List<? super Cat> 这句话翻译过来就是:"一个列表,它里面的元素类型是未知的(?),但这个未知类型肯定是 Cat 或者 Cat 的某个父类"。
-
List<Cat>符合吗?符合。 -
List<Animal>符合吗?符合。 -
List<Object>符合吗?符合。
为什么这次可以 add 了?因为无论这个列表到底是 List<Cat>, List<Animal> 还是 List<Object>,放一只 Cat 进去都是类型安全的。
但是,当你从这个列表里取元素 时,编译器为了安全,只能保证取出来的一定是 Object。因为它不确定这个列表到底是哪种父类型,只能给你所有类型的共同祖先 Object。
总结 ? super T:
-
它让方法变得更通用,可以接收
T及其所有父类的集合。 -
它通常用于写入数据 的场景(我们往
cats列表里add新元素)。我们称这种集合为消费者 (Consumer),因为它消费(接收)我们提供的数据。
PECS 法则
上面这两条,合在一起就是大名鼎鼎的 PECS 法则:
P roducer-E xtends, C onsumer-Super
-
生产者用 Extends :如果你需要一个集合来读取/获取 数据(它作为生产者),那么用
? extends T。 -
消费者用 Super :如果你需要一个集合来写入/添加 数据(它作为消费者),那么用
? super T。 -
既要读又要写 :那就不要用通配符,直接用精确类型,比如
List<Animal>。
JDK 里的 Collections.copy() 方法就是 PECS 法则的最佳实践: public static <T> void copy(List<? super T> dest, List<? extends T> src)
-
src是源头,是生产者 ,我们只会从里面读,所以用extends。 -
dest是目的地,是消费者 ,我们只会往里面写,所以用super。
类型擦除 - 泛型的"底层秘密"
有没有想过一个问题:泛型是 JDK 1.5 才有的新功能,那 1.5 之前编译出来的旧 class 文件(字节码),是怎么和 1.5 之后带泛型的代码一起工作的呢?
答案就是类型擦除 (Type Erasure)。
核心思想 :泛型只存在于编译期 ,用来给编译器做类型检查。到了运行期,JVM 其实是看不到这些泛型信息的,它们都被"擦除"了。
可以这么理解: List<String> 和 List<Integer> 在写代码和编译的时候,是两种完全不同的类型。但是,一旦编译完成,生成的字节码里,它们都会变回"赤裸裸"的 List,也就是我们"蛮荒时代"的那个 List。
编译器在"擦除"前后做了两件重要的事:
-
类型检查 :在编译时,严格按照你写的泛型(如
List<String>)来检查你的代码。如果你add了一个Integer,编译器直接报错。这是泛型安全的核心。 -
自动类型转换 :当编译器检查通过后,它在生成字节码时,会偷偷地帮你加上强制类型转换。 比如你的代码是
String s = list.get(0);,生成的字节码实际上可能是String s = (String) list.get(0);。
类型擦除带来的几个重要限制(面试常考):
-
不能
new T():你不能在泛型类里写T data = new T();。因为擦除后,T会变成Object,JVM 根本不知道你想new的是哪个具体类。 -
不能
instanceof List<String>:你不能用instanceof来判断一个对象是否属于某个具体的泛型类型。if (myList instanceof List<String>)是非法的。因为在运行时,JVM 眼里只有List,没有<String>。你只能写if (myList instanceof List)。 -
不能创建泛型数组 :你不能写
List<String>[] array = new List<String>[10];。这也是因为数组在运行时需要知道自己的确切元素类型,但泛型信息被擦除了,这就产生了矛盾。
一句话总结类型擦除: 泛型是编译器给你的一个"君子协定 "。它在编译代码时帮你把关,确保类型安全。一旦代码编译通过,它就"卸磨杀驴",把泛型信息擦掉,换成旧的 Object 和强制转换,让代码能在任何 JVM 上运行。
PECS 法则和"类型擦除"是泛型里最抽象、最底层,但也最重要的两个概念。
-
PECS 指导使用者如何设计出更灵活、更通用的 API。
-
类型擦除 解释了泛型的工作原理及其局限性。
补充说明:
即使在 JDK 21 里,类型擦除依然存在吗?
答案是:是的,绝对存在。
这可能是 Java 最著名的"历史包袱"之一。类型擦除机制的核心目的就是为了向后兼容。
你想想,在 JDK 1.5 之前,所有的 ArrayList 在字节码层面就是 ArrayList,里面存的都是 Object。如果从 JDK 1.5 开始,编译后的字节码里突然出现了 ArrayList<String> 这种全新的东西,那么老的 JVM(1.5 之前的版本)就会完全不认识,直接抛出错误。
为了让泛型这个新语法能够在所有(包括老的)JVM 上运行,Java 的设计者们才决定采用"编译器做手脚,JVM 无感知"的策略,也就是类型擦除。
所以,直到今天最新的 JDK 版本,为了维护整个 Java 生态的稳定和兼容性,这个机制依然是泛型工作的基石。任何一个 List<String> 在编译后,其字节码中的类型签名依然是 List。