一文读懂java泛型机制

27 2 月, 2023 177点热度 0人点赞 0条评论

1. 什么是泛型

Java泛型这个特性是从JDK 1.5才开始加入的,因此为了兼容之前的版本,Java泛型的实现采取了“伪泛型”的策略,即Java在语法上支持泛型,但是在编译阶段会进行所谓的“类型擦除”(Type Erasure),将所有的泛型表示(尖括号中的内容)都替换为具体的类型(其对应的原生态类型),就像完全没有泛型一样。

2. 泛型的作用

在泛型出现以前,我们考虑这么一个场景,当我们使用List只是存储字符串数据的时候,在泛型没有出现之前,则对应的代码如下:

List data = new ArrayList();
data.add("233");
data.add(123); // 加入整型数据

String s = (String) data.get(1);

在上面中,通过ArrayList创建了一个集合,这种声明方式主要有以下缺点:

  1. 存储数据的时候无法做数据类型校验
  2. 取数据的时候需要判断数据类型,以及需要做数据类型的转换
  3. 在实际使用过程中,易出错

基于以上的问题,当我们需要共用代码的时候,并且对输入数据类型做校验时,就引入了泛型

3. 泛型的使用方式

泛型的基本使用主要包含了三种方式:泛型类,泛型接口,泛型方法

3.1 泛型类

下面通过一个简单的实例查看泛型类的使用:

public class GenericType<T>{
    private T value;

    public GenericType(T value) {
        this.value = value;
    }

    public T getValue() {
        return this.value;
    }

    public void setValue(T t) {
        this.value = t;
    }
}

在这个实例中,主要包含了以下几个部分:

  • T只是标识,代表了具体的类型
  • 类中包含了T的成员变量,该成员变量的类型是在泛型类定义的时候确定
  • 在方法中可以根据泛型类型设置值和取值,也是根据泛型类定义的时候确认

通过以上的类型定义,我们具体的使用方式为:

public static void main(String[] args) {
    GenericType<Long> type = new GenericType<Long>(2L);
    type.setValue(123L);

    type.setValue("2323"); // 类型校验异常,报错
    System.out.println(type.getValue()); // 123
}

通过泛型的定义,我们通过<>的方式为泛型指定具体的类型,因此我们在使用setValue()方法可以帮助我们对类型进行校验,当我们设置setValue("2323")时,将会导致编译错误。

在泛型类型的时候,我们也可以同时为设置多个泛型标记,例如:

public class MultiGenericType<K, V, T> {
    private K key;
    private V val;
    private T t;

    public MultiGenericType(K key, V val, T t) {
        this.key = key;
        this.val = val;
        this.t = t;
    }

    public K getKey() {
        return key;
    }

    public void setKey(K key) {
        this.key = key;
    }

    public V getVal() {
        return val;
    }

    public void setVal(V val) {
        this.val = val;
    }

    public T getT() {
        return t;
    }

    public void setT(T t) {
        this.t = t;
    }
}

则使用方式和单个泛型的使用方式一样,对应的使用方式为:

public static void main(String[] args) {
        MultiGenericType<String, String, Integer> type = new MultiGenericType<>("2", "6", 3);
        type.setKey("234");
        type.setT(23);
        type.setVal("34");
    }

3.2 泛型接口

其实接口也是一个类型,所以泛型接口的使用方式和类型的使用方式是一样的,使用实例如下:

public interface GenericInterface<T> {

    T getVal();
}

则在使用该泛型接口的时候,可以传递具体类型或者也可以传递泛型标识,例如:

public class GenericInterfaceImpl implements GenericInterface<Integer> {
    @Override
    public Integer getVal() {
        return null;
    }
}

class GenericInterfaceImpl2<T> implements GenericInterface<T> {

    @Override
    public T getVal() {
        return null;
    }
}

3.3 泛型方法

泛型方法与类中的泛型声明是独立的体系,在实体方法或者静态方法上都可以声明泛型,例如:

public class GenericType<T>{
    private T value;

    public GenericType(T value) {
        this.value = value;
    }

    public GenericType() {}

    public T getValue() {
        return this.value;
    }

    public void setValue(T t) {
        this.value = t;
    }

    /**
     *
     * @param clazz 泛型T的class对象
     * @return T 确定了返回值为泛型类型
     * @param <T> 声明泛型类型
     */
    public static <T> T getObject(Class<T> clazz) {
        try {
            return clazz.newInstance();
        } catch (InstantiationException e) {
            throw new RuntimeException(e);
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }
    /**
     *
     * @param clazz 泛型T的class对象
     * @return T 确定了返回值为泛型类型
     * @param <T> 声明泛型类型
     */
    public <T> T get(Class<T> clazz) {
        return getObject(clazz);
    }
}

为了证明泛型方法和泛型类的关系,在上面的实例中,在泛型类中定义了两个泛型方法:

