栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

Java 泛型的原理以及泛型的补偿

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

Java 泛型的原理以及泛型的补偿

繁琐的语法和泛型擦除原理

Java泛型的语法有很多看起来毫无意义的规则和写法,例如:



(T[])new Object[size]
// ...

这些本质上都是由于Java泛型机制的实现原理——擦除。
所谓的擦除:

  • 所有的泛型在编译之后都会被擦除,替换为它们的非泛型上界,例如List,其实实际的类型是Object
  • Java的泛型指在编译之前多了额外的检查

这看起来以一种很轻量优雅的方式实现的泛型,但实际上带来的诸多不便,包括功能上的损失,有时候不像直觉一样满足期望。特别是跟类似c++这种真正的泛型对比起来。
之所以要这么设计,本质上是考虑了Java的向后兼容性,设计团队在对泛型进行研究决策后认为擦除是唯一能满足需求的实现方式——允许Java向早期发布的软件兼容,最终的编译之后的代码毫无区别,软件的使用者无法感知泛型的存在。
这也就给后续增加了很多补偿的语法。

泛型

泛型的核心要义是:

  • Java泛型只在编译前检查
  • Java泛型在运行时会被完全擦除为原生类型,在运行时,它压根无法得知任何泛型信息
  • Java泛型的语法,编码规则尽管看起来非常像那么回事,仿佛在告诉我们它就是泛型。但是我们要时刻提醒自己:不,它只是一个Object
泛型补偿

正由于Java泛型会被完成擦除,所以新建泛型对象,新建泛型数组,instance of等等,这些都是无法编译通过的,所以说Java的泛型是受限的,有时候也会令人失望,如果想按照直觉对他们进行使用的话。

// 编译失败
new T();
new T[10];
if(T instance of Fruit){}

因而也演变出一系列补偿的语法。

对象创建

Java泛型无法新建对象,所以很多时候我们都要给出一个工厂,用于创建对象。

使用class对象实现工厂

使用类对象可以通过反射新建对象,这也是Java泛型中经常需要传入xxx.class的原因。

// 泛型补偿:使用类对象工厂
class ClassAsFactory {
    private Class type;

    public ClassAsFactory(Class type) {
        this.type = type;
    }

    public T create() {
        try {
            return type.newInstance();
        } catch (Exception e) {
            throw new RuntimeException();
        }
    }
}

这种方式的缺点是,当该泛型对象指定的类型没有默认构造器,就会运行失败,例如Integer。

使用自定义对象工厂

Java的开发团队更推荐使用自定义工厂。

// 泛型补偿:使用自定义工厂
interface Factory {
    T create();
}
class IntegerFactory implements Factory {

    @Override
    public Integer create() {
        return new Integer(0);
    }
}
数组创建

上文中也提到,没法使用泛型直接创建数组。所以在泛型中,唯一创建数组的方式就是创建上界类型数组,然后强制类型转换。

public class GenericArray {
    private T[] data;

    public GenericArray(int size) {
        // 编译时警告未检查类型转换
        data = (T[]) new Object[size];
    }

    public T[] getData() {
        return data;
    }

    public static void main(String[] args) {
        GenericArray array = new GenericArray<>(10);
        // 编译通过,运行无法通过,因为擦除后的实际类型是Object
        Integer[] nums = array.getData();
    }
}

但上述main中的代码,编译通过,但是运行会失败。为什么呢?我们要记住,private T[] data实际运行的类型是Object,我们当然无法让Integer[]指向Object数组!
所以,正常情况下,更推荐在内部直接使用Object数组,避免忘记这件事,导致运行时出错。

class GenericArray2 {
    Object[] data;

    public GenericArray2(int size) {
        // 编译时警告未检查类型转换
        data = new Object[size];
    }

    public T getAt(int i) {
        return (T) data[i];
    }

    public T[] getData() {
        return (T[]) data;
    }

    public static void main(String[] args) {
        GenericArray2 array = new GenericArray2<>(10);
        // 编译通过,运行无法通过,因为擦除后的实际类型仍是Object
        // 但建议集合内部最好一直用Object
        //Integer[] nums = array.getData();
    }
}

但类似需求其实是有解的,最佳实践是以下这样,通过class对象来新建指定泛型的数组。

class GenericArray3 {
    T[] data;

    // 当警告可以预期的时候,最好将它关掉,这样一来真正出现警告的时候我们就可以找到
    // 其次,使用@SuppressWarnings,需要尽可能缩小范围,聚焦在出问题的地方
    @SuppressWarnings("unchecked")
    public GenericArray3(Class type, int size) {
        // 编译时警告未检查类型转换
        data = (T[]) Array.newInstance(type, size);
    }

    public T getAt(int i) {
        return (T) data[i];
    }

    public T[] getData() {
        return (T[]) data;
    }

    public static void main(String[] args) {
        GenericArray3 array = new GenericArray3<>(Integer.class, 10);
        // 编译通过,数组的补偿形式
        Integer[] nums = array.getData();
    }
}
extends和super限定边界 为什么需要限定边界

由于类型会被擦除,所以一旦你想使用泛型T调用任何方法的时候,都会无法调用。

