大白话说Java泛型(二):深入理解通配符

时间:2023-12-27 12:36:07

文章首发于【博客园-陈树义】,点击跳转到原文《大白话说Java泛型(二):深入理解通配符》

上篇文章《大白话说Java泛型(一):入门、原理、使用》,我们讲了泛型的产生缘由以及其基本使用。但泛型还有更加复杂的应用,如:

List<? extends Number> list = new ArrayList();
List<? super Number> list = new ArrayList();

上面的 extends 和 super 关键字其实就是泛型的高级应用:泛型通配符。

但在讲泛型通配符之前,我们必须对编译时类型和运行时类型有一个基本的了解,才能更好地理解通配符的使用。

编译时类型和运行时类型

我们先来看看一个简单的例子。

Class Fruit{}
Class Apple extends Fruit{}

上面声明一个 Fruit 类,Apple 类是 Fruit 类的子类。

接着下面我们声明一个苹果对象:

Apple apple = new Apple();

这样的声明,我相信大家都没有什么异议,声明一个 Apple 类型的变量指向一个 Apple 对象。在上面这段代码中,apple 属性指向的对象,其编译时类型和运行时类型都是 Apple 类型。

但其实很多时候我们也使用下面这种写法:

Fruit apple = new Apple();

我们使用 Fruit 类型的变量指向了一个 Apple 对象,这在 Java 的语法体系中也是没有问题的。因为 Java 允许把一个子类对象(Apple对象)直接赋值给一个父类引用变量(Fruit类变量),一般我们称之为「向上转型」。

那问题来了,此时 apple 属性所指向的对象,其编译时类型和运行时类型是什么呢?

很多人会说:apple 属性指向的对象,其编译时类型和运行时类型不都是 Apple 类型吗?

正确答案是:apple 属性所指向的对象,其在编译时的类型就是 Fruit 类型,而在运行时的类型就是 Apple 类型。

这是为什么呢?

因为在编译的时候,JVM 只知道 Fruit 类变量指向了一个对象,并且这个对象是 Fruit 的子类对象或自身对象,其具体的类型并不确定,有可能是 Apple 类型,也有可能是 Orange 类型。而为了安全方面的考虑,JVM 此时将 apple 属性指向的对象定义为 Fruit 类型。因为无论其是 Apple 类型还是 Orange 类型,它们都可以安全转为 Fruit 类型。

而在运行时阶段,JVM 通过初始化知道了它指向了一个 Apple 对象,所以其在运行时的类型就是 Apple 类型。

泛型中的向上转型

当我们明白了编译时类型和运行时类型之后,我们再来理解通配符的诞生就相对容易一些了。

还是上面的场景,我们有一个 Fruit 类,Apple 类是 Fruit 的子类。这时候,我们增加一个简单的容器:Plate 类。Plate 类定义了盘子一些最基本的动作:

public class Plate<T> {
    private List<T> list;
    public Plate(){}
    public void add(T item){list.add(item);}
    public T get(){return list.get(0);}
}

按我们之前对泛型的学习,我们可以知道上面的代码定义了一个 Plate 类。Plate 类定义了一个 T 泛型类型,可以接收任何类型。说人话就是:我们定义了一个盘子类,这个盘子可以装任何类型的东西,比如装水果、装蔬菜。

如果我们想要一个装水果的盘子,那定义的代码就是这样的:

Plate<Fruit> plate = new Plate<Fruit>();

我们直接定义了一个 Plate 对象,并且指定其泛型类型为 Fruit 类。这样我们就可以往里面加水果了:

plate.add(new Fruit());
plate.add(new Apple());

按照 Java 向上转型的原则,我们当然也觉得 Java 泛型可以向上转型,即我们上面关于水果盘子的定义可以变为这样:

Plate<Fruit> plate = new Plate<Apple>();  //Error

但事实上,这种写法是错误的,上面的代码在编译的时候会出现编译错误。

按理说,这种写法应该是没有问题的,因为 Java 支持向上转型嘛。

错误的原因就是:泛型并不直接支持向上转型,JVM 会要求其指向的对象是 Fruit 类型的对象。

正是为了解决保持「向上转型」概念在 Java 语言中的统一,使泛型也支持向上转型,所以 Java 推出了通配符的概念。

上面这行代码如果要正常编译,只需要修改一下 Plate 类的声明即可:

Plate<? extends Fruit> plate = new Plate<Apple>();

上面的这行代码表示:plate 可以指向任何 Fruit 类对象,或者任何 Fruit 的子类对象。Apple 是 Fruit 的子类,自然就可以正常编译了。

extends通配符的缺陷

虽然通过这种方式,Java 支持了 Java 泛型的向上转型,但是这种方式是有缺陷的,那就是:其无法向 Plate 中添加任何对象,只能从中读取对象。

Plate<? extends Fruit> plate = new Plate<Apple>();
plate.add(new Apple()); //Compile Error
plate.get();    // Compile Success

可以看到,当我们尝试往盘子中加入一个苹果时,会发现编译错误。但是我们可以从中取出东西。那为什么我们会无法往盘子中加东西呢?

这还得从我们对盘子的定义说起。