  • <T>表明了对应的方法为泛型方法,当在方法上声明就是泛型方法,在类型上声明就是泛型类
  • 方法返回值T, 表明了方法的返回值为T,并且根据定义的实际调用的类型返回
  • 方法参数Class<T>: 这里定义了T的具体的class对象,泛型T是无法被实例化的,因此需要具体的class对象创建T的具体实例

4. 泛型擦除

在泛型被编译之后,实际在编译后的字节码中是看不到泛型泛型信息,例如我们有如下泛型类声明:

public class Reference<T> {
    private T value;

    public void setValue(T value) {
        this.value = value;
    }

    public T getValue() {
        return value;
    }
}

我们通过查看字节码信息``javap -c Reference.class``, 有如下信息:

以上字节码编译过后可以看到,最终在字节码中存储的是Object的类型。

在上面泛型方法中,也可以通过javap -c GenericType.class的命令查看编译后的字节码信息:

通过以上的两个字节码的查看,证实了泛型在编译后被擦除的事实,实际上存储的是Object对象。但是在具有上边界和下边界上,却有些不同的地方,将在下面中介绍。

泛型擦除本身具有写局限性:

  1. <T>不能是基本类型,因为实际类型是Object, int无法转换为Object类型,只能使用Integer
  2. 无法获取带有泛型的Class对象
  3. 无法判断带泛型的类型。例如:t instanceof Pair<String>. 这种写法是不被允许的
  4. 不能实例化泛型标识T
  5. 不恰当的覆写方法。例如:public boolean equals(T t); 这个方法是不被允许的,因为T最终会被编译成为Object对象,而equals方法来自于Object对象,因此这种覆写不会被允许。

5. 泛型的继承和子类型

在Java中,只要类型存在继承或者实现关系,则可以将子类分配给父类使用。这也是多态使用的一种方式,在泛型中,这种多态也是支持。例如定义一下泛型类:

package com.java.demo.generic;

/**
 * @author xianglujun
 * @date 2023/2/27 16:39
 */
public class GenericSubTypeDemo<T> {
    private T val;

    public  GenericSubTypeDemo() {}
    public GenericSubTypeDemo(T val) {
        this.val = val;
    }

    public T getVal() {
        return val;
    }

    public void setVal(T val) {
        this.val = val;
    }

    public static void main(String[] args) {
        GenericSubTypeDemo<Number> demo = new GenericSubTypeDemo<>();
        demo.setVal(12); // 设置Integer
        demo.setVal(12L); // 设置Long
        demo.setVal(123.3D); // 设置Double
       GenericSubTypeDemo<Integer> intDemo = new GenericSubTypeDemo<>();
       demo = intDemo; // 编译错误
    }
}

因为Integer, Double, Long都是Number的子类,因为向Number中设置值都是正常的。但是GenericSubTypeDemo<Integer>与GenericSubTypeDemo<Number>之间并不存在继承关系,因此不能直接赋值。

6. 泛型类及其子类

在上面的实例中,泛型类或者泛型接口是可以被继承或者实现的,我们以JDK框架中的Collection为例:

public static void main(String[] args) {
        ArrayList<String> arrayList = new ArrayList<>();
        List<String> list = arrayList;
        Collection<String> collection = list;
    }

在泛型类继承和实现上,只要他们的泛型类型一致,则本身的继承关系没有发生改变。则对应关系为:

7. 泛型上下边界

考虑一下类型,当我们实现两个数相加时,具体实现如下:

public class NumberCountUtil {

    public static double add(Pair<Number> pair) {
        return pair.getFirst().doubleValue() + pair.getLast().doubleValue();
    }

    public static class Pair<T> {
        private T first;
        private T last;

        public Pair(T first, T last) {
            this.first = first;
            this.last = last;
        }

        public T getFirst() {
            return first;
        }

