Java泛型 - 协变与逆变的个人理解

泛型概述

泛型是现代编程语言中的重要特性,简单来说就是不必指定类型,可以写出非特定类型,模板化的代码,提高代码重用率。

泛型应用最广的地方应该就是容器类了。在Java的容器类中大量的使用了泛型。

例如ArrayList

java 复制代码
public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
    @java.io.Serial
    private static final long serialVersionUID = 8683452581122892189L;

    /**
     * Default initial capacity.
     */
    private static final int DEFAULT_CAPACITY = 10;

    /**
     * Shared empty array instance used for empty instances.
     */
    private static final Object[] EMPTY_ELEMENTDATA = {};

    /**
     * Shared empty array instance used for default sized empty instances. We
     * distinguish this from EMPTY_ELEMENTDATA to know how much to inflate when
     * first element is added.
     */
    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

    /**
     * The array buffer into which the elements of the ArrayList are stored.
     * The capacity of the ArrayList is the length of this array buffer. Any
     * empty ArrayList with elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA
     * will be expanded to DEFAULT_CAPACITY when the first element is added.
     */
    transient Object[] elementData; // non-private to simplify nested class access

    /**
     * The size of the ArrayList (the number of elements it contains).
     *
     * @serial
     */
    private int size;

可以看到ArrayList存放数据本质就是一个Object数组elementData,因此,如果不使用泛型,直接存储Object。比如将String类型放入容器,那么在get函数取出元素时类型为Object,这时候就需要强制转换。

java 复制代码
ArrayList list = new ArrayList();
list.add("test");
String str = (String) list.get(0);

如何这个容器中还存在其他类型的元素,那么取出元素时就很容易出现ClassCastException异常。

java 复制代码
list.add(123);
str = (String) list.get(1);

当然,如果只写一个专门存储String或者Integer的ArrayList也可以,但是这样就需要给每一个类型都写单独编写,更别提还有自己写的类。

因此,泛型就出现了,泛型类可以在编译阶段就检查类型,这样就不会导致类型转换的异常。

下面是一个最简单的泛型类

java 复制代码
public class Generic<T>{ 
    private T val;

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

    public T getVal(){
        return val;
    }
}

泛型擦除

泛型擦除是指Java中的泛型只在编译期有效,在运行期间会被删除。

如下面这段代码

java 复制代码
public class Foo {  
    public void test(List<String> stringList){  

    }  
    public void test(List<Integer> integerList) {  

    }  
}  

这段代码会报错,方法不能重载,原因就是上面两个方法,在编译后被泛型擦除,最后都是

java 复制代码
public void test(List) {}

因此不能区分两个函数。

泛型类的继承

泛型类的继承关系不是由泛型类型决定的,如List<Integer>和List<Number>,虽然Integer继承自Number,但是List<Integer>和List<Number>并没有继承关系。

要想使两个泛型类具有继承关系,只能使两个泛型类本身之间继承,或实现接口。

如上面的ArrayList就继承了AbstractList<E<以及List<E<接口。

泛型的逆变和协变

先从一个数组说起,Java的数组是协变的。

看下面这段代码

java 复制代码
public class Test {
    public static void main(String[] args) {
        Number[] arr = new Integer[2];
        arr[0] = 1;
        arr[1] = 0.5;
    }
}

这段代码在编译器并不会出错,但是一旦运行,将会抛出一个异常

javastacktrace 复制代码
Exception in thread "main" java.lang.ArrayStoreException: java.lang.Double
    at Test.main(Test.java:5)

这是因为Integer是Number的子类型,因此Integer[]也是Number[]的子类型,这样的性质被称为协变,在编译器并没有检查出错误。

但是在运行时,jvm虚拟机发现这个arr其实是一个Integer类型的数组,不是Number类型,所以不能存放进入double类型的数字,因此抛出了一个异常。

泛型的不变性

因此,在吸取了上面的教训之后,泛型被设计为不变,也就是说,List<Integer>并不是List<Number>的子类型。

这样在编译器就可以检查出错误,防止运行期再报错。

但是这样就引入一个新的问题,如何才能实现协变呢。

协变在Java中还是很常用的,比如我只想要一个Fruit集合,里面存放着水果,但我不想管里面到底存放的是哪种水果。