class Demo {
	T data;
    public Demo() {
        // 编译不通过,无法找到方法,在c++的泛型中,类似的写法就可以实现
        // 只要T提供了方法func
        T.func();
    }
}

所以,Java的解法是允许为泛型设定边界,告诉编译器,T到底限制在什么范围内。

class Demo 

这样一来,T将可以调用类A(或接口A),接口B,接口C中的方法。

正确地使用extends和super

而很自然的,的意思是A及A的子类,而则是A即A的父类。
但使用的时候并不像它看起来那么自然,有时候会出现很多意想不到、反直觉的问题。

public class Main {

    public static void main(String[] args) {
        List fruits = new ArrayList<>();
        fruits.add(new Apple());
        fruits.add(new Fruit());
        
        // 1. extends
        List extFruits = fruits;
        // 编译失败,add(? extends Fruit),无法确定extFruits动态绑定的实际类型是什么,可能是某种水果的列表,比如菠萝,所以无法添加
        extFruits.add(new Fruit());
        extFruits.add(new Apple());
        // 正常执行
        Fruit fruit = extFruits.get(0);
        // 编译失败,get(? extends Fruit),无法确定extFruits动态绑定的实际子类型是什么,可能是其他水果
        Apple apple = extFruits.get(0);
        
        // 2. super
        List supFruits = fruits;
        // 编译成功,add(? super Apple),可以看出add(Apple的某个父类)
        // Apple对象符合向上转型的条件,可以添加
        supFruits.add(new Apple());
        // Banana是Apple的子类,当然也是Apple某个父类的子类,符合向上转型的条件,可以添加
        supFruits.add(new Banana());
        // 编译失败,add(Apple的某个父类),无法确定supFruits动态绑定的实际父类是什么,可能压根和Fruit没有关系,所以这里无法通过
        supFruits.add(new Fruit());
        // 编译失败,get(int),返回的是Apple或Apple的某个父类,所以需要强行向下转型
        Apple obj = supFruits.get(0);
    }

}
class Fruit { }

class Apple extends Fruit { }

class Banana extends Apple { }

分析一下,这一段代码会编译失败。List声明了一个列表引用,这个列表保存的是Fruit以及Fruit的子类。但事实情况是,往列表里添加Fruit,或者添加它的子类对象,都会失败,编译器不允许这种情况。
为什么呢?它的重点在于,List保存了Fruit的一种子类列表,但它是运行时才会被确定引用的是哪种子类列表(这里fuits可以是随机的,可能是苹果,有可能是菠萝),于是对于编译器来说,它并不知道运行时列表是什么列表,于是它就必须禁止这种情况。

一个结论是,List声明的列表,只能读不能写,原因就是上述所分析的。

// 1. extends
List extFruits = fruits;
// 编译失败,add(? extends Fruit),无法确定extFruits动态绑定的实际类型是什么,可能是某种水果的列表,比如菠萝,所以无法添加
extFruits.add(new Fruit());
extFruits.add(new Apple());

同理,List表示列表里保存了Apple以及Apple的父类,但实际上只能添加Apple以及Apple的子类——如果你添加Apple可以成功,Banana也可以成功,但是你添加Fruit对象则会失败。

List supFruits = fruits;
// 编译成功,add(? super Apple),可以看出add(Apple的某个父类)
// Apple对象符合向上转型的条件,可以添加
supFruits.add(new Apple());
// Banana是Apple的子类,当然也是Apple某个父类的子类,符合向上转型的条件,可以添加
supFruits.add(new Banana());
// 编译失败,add(Apple的某个父类),无法确定supFruits动态绑定的实际父类是什么,可能压根和Fruit没有关系,所以这里无法通过
supFruits.add(new Fruit());

我们来分析一下,这种正确的分析思路非常重要。

  • List声明列表引用,该列表保存的是Apple以及Apple的基类。
  • add的实际方法为add(? super Apple),其中参数代表Apple或Apple的一个父类。由于多态机制,父类能够持有子类引用,所以凡是Apple或Apple的子类都能被add。
  • 反直觉的,Apple的某个父类Fruit的对象无法被add。这是因为supFruits的引用是动态绑定的,有可能它绑定的是另一个父类列表,例如可能是new ArrayList,这样一来编译器保证Fruit的对象能够安全添加。

一个结论,List可以写(Apple以及Apple的子类)无法读(只能读Object对象,不然就必须向下转型)。

super和extends混用
public class Collections {
    public static  void copy(List dest, List src) {
        for (int i=0; i 

看参数的定义就让人非常迷惑,也不好理解。其中本质上区别在于哪个作为读,哪个需要写。
一个通用的原则是,dest发生了修改(写),所以它应该是super;
src直接传入,所以是读取。

通配符

看起来List就等于List一模一样,但事实上它不像看起来那么没用。

  • 声明它是想使用泛型,而不是用的原生类型
  • 使用List之后无法再像表里添加任何对象,这很好理解,代表某种特定类型,编译器无法确保它是什么,它能否安全添加。
  • List是所有List的超类
  • 通常情况下List可以用List替换掉

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/425205.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号