        public T getLast() {
            return last;
        }
    }
}

在实际调用的时候,通过Pair<Number>是可以正常编译和运行的:

double sum = add(new Pair<Number>(1,3));

但是当将参数替换为Pair<Integer>时,则会编译出错:

double sum = add(new Pair<Integer>(1,3));

编译时提示:

java: 不兼容的类型: com.java.demo.generic.NumberCountUtil.Pair<java.lang.Integer>无法转换为com.java.demo.generic.NumberCountUtil.Pair<java.lang.Number>

这是因为,我们在上面讨论继承关系时,Pair<Integer>并不是Pair<Number>的子类,因此不能够直接传入参数。

7.1 泛型上边界extends

为了解决以上的问题,我们可以通过<? extends Number>方式来确定泛型的上边界,该边界使得接收的参数类型变得更加广泛,可以接收:

  • Number本身作为参数
  • Number的子类作为参数

我们将上面add方法进行改造,让add方法能够接收所有数字类型的参数,并进行计算:

public static double add(Pair<? extends Number> pair) {
        return pair.getFirst().doubleValue() + pair.getLast().doubleValue();
    }

则我们使用时,则Pair<Integer>也可以作为参数传递:

double sum = add(new Pair<Integer>(1,3));

我们再将add方法做些改变,修改对应的声明如下:

public static double add(Pair<? extends Number> pair) {
        pair.setFirst(new Integer(pair.getFirst().intValue() + 100)); // 编译报错
        return pair.getFirst().doubleValue() + pair.getLast().doubleValue();
    }

这里会导致在编译的时候无法通过,主要原因:

  • 当在调用add方法的时候传入Pair<Double>时,这时是满足上限的约定的
  • 但是在方法中继续调用setFirst方法的时候,Pair<Double>和Pair<Integer>两者类型不匹配会导致异常

这里就引出了泛型上边界的一个限制

方法参数签名setFirst(? extends Number)无法传递任何Number的子类型给setFirst(? extends Number)

通过上面的分析,我们的到泛型边界的一个特别重要的作用:

在传递的方法参数的时候,可以防止方法向集合中新增元素,或者修改同样具有泛型边界定义的元素的值,这是对对象的数据具有一定的保护作用。

当我们对上面的代码进行反编译如下:

可以看出,泛型上边界<? extends Number>使用的时候,在编译时,使用的是Number类型,因此和不指定上边界时,使用Object存在一定的差别

7.2 泛型下边界super

在泛型上边界中,我们知道Pair<Integer>并不是Pair<Number>的子类,因此在如下方法中:

public static void set(Pair<Integer> pair, Integer first, Integer last) {
       pair.setFirst(first);
       pair.setLast(last);
   }

在使用set方法的时候,我们不能传入Pair<Number>的定义,然后正确执行该方法。

在java中可以使用泛型下边界super来实现, 这样我们就可以传入Pair<Number>, Pair<Object>, Pair<Integer>这样的参数。我们使用super改造上面的方法:

public static void set(Pair<? super Integer> pair, Integer first, Integer last) {
    pair.setFirst(first);
    pair.setLast(last);
}

通过super改造后的方法,则表示了可以接收Integer以及Integer的父类声明的Pair类型

则我们可以正常使用一下代码:

public static void main(String[] args) {
    Pair<Integer> pair = new Pair<Integer>(1,3);
    double sum = add(pair);
    set(pair, 1, 3);

    Pair<Number> numberPair = new Pair<>(23, 14);
    set(numberPair, 12, 16);
}

这里重点关注下Pair中的方法,在上面的声明中,实际上对应的setFirst的方法为:

public void setFirst(? super Integer)

因此,这个时候我们传入Integer类型的参数进入是没有问题,当我们尝试在执行中加入一下代码时:

Integer l = pair.getLast();

将会造成编译报错,报错信息为:

java: 不兼容的类型: capture#1, 共 ? super java.lang.Integer无法转换为java.lang.Integer

这里主要原因在于super的用法,我们考虑在向setFirst设置参数时,是可以设置Number, Integer, Object类型的参数,我们设置Number的参数的时候,然后getFirst用Integer接收,将导致类型的转换异常,因此这里不能直接使用Integer来接收结果值。但是可以通过Object来接收结果。

因此,以上报错的地方,可以修改为:

Object l = pair.getLast();

因此,这里将不会产生编译异常。这主要是考虑到了类型的转换带来的隐藏的问题。

因此,我们可以得出结论,在<? super Integer>声明的泛型,在方法内部只能够写,不能够读。当然这里要除开Object读的情况。

我们还是将字节码文件进行反编译,看下编译器如何处理super这种下边界的限制:

下边界编译的处理,最终是处理成为了Object类型,这里也可以解释为什么getLast方法不能直接使用Integer来接收了。

7.3 对比extends和super通配符

<? extends T>类型和<? super T>类型的区别在于:

