泛型学习——看透通配符?与PECS 法则

简单的泛型理解见上一篇文章: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> 符合这个描述吗?符合,DogAnimal 的子类。

  • List<Cat> 符合这个描述吗?符合,CatAnimal 的子类。

  • List<Animal> 符合这个描述吗?符合,AnimalAnimal 本身。

这样一来,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

编译器在"擦除"前后做了两件重要的事:

  1. 类型检查 :在编译时,严格按照你写的泛型(如 List<String>)来检查你的代码。如果你 add 了一个 Integer,编译器直接报错。这是泛型安全的核心。

  2. 自动类型转换 :当编译器检查通过后,它在生成字节码时,会偷偷地帮你加上强制类型转换。 比如你的代码是 String s = list.get(0);,生成的字节码实际上可能是 String s = (String) list.get(0);

类型擦除带来的几个重要限制(面试常考):

  1. 不能 new T() :你不能在泛型类里写 T data = new T();。因为擦除后,T 会变成 Object,JVM 根本不知道你想 new 的是哪个具体类。

  2. 不能 instanceof List<String> :你不能用 instanceof 来判断一个对象是否属于某个具体的泛型类型。if (myList instanceof List<String>) 是非法的。因为在运行时,JVM 眼里只有 List,没有 <String>。你只能写 if (myList instanceof List)

  3. 不能创建泛型数组 :你不能写 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

相关推荐
这周也會开心5 小时前
云服务器安装JDK、Tomcat、MySQL
java·服务器·tomcat
hrrrrb6 小时前
【Spring Security】Spring Security 概念
java·数据库·spring
小信丶6 小时前
Spring 中解决 “Could not autowire. There is more than one bean of type“ 错误
java·spring
周杰伦_Jay7 小时前
【Java虚拟机(JVM)全面解析】从原理到面试实战、JVM故障处理、类加载、内存区域、垃圾回收
java·jvm
程序员小凯11 小时前
Spring Boot测试框架详解
java·spring boot·后端
豐儀麟阁贵11 小时前
基本数据类型
java·算法
_extraordinary_11 小时前
Java SpringMVC(二) --- 响应,综合性练习
java·开发语言
程序员 Harry12 小时前
深度解析:使用ZIP流式读取大型PPTX文件的最佳实践
java
Larry_Yanan12 小时前
QML学习笔记(三十四)QML的GroupBox、RadioButton
c++·笔记·qt·学习·ui