彻底理解Java中的泛型

通过阅读完本篇文章,你将知道:

  • 什么是泛型?
  • 为什么要有泛型?
  • 使用泛型的正确姿势?
  • 如何对泛型参数进行限定?
  • 泛型有哪些局限性或者细节需要注意?
  • 什么是泛型

    “泛型”的字面意思就是广泛的类型。它将Java类处理的数据类型进行参数化,由使用者传入,使得Java类与其操作的数据类型不再绑定在一起,同一套代码可以用于多种数据类型,这样,不仅可以复用代码,降低耦合,而且可以提高代码的可读性和安全性。

    慢慢体会最后一句话:“提高了可读性和安全性”。

    阅读完本篇文章,细细消化完之后你就能够理解:(1)为什么提高了可读性? (2)又为什么提高了安全性?

    简单泛型使用案例:

    public class Pair {
    T first;
    T second;
    public Pair(T first, T second){
    this.first = first;
    this.second = second;
    }
    public T getFirst() {
    return first;
    }
    public T getSecond() {
    return second;
    }
    }

    Pair就是一个泛型类,与普通类的区别体现在:1)类名后面多了一个;2)first和second的类型都是T。T是什么呢?T表示类型参数,它可以由使用者传入。

    使用:

    Pair minmax = new Pair(1,100);
    Integer min = minmax.getFirst();
    Integer max = minmax.getSecond();

    为什么要有泛型

    我们在第一小节定义了一个泛型类Pair,使用泛型参数T来表示Pair处理的数据类型是"广泛"的,可以由使用者传入。

    问:不使用泛型,直接定义一个普通类,内部要处理的数据直接使用Object定义不行吗?

    比如下面我Pair类直接这样写:

    public class Pair {
    Object first;
    Object second;
    public Pair(Object first, Object second){
    this.first = first;
    this.second = second;
    }
    public Object getFirst() {
    return first;
    }
    public Object getSecond() {
    return second;
    }
    }

    使用Pair的代码可以为:

    Pair minmax = new Pair(1,100);
    Integer min = (Integer)minmax.getFirst();
    Integer max = (Integer)minmax.getSecond();
    Pair kv = new Pair("name", "老马");
    String key = (String)kv.getFirst();
    String value = (String)kv.getSecond();

    这样写是可以的!并且,Java泛型的内部原理就是这样的!

    稍微简单解释下基本实现原理:

    我们知道,Java有Java编译器和Java虚拟机,编译器将Java源代码转换为.class文件,虚拟机加载并运行.class文件。对于泛型类,

    • Java编译器会将泛型代码转换为普通的非泛型代码,就像上面的普通Pair类代码及其使用代码一样,将类型参数T擦除,替换为Object,插入必要的强制类型转换。
    • Java虚拟机实际执行的时候,它是不知道泛型这回事的,只知道普通的类及代码。

    再强调一下,Java泛型是通过擦除实现的,类定义中的类型参数如T会被替换为Object,在程序运行过程中,不知道泛型的实际类型参数,比如Pair,运行中只知道Pair,而不知道Integer。认识到这一点是非常重要的,它有助于我们理解Java泛型的很多限制。 继续往后面看。

    到这里我们已经知道了当我们在代码中使用泛型时,Java编译器实际上会把所有的泛型参数擦除掉,然后根据代码推断插入一些必要的强制类型转换,我们最终的泛型代码都变成了普通的非泛型代码。

    那既然这样,为什么要使用泛型呢???

    语言和程序设计的一个重要目标是将bug尽量消灭在摇篮里,能消灭在写代码的时候,就不要等到代码写完程序运行的时候。

    只使用Object,代码写错的时候,开发环境和编译器不能帮我们发现问题,看代码:

    Pair pair = new Pair("老马",1);
    Integer id = (Integer)pair.getFirst();
    String name = (String)pair.getSecond();

    看出问题了吗?写代码时不小心把类型弄错了,不过,代码编译时是没有任何问题的,但运行时程序抛出了类型转换异常ClassCastException。

    如果使用泛型,则不可能犯这个错误(不可能等到程序运行时才发现代码存在类型转换的错误),比如下面的代码:

    Pair pair = new Pair("老马",1);
    Integer id = pair.getFirst(); //有编译错误
    String name = pair.getSecond(); //有编译错误

    所以,我们现在明白Java中设计泛型这一语法的目的是什么了吧,就是尽量提高程序的安全性,什么安全性?类型安全性。也即使用了泛型,如果在编译期间没有报错,那么基本可以保证程序在运行的时候不会出现类型安全问题。除了提高了程序的安全性,还有什么好处?可读性,怎么理解,我们可以发现使用了泛型之后就不用我们自己去手动做一些类型转换了,大大的提高了代码的简洁和可读性。

    读到这里就基本知道了使用泛型的好处,那下一步就是要知道如何正确使用泛型。

    使用泛型的正确姿势

    在准备学习使用泛型之前,我们的脑海中应该对泛型的使用有一个基本的认识框架,也就是泛型可以在什么地方使用?

  • 泛型可以用于定义类
  • 泛型可以用于定义方法
  • 泛型可以用于定义接口
  • 接下来我们就按照上面的思路进行学习使用泛型。

    定义泛型类

    定义一个泛型类就是在直接类名添加泛型参数,然后在类的内部就可以使用这个泛型了。

    class className {
    private T a;
    public className(T a) {
    this.a = a;
    }
    public T getA() {
    return this.a;
    }
    public void setA(T a) {
    this.a = a;
    }
    }
    // 也可以定义多个泛型
    class className {
    }

    定义泛型方法

    除了泛型类,方法也可以是泛型的,而且,一个方法是不是泛型的,与它所在的类是不是泛型没有什么关系,定义一个泛型方法直接在方法的返回值前面添加泛型参数即可,然后就可以在方法内部、方法的参数、方法的返回值中使用泛型了

    public static int indexOf(T[] arr, T elm){
    for(int i=0; i :泛型参数必须是E或者E的子类,这个E可以是某个具体的类或者某个具体的接口,也可以是其它类型参数
    (2):表示无限定通配符,通俗的讲可以是任何的类型。它的真正本意是:类型安全无知,为了保证类型安全,只能读不能写。
    知道了对泛型参数限定的三种方法之后,就需要知道在什么场景下会使用。
    的使用
    使用场景:当我们需要对泛型参数进行更加灵活或者具体的读取时,我们就可以使用指定泛型参数的上界是E,那么我们就可以统一把传进来的具体泛型参数当做E类型进行读取了。
    1. 上界是一个具体的类:
    定义一个子类NumberPair,限定两个类型参数必须为Number或者Number的子类,那么我们就可以把传进来的泛型参数当做Number类型进行统一处理了,可以直接使用Number中的"读方法”了。
    public class NumberPair
    extends Pair {
    public NumberPair(U first, V second) {
    super(first, second);
    }
    }
    例如下面我们可以在类中定义一个求和方法,直接使用Number中的doubleValue方法。
    public double sum(){
    return getFirst().doubleValue() +getSecond().doubleValue();
    }
    使用:
    NumberPair pair = new NumberPair(10, 12.34);
    double sum = pair.sum();
    限定类型后,如果类型使用错误,编译器会提示。
    问:在前面中,我们知道在编译期间泛型会被擦除全部转换成Object类型,那指定边界之后泛型擦除还是全都转换成Object吗?
    指定边界后,类型擦除时就不会转换为Object了,而是会转换为它的边界类型,这也是容易理解的。
    2. 上界是一个接口:
    当我们限定类型参数的的上界是某个接口时,也即要求将来传入的具体类型必须是实现了该接口。
    常见的场景是限定类型必须实现Comparable接口,我们来看代码:
    public static T max(T[] arr){
    T max = arr[0];
    for(int i=1; i0){
    max = arr[i];
    }
    }
    return max;
    }
    max方法计算一个泛型数组中的最大值。计算最大值需要进行元素之间的比较,要求元素实现Comparable接口,所以给类型参数设置了一个上边界Comparable, T必须实现Comparable接口。
    不过,直接这么编写代码,Java中会给一个警告信息,因为Comparable是一个泛型接口,它也需要一个类型参数,所以完整的方法声明应该是:
    public static T max(T[] arr){
    //主体代码
    }
    是一种令人费解的语法形式,这种形式称为递归类型限制,可以这么解读:
    T表示一种数据类型,必须实现Comparable接口,
    且必须可以与相同类型的元素进行比较。因为Comparable接口中的类型参数也是T,(当然可以指定为其他类型,语法上没问题,不过意义不大)
    3. 上界为其他类型参数
    Java支持一个类型参数以另一个类型参数作为上界。
    比如下面一个容器类,它的泛型参数是E,类中定义了一些基本的方法。
    public class DynamicArray {
    private static final int DEFAULT_CAPACITY = 10;
    private int size;
    private Object[] elementData;
    public DynamicArray() {
    this.elementData = new Object[DEFAULT_CAPACITY];
    }
    private void ensureCapacity(int minCapacity) {
    int oldCapacity = elementData.length;
    if(oldCapacity >= minCapacity){
    return;
    }
    int newCapacity = oldCapacity * 2;
    if(newCapacity < minCapacity)
    newCapacity = minCapacity;
    elementData = Arrays.copyOf(elementData, newCapacity);
    }
    public void add(E e) {
    ensureCapacity(size + 1);
    elementData[size++] = e;
    }
    public E get(int index) {
    return (E)elementData[index];
    }
    public int size() {
    return size;
    }
    public E set(int index, E element) {
    E oldValue = get(index);
    elementData[index] = element;
    return oldValue;
    }
    }
    现在有这样一个需求:给上面的DynamicArray类增加一个实例方法addAll,这个方法将参数容器中的所有元素都添加到当前容器里来
    直觉上我们可以直接像下面这样写:
    public void addAll(DynamicArray c) {
    for(int i=0; i的使用
    ,称为无限定通配符。我们来看个例子,在DynamicArray中查找指定元素,代码如下:
    public static int indexOf(DynamicArray