  • <? extends T>允许调用读方法T get()获取T的引用,但不允许调用写方法set(T)传入T的引用(传入null除外);
  • <? super T>允许调用写方法set(T)传入T的引用,但不允许调用读方法T get()获取T的引用(获取Object除外)。

一个是允许读不允许写,另一个是允许写不允许读。

7.4 PECS原则

在具体使用场景中,extends和super两者该如何选择呢?主要遵循:Producer Extends Consumer Super:

  • 如果需要返回泛型标识T,则为生产者,这个时候就需要使用extends进行声明
  • 如果需要写入标识T,则为消费者,这时就需要使用super声明

可以查看下Collections#copy方法:

public static <T> void copy(List<? super T> dest, List<? extends T> src) {
   ..
}

在这个实例中:

  • 需要返回T的src是生产者,因此使用extends声明
  • 需要写入T的dest是消费者,因此使用super声明

7.4 无限通配符

无限通配符是对泛型没有做限定,例如声明List<?>, Pair<?> 这种就是没有限定的通配符。这种通配符在没有指定extends或者super的时候,同事具有两者的缺点:

  • 不允许调用set(T)方法并传入引用(null除外);
  • 不允许调用T get()方法并获取T引用(只能获取Object引用)。
public static void set(Pair<?> pair, Integer obj) {
    pair.setLast(obj); // 编译异常
    pair.setLast(null); // 编译通过
    Object r = pair.getLast(); // 正常
    Integer i = pair.getLast(); // 编译异常
}

因此这种实现,既不能实现写入,也不能读。这可以通过这种方式判断值是否为null。

在大多数情况下,<?>可以使用<T>来进行替换。

但是<?>有个最大的特点,Pair<?>是所有Pair<T>的超类:也就是说,所有的Pair泛型都可以赋值给Pair<?>

8. 泛型的多态

多态为java的特性,泛型的多态说的就是在类中声明了泛型,然后子类继承或者实现泛型类。我们定义泛型类如下:

public class ObjReference<T> {

    private T val;

    public void setVal(T val) {
        this.val = val;
    }

    public T getVal() {
        return this.val;
    }
}

定义一个子类:

public class IntegerReference extends ObjReference<Integer> {
    @Override
    public void setVal(Integer val) {
        super.setVal(val);
    }

    @Override
    public Integer getVal() {
        return super.getVal();
    }
}

从上面可以看出,在泛型多态上面,其实是方法的重载,而不是重写。如果是方法的重新,那么根据泛型的擦除,那么父类中的方法定义为:

public void setVal(Object obj) {}

那么,我们可以写一个类,看能否调用到父类方法:

public static void main(String[] args) {
    IntegerReference reference = new IntegerReference();
    reference.setVal(1);
    reference.setVal(new Object()); // 编译报错
}

从调用方法可以知道,直接调用父类的Object方法会导致编译报错,那么可以确定确实是方法重载。那么jvm是如何解决这样的事情的呢?答案就是桥接方法

我们通过javap的命令,IntegerReference类型的字节码反编译,得到以下的信息:

在上面的字节码反编译后,我们可以看到setVal和getVal方法分别有两个。首先我们来看setVal方法

setVal(Integer)和setVal(Object)两个方法,而setVal(Object)方法是由编译器生成,在指令中,可以看到做了这么几件事情:

  • 类型的检查,判断传入的类型是否为Integer类型
  • 类型检查通过后,调用setVal(Integer)方法执行

这就是桥接方法的意义,主要作用就在于最终调用实现类的重载方法,这也是JVM为了解决泛型方法重载所采用的策略。

这里我们主要关注下getVal方法,可以看到getVal方法的定义其实很像,唯一不同在于其返回值:

public Integer getVal() {}

public Object getVal() {}

在我们平常的开发中,这样的方法定义其实会导致编译不通过的,但是JVM在为了解决这种重载策略时,却能够使用这种定义,这主要是因为JVM中对方法唯一性定义是方法名称+参数+返回值,因此这种编译器加入的代码,在JVM也是能够通过的。

以上就是关于泛型知识的内容,后面将主要介绍泛型和反射的知识,主要讲解集中Type的使用和如何获取到反正真正的类型。

 

参考文章

  1. https://www.liaoxuefeng.com/wiki/1252599548343744/1265105920586976
  2. https://waylau.gitbooks.io/essential-java/content/docs/generics.html
  3. https://pdai.tech/md/java/basic/java-basic-x-generic.html

专注着

一个奋斗在编程路上的小伙

文章评论