Plate<? extends Fruit> plate = new Plate<XXX>();

上面我们对盘子的定义中,plate 可以指向任何 Fruit 类对象,或者任何 Fruit 的子类对象。也就是说,plate 属性指向的对象其在运行时可以是 Apple 类型,也可以是 Orange 类型,也可以是 Banana 类型,只要它是 Fruit 类,或任何 Fruit 的子类即可。即我们下面几种定义都是正确的:

Plate<? extends Fruit> plate = new Plate<Apple>();
Plate<? extends Fruit> plate = new Plate<Orange>();
Plate<? extends Fruit> plate = new Plate<Banana>();

这样子的话,在我们还未具体运行时,JVM 并不知道我们要往盘子里放的是什么水果,到底是苹果,还是橙子,还是香蕉,完全不知道。既然我们不能确定要往里面放的类型,那 JVM 就干脆什么都不给放,避免出错。

正是出于这种原因,所以当使用 extends 通配符时,我们无法向其中添加任何东西。

那为什么又可以取出数据呢?因为无论是取出苹果,还是橙子,还是香蕉,我们都可以通过向上转型用 Fruit 类型的变量指向它,这在 Java 中都是允许的。

Fruit apple = plate.get();
Apple apple = plate.get();  //Error

可以从上面的代码看到,当你尝试用一个 Apple 类型的变量指向一个从盘子里取出的水果时,是会提示错误的。

所以当使用 extends 通配符时,我们可以取出所有东西。

总结一下,我们通过 extends 关键字可以实现向上转型。但是我们却失去了部分的灵活性,即我们不能往其中添加任何东西,只能取出东西。

super通配符的缺陷

与 extends 通配符相似的另一个通配符是 super 通配符,其特性与 extends 完全相反。

Plate<? super Apple> plate = new Plate<Fruit>();

上面这行代码表示 plate 属性可以指向一个特定类型的 Plate 对象,只要这个特定类型是 Apple 或 Apple 的父类。也就是说,如果 EatThing 类是 Fruit 的父级,那么下面的声明也是正确的:

Plate<? super Apple> plate = new Plate<EatThing>();

当然了,下面的声明肯定也是对的,因为 Object 是任何一个类的父级。

Plate<? super Apple> plate = new Plate<Object>();

既然这样,也就是说 plate 指向的具体类型可以是任何 Apple 的父级,JVM 在编译的时候肯定无法判断具体是哪个类型。但 JVM 能确定的是,任何 Apple 的子类都可以转为 Apple 类型,但任何 Apple 的父类都无法转为 Apple 类型。

所以对于使用了 super 通配符的情况,我们只能存入 T 类型及 T 类型的子类对象。

Plate<? super Apple> plate = new Plate<Fruit>();
plate.add(new Apple());
plate.add(new Fruit()); //Error

当我们向 plate 存入 Apple 对象时,编译正常。但是存入 Fruit 对象,就会报编译错误。

而当我们取出数据的时候,也是类似的道理。JVM 在编译的时候知道,我们具体的运行时类型可以是任何 Apple 的父级,那么为了安全起见,我们就用一个最顶层的父级来指向取出的数据,这样就可以避免发生强制类型转换异常了。

Object object = plate.get();
Apple apple = plate.get();  //Error
Fruit fruit = plate.get();  //Error

从上面的代码可以知道,当使用 Apple 类型或 Fruit 类型的变量指向 plate 取出的对象,会出现编译错误。而使用 Object 类型的额变量指向 plate 取出的对象,则可以正常通过。

也就是说对于使用了 super 通配符的情况,我们取出的时候只能用 Object 类型的属性指向取出的对象。

PECS原则

说到这里,我相信大家已经明白了 extends 和 super 通配符的使用和限制了。我们知道:

  • 对于 extends 通配符,我们无法向其中加入任何对象,但是我们可以进行正常的取出。
  • 对于 super 通配符,我们可以存入 T 类型对象或 T 类型的子类对象,但是我们取出的时候只能用 Object 类变量指向取出的对象。

从上面的总结可以看出,extends 通配符偏向于内容的获取,而 super 通配符更偏向于内容的存入。我们有一个 PECS 原则(Producer Extends Consumer Super)很好的解释了这两个通配符的使用场景。

Producer Extends 说的是当你的情景是生产者类型,需要获取资源以供生产时,我们建议使用 extends 通配符,因为使用了 extends 通配符的类型更适合获取资源。

Consumer Super 说的是当你的场景是消费者类型,需要存入资源以供消费时,我们建议使用 super 通配符,因为使用 super 通配符的类型更适合存入资源。

但如果你既想存入,又想取出,那么你最好还是不要使用 extends 或 super 通配符。

总结

Java 泛型通配符的出现是为了使 Java 泛型也支持向上转型,从而保持 Java 语言向上转型概念的统一。但与此同时,也导致 Java 通配符出现了一些缺陷,使得其有特定的使用场景。

关于泛型的两篇文章到这里就结束了,希望大家看了都能对泛型有更深入的理解。

文章首发于【博客园-陈树义】,点击跳转到原文《大白话说Java泛型(二):深入理解通配符》