Java 泛型入门教程:入门、使用、原理

Posted by 陈树义 on 2018-01-15

文章首发于【博客园-陈树义】,点击跳转到原文《大白话说Java泛型:入门、使用、原理》

远在 JDK 1.4 版本的时候,那时候是没有泛型的概念的。当时 Java 程序员们写集合类的代码都是类似于下面这样:

List list = new ArrayList();
list.add("www.cnblogs.com");
list.add(23);
String name = (String)list.get(0);
Integer number = (Integer)list.get(1);

在代码中声明一个集合,我们可以往集合中放入各种各样的数据,而在取出来的时候就进行强制类型转换。但其实这样的代码存在一定隐患,因为可能过了不久我们就会忘记到底我们存放的 list 里面到底第几个是 String,第几个是 Integer 了。这样就会出现下面这样的情况:

List list = new ArrayList();
list.add("www.cnblogs.com");
list.add(23);
String name = (String)list.get(0);
String number = (String)list.get(1);	//ClassCastException

上面的代码在运行时会发生强制类型转换异常。这是因为我们在存入的时候,第二个是一个 Integer 类型,但是取出来的时候却将其强制转换为 String 类型了。Sun 公司为了使 Java 语言更加安全,减少运行时异常的发生。于是在 JDK 1.5 之后推出了泛型的概念。

于是在 JDK 1.5 之后,我们如果使用集合来书写代码,可以使用下面这种形式:

List<String> list = new ArrayList();
list.add("www.cnblogs.com");
list.add("www.cnblogs.com/chanshuyi");
String cnBlogs = list.get(0);
String myWebSite = list.get(1);	

**泛型就是将类型参数化,其在编译时才确定具体的参数。在上面这个例子中,这个具体的类型就是 String。**可以看到我们在创建 List 集合的时候指定了 String 类型,这就意味着我们只能往 List 集合中存放 String 类型的数据。而当我们指定泛型之后,我们去取出数据后就不再需要进行强制类型转换了,这样就减少了发生强制类型转换的风险。

泛型的原理

上面我们通过两个很简单的例子知道了为什么要有泛型,以及泛型最简单的使用。下面我们通过一个面试中常见的例子来看一下泛型的本质是什么。

ArrayList<String> a = new ArrayList<String>();
ArrayList<Integer> b = new ArrayList<Integer>();
Class c1 = a.getClass();
Class c2 = b.getClass();
System.out.println(c1 == c2); 

在继续往下看之前,先想一想,这道题输出的结果是什么?

是 true 还是 false ?

这道题输出的结果是 true。因为无论对于 ArrayList 还是 ArrayList,它们的 Class 类型都是一直的,都是 ArrayList.class。

那它们声明时指定的 String 和 Integer 到底体现在哪里呢?

**答案是体现在类编译的时候。**当 JVM 进行类编译时,会进行泛型检查,如果一个集合被声明为 String 类型,那么它往该集合存取数据的时候就会对数据进行判断,从而避免存入或取出错误的数据。

也就是说:**泛型只存在于编译阶段,而不存在于运行阶段。**在编译后的 class 文件中,是没有泛型这个概念的。

上面我们只是说了泛型在集合中的使用方式,但其实泛型的应用范围不仅仅只是集合,还包括类、方法、Map 接口等等。

泛型的使用情景

泛型的应用还广泛存在于下面几种情形:泛型类、泛型方法、泛型集合。

泛型类

泛型类一般使用字母 T 作为泛型的标志。

public class GenericClass<T> {
    private T object;
    public T getObject() {
        return object;
    }
    public void setObject(T object) {
        this.object = object;
    }
}

使用:

public static void main(String[] args) {
    GenericClass<Integer> integerGenericClass = new GenericClass<>(100);
    integerGenericClass.showType();
    GenericClass<String> stringGenericClass = new GenericClass<>("www.cnblogs.com/chanshuyi");
    stringGenericClass.showType();
}

除了使用 T 作为泛型类的标志之外,在需要使用 Map 的类中,通常使用 K V 两个字母表示 Key Value 对应的类型。

public class GenericMap<K, V> {
    private K key;
    private V value;
    public void put(K key, V value) {
        this.key = key;
        this.value = value;
    }
}

使用:

public static void main(String[] args) {
        GenericMap<Integer, String> team = new GenericMap<>();
        team.put(1, "YaoMin");
        team.put(2, "Me");
        GenericMap<String, Integer> score = new GenericMap<>();
        score.put("YaoMin", 88);
        score.put("Me", 80);
    }

泛型方法

泛型方法一般使用字母 T 作为泛型的标志。

public class GenericMethod {
    public static <T> T getObject(Class<T> clz) throws InstantiationException, IllegalAccessException{
        T t = clz.newInstance();
        return t;
    }
}

使用:

public static void main(String[] args) throws Exception{
    GenericMethod genericMethod = getObject(GenericMethod.class);
    System.out.println("Class:" + genericMethod.getClass().getName());
}

泛型通配符

除了泛型类、泛型方法之外,泛型还有更加复杂的应用,如:

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 支持向上转型嘛。

错误的原因就是: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 完全相反。super通配符可以存入对象,但是取出对象的时候受到限制。

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

上面这行代码表示 plate 属性可以指向一个特定类型的 Plate 对象,只要这个特定类型是 Apple 或 Apple 的父类。上面的 Fruit 类就是 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泛型:入门、使用、原理》