泛型学习——看透通配符?与PECS 法则
简单的泛型理解见上一篇文章:https://blog.csdn.net/2302_78439400/article/details/153181138?spm=1001.2014.3001.5501
接下来继续解读一下通配符与PECS法则
我们先从一个问题开始。假设我们有一个动物园,里面有各种动物。
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()
方法)。
你可能会很自然地写出这样的代码:
public void feedAll(List<Animal> animals) {for (Animal animal : animals) {animal.eat();}
}
这看起来没问题。但当你想用它来喂一盘狗的时候,问题就来了:
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
(读取/生产者)
我们可以把方法改成这样:
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>
就传不进来了。这时就需要下界通配符:
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 法则:
Producer-Extends, Consumer-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
。