java 复制代码
public void consume(List<Fruit> list) {
    ......
}

这时,泛型的不变性就带来了麻烦,加入我现在有一个List<Apple>,因为List<Fruit>并不是List<Apple>的父类型,参数就传递不进去。

java 复制代码
List<Apple> appleList = new ArrayList<Apple>;
consume(appleList); // 报错

因此,在泛型中如何实现协变就成为了一个问题。

还有一种情况,如果我们希望往List<Object>中放水果,使用一个produce函数将所有List<Apple>或者List<Banana>的元素全部添加到List<Object>,但又希望在produce函数中向容器添加非Fruit的其他元素时进行检查并报错,这时候就需要逆变。

泛型通配符

要实现泛型协变和逆变,这时通配符 ? 就派上用场了。

  • <? extends>实现了泛型的协变
  • <? super>实现了泛型的逆变

在上面的代码中,假如在consume函数中我们想传入参数,就需要把List<Fruit>改为List<? extends Fruit>。这样就不会产生报错了。

List<? extends Fruit>,其中<? extends Fruit>代表的类型为:Fruit及其子类型,此时传入List<Apple>就没有问题了。

但是当List<Apple>协变为List<? extends Fruit>之后,就不能往容器中再放入元素了。

原因在于,当容器协变后,List<? extends Fruit>中的类型不能再被确定为Apple,<? extends Fruit>虽然包含Apple,但是并不特指为Apple。因此,如果放入一个其他的类型,比如Banana,那么在使用上一个List<Apple>进行读取的时候就会出现类型转换错误。

同样的,如果希望往Fruit中放水果,就可以使用<? super Fruit>让List<Object>逆变为List<? super Fruit>,这样在函数中就可以调用add方法。

从上面的例子可以看出,extends确定了泛型的上界,而super确定了泛型的下界。

PECS

究竟什么时候使用extends,什么时候使用super。也就是PECS

PECS: producer-extends, consumer-super.

生产者使用extends,因为协变只可读取,不可写入。消费者使用super,因为super写入可以保证类型检查。

在Collections中的copy函数就很好地诠释了PECS

java 复制代码
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
    int srcSize = src.size();
    if (srcSize > dest.size())
        throw new IndexOutOfBoundsException("Source does not fit in dest");

    if (srcSize < COPY_THRESHOLD ||
        (src instanceof RandomAccess && dest instanceof RandomAccess)) {
        for (int i=0; i<srcSize; i++)
            dest.set(i, src.get(i));
    } else {
        ListIterator<? super T> di=dest.listIterator();
        ListIterator<? extends T> si=src.listIterator();
        for (int i=0; i<srcSize; i++) {
            di.next();
            di.set(si.next());
        }
    }
}

在这里,src使用extends进行协变,只可读取,dest使用super进行逆变,保证写入的类型检查。

相关推荐
xiaolingting2 小时前
Java 二叉树非递归遍历核心实现
java··二叉树非递归遍历
嘵奇2 小时前
深入解析 Java 8 Function 接口:函数式编程的核心工具
java·开发语言
一路向北North4 小时前
IDEA加载项目时依赖无法更新
java·ide·intellij-idea
小萌新上大分5 小时前
SpringCloudGateWay
java·开发语言·后端·springcloud·springgateway·cloudalibaba·gateway网关
直视太阳6 小时前
springboot+easyexcel实现下载excels模板下拉选择
java·spring boot·后端
Code成立6 小时前
《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》第2章 Java内存区域与内存溢出异常
java·jvm·jvm内存模型·jvm内存区域
一 乐6 小时前
实验室预约|实验室预约小程序|基于Java+vue微信小程序的实验室预约管理系统设计与实现(源码+数据库+文档)
java·数据库·微信小程序·小程序·毕业设计·论文·实验室预约小程序
程序媛学姐6 小时前
SpringRabbitMQ消息模型:交换机类型与绑定关系
java·开发语言·spring
努力努力再努力wz6 小时前
【c++深入系列】:类与对象详解(中)
java·c语言·开发语言·c++·redis
兰亭序咖啡6 小时前
学透Spring Boot — 009. Spring Boot的四种 Http 客户端
java·spring boot·后端