MENU

Java 中的类型擦除、协变和逆变

2020 年 05 月 09 日 • 阅读: 7658 • 后端

什么是类型擦除

Java 泛型依赖编译器实现,只存在于编译期,JVM 中没有泛型概念。具体来说,每定义一个泛型类,编译阶段都会将类型参数替换为具体类型,生成对应的原生类。到运行时,类型参数已不存在。

例如,下面的泛型类:

public class GenericType<T> {
    private T v;

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

    public T getV() {
        return v;
    }

    public void setV(T v) {
        this.v = v;
    }

    public static void main(String[] args) {
        GenericType<String> gt = new GenericType<>("");
        System.out.println(gt.getV());
    }
}

在编译后生成的原生代码就像这样:

public class GenericType {
    private Object v;

    public GenericType(Object v) {
        this.v = v;
    }

    public Object getV() {
        return v;
    }

    public void setV(Object v) {
        this.v = v;
    }

    public static void main(String[] args) {
        GenericType gt = new GenericType("");
        System.out.println((String)gt.getV());
    }
}

为什么要擦除类型

  • 在 Java 诞生 10 年后,才想实现类似 C++ 模板的概念,即 泛型
  • Java 的类库是 Java 生态中非常宝贵的财富,必须保证 向后兼容(即现有代码依旧合法)和 迁移兼容(泛化代码和非泛化代码可互相调用)

基于以上背景,Java 设计者采取了 类型擦除 这种折中的实现方式。

类型参数替换规则

编译后,类型参数会被擦除并替换为 最小上限,如果没有指定,则上限为 Object,就像上面的代码。可通过 <T extends Father> 表达式指定 T 的最小上限,就像下面这样。

class Father {}
class Son extends Father {}

public class GenericType<T extends Father> {
    private T v;

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

    public T getV() {
        return v;
    }

    public void setV(T v) {
        this.v = v;
    }

    public static void main(String[] args) {
        GenericType<Son> gt = new GenericType<>(null);
        gt.setV(new Son());
        System.out.println(gt.getV());
    }
}

则编译后 T 会被替换为最小上限 Father。

class Father {}
class Son extends Father {}

public class GenericType {
    private Father v;

    public GenericType(Father v) {
        this.v = v;
    }

    public Father getV() {
        return v;
    }

    public void setV(Father v) {
        this.v = v;
    }

    public static void main(String[] args) {
        GenericType gt = new GenericType(null);
        gt.setV(new Son());
        System.out.println((Son)gt.getV());
    }
}

编译器做了两件事,第一,保证 传入值 类型为 声明类型,否则无法通过编译。如声明 GenericType<Son> gt,则传入值必须为 Son 或其子类。对应 GenericType 类,虽然其 构造器setV 方法参数被擦除为 Father,但因声明为 GenericType<Son> gt,编译器只允许传入 Son 及其子类

第二,在所有调用 传出 值方法的地方插入 转型 字节码,同样转为 声明类型。对应 GenericType 类,因声明为 GenericType<Son> gt,每个调用 getV 方法处,都会插入转为 Son 类型的字节码。

什么是协变

简单来说,协变即 子类型 ≦ 基类型,赋值时可以 自动向上转型。Java 数组支持协变,如下代码中,Son[] 型数组可以赋值给 Father[] 型,因 Son 是 Father 的子类,但编译器仍允许该引用放入 Father 对象,这将导致运行时异常。

class Father {}
class Son extends Father {}

public class GenericType {
    public static void main(String[] args) {
        Father[] fathers = new Son[2];
        fathers[0] = new Son();
        // 下行可以通过编译,但会抛 ArrayStoreException
        fathers[1] = new Father();
    }
}

Java 非通配符泛型不支持协变,这意味着以下语句无法通过编译。

List<Father> fathers = new ArrayList<Son>();

上边界限定通配符泛型 支持,以下语句可以通过编译。

List<? extends Father> fathers = new ArrayList<Son>();

但此时,编译器无法得知 List<? extends Father> 存储的究竟是 Father 还是它的某个 子类 对象,但一定是某一个,所以它不允许向其中添加任何对象,除了 null。可以理解为编译器不知道 ? 对应的特定类是谁,无法保证添加进去的对象可以转型为该特定类,所以不允许添加。

// 以下 3 行无法通过编译
fathers.add(new Son());
fathers.add(new Father());
fathers.add(new Object());

// 只能向其中添加 null
fathers.add(null);

与此同时,编译器允许从中取出元素赋值给 Father 引用。正如上面所说,List<? extends Father> 中存储的是 Father,或其某个子类的对象,所以取出后上转给 Father 是安全的,且编译时会自动生成转型字节码。

// 但可取出 Father 或它的某个子类对象,自动上转为 Father 型
Father father = fathers.get(0);

什么是逆变

与协变相反,逆变即父类可下转为子类。Java 数组和非通配符泛型都不支持逆变,比如下面的代码都无法通过编译。

Son[] sons = new Father[100];
List<Son> son = new ArrayList<Father>();

下边界限定通配符泛型 支持,以下语句可以通过编译。

List<? super Son> sons = new ArrayList<Father>();

List<? super Son> 表示该 List 中持有某个未知具体类型,它可能是 Son,也可能是 Son 的父类。这意味着向其中放入 Son 或其子类 对象都是合法的。而放入 Father 对象不合法,因为那个具体父类不一定是 Father 或其子类。

class Father {}
class Son extends Father {}
class Grandchild extends Son {}

public class GenericType {
    public static void main(String[] args) {
        List<? super Son> sons = new ArrayList<Father>();
        // 下面两行能够通过编译
        sons.add(new Son());
        sons.add(new Grandchild());

        // 下面不行
        sons.add(new Father());
    }
}

与此同时,从 List<? super Son> 中不一定可取出能下转为 Son 的对象,因为它的类型是 Son 或其某个父类,父类转子类是不安全的。它也不一定能取出可转为 Father 的对象,因为那个父类不一定是 Father。唯一能做的是将取出的对象上转为 Object

// 下面两行不能通过编译
Son son = sons.get(0);
Father father = sons.get(1);

// 下面可以
Object object = sons.get(2);

无边界通配符泛型

<?> 表示某种特定类型,但编译器并不知道是哪种类型,这意味着向此种容器中放任何对象都是非法的,除了 null。与此同时,从中取出的对象只能上转为 Object,因为无法确定它的类型。

List<?> list = new ArrayList<Son>();
// 下面两行无法通过编译
list.add(new Son());
list.add(new Object());

// 下行合法
list.add(null);

// 下行无法通过编译
Son son = list.get(0);

// 下行合法
Object object = list.get(0);

如果不使用泛型,则容器使用 Object[] 存放元素,它可以接收任何类型对象,但取出时需要强转为具体类型,以下操作均合法。

List list = new ArrayList();
list.add(new Son());
list.add(new Father());

Son son = (Son) list.get(0);

List<?> listList list 的另一个区别在于,前者中的所有元素类型相同,而后者并无此要求,因为任何对象均可上转为 Object。

参考资料

  • Chapter 15, Generics —— Thinking in Java, Version 4
TG 大佬群 QQ 大佬群

返回文章列表 文章二维码
本页链接的二维码
打赏二维码
添加新评论

Loading captcha...

已有 2 条评论
  1. zzzmh zzzmh   Windows 7 x64 Edition  Google Chrome 83.0.4103.61

    大佬厉害了,啥都懂啊!@(滑稽)

    1. LOGI LOGI   Windows 10 x64 Edition  Google Chrome 83.0.4103.61

      @zzzmh说笑了,大佬