前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >面试官问我:“泛型擦除是什么,会带来什么问题?”

面试官问我:“泛型擦除是什么,会带来什么问题?”

作者头像
ClericYi
发布2020-06-23 15:14:04
2.1K0
发布2020-06-23 15:14:04
举报
文章被收录于专栏:ClericYi's Blog

什么是泛型擦除?

其实我们很常见这个问题,你甚至经常用,只是没有去注意罢了,但是很不碰巧这样的问题就容易被面试官抓住。下面先来看一段代码吧。

代码语言:javascript
复制
List list = new ArrayList();
List listString = new ArrayList<String>();
List listInteger = new ArrayList<Integer>();

这几段代码简单、粗暴、又带有很浓厚的熟悉感是吧。那我接下来要把一个数字1插入到这三段不一样的代码中了。

作为读者的你可能现在已经黑人问号了????你肯定有很多疑问,这明显不一样啊,怎么可能。

代码语言:javascript
复制
public class Main {
    public static void main(String[] args) {
        List list = new ArrayList();
        List listString = new ArrayList<String>();
        List listInteger = new ArrayList<Integer>();

        try {
            list.getClass().getMethod("add", Object.class).invoke(list, 1);
            listString.getClass().getMethod("add", Object.class).invoke(listString, 1);
            // 给不服气的读者们的测试之处,你可以改成字符串来尝试。
            listInteger.getClass().getMethod("add", Object.class).invoke(listInteger, 1);
        } catch (Exception e) {
            e.printStackTrace();
        }
        System.out.println("list size:" + list.size());
        System.out.println("listString size:" + listString.size());
        System.out.println("listInteger size:" + listInteger.size());
    }
}

不好意思,有图有真相,我就是插进去了,要是你还不信,我还真没办法了。

探索真相

上述的就是泛型擦除的一种表现了,但是为了更好的理解,当然要更深入了是吧。虽然List很大,但却也不是不能看看。

两个关键点,来验证一下:

  1. 数据存储类型
  2. 数据获取
代码语言:javascript
复制
// 先来看看画了一个大饼的List
// 能够过很清楚的看到泛型E
public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable{       
    // 第一个关键点    
    // 还没开始就出问题的存储类型
    // 难道不应该也是一个泛型E?
    transient Object[] elementData;


    public E get(int index) {
        rangeCheck(index);

        return elementData(index); // 1---->
    }

    // 由1直接调用的函数
    // 第二个关键点,强制转化得来的数据
    E elementData(int index) {
        return (E) elementData[index];
    }
}

我想,其实你也能够懂了,这个所谓的泛型T最后会被转化为一个Object,最后又通过强制转化来进行一个转变。从这里我们也就能够知道为什么我们的数据从前面过来的时候,String类型数据能够直接被Integer进行接收了。会带来什么样的问题?

(1) 强制类型转化

这个问题的结果我们已经在上述文章中提及到了,通过反射的方式去进行插入的时候,我们的数据就会发生错误。

如果我们在一个List<Integer>中在不知情的情况下插入了一个String类型的数值,那这种重大错误,我们该找谁去说呢。

(2)引用传递问题

上面的问题中,我们已经说过了T将在后期被转义成Object,那我们对引用也进行一个转化,是否行得通呢?

代码语言:javascript
复制
List<String> listObject = new ArrayList<Object>();
List<Object> listObject = new ArrayList<String>();
代码语言:javascript
复制

如果你这样写,在我们的检查阶段,会报错。但是从逻辑意义上来说,其实你真的有错吗?

假设说我们的第一种方案是正确的,那么其实就是将一堆Object数据存入,然后再由上面所说的强制转化一般,转化成String类型,听起来完全ok,因为在List中本来存储数据的方式就是Object。但其实是会出现ClassCastException的问题,因为Object是万物的基类,但是强转是为子类向父类准备的措施。

再来假设说我们的第二种方案是正确的,这个时候,根据上方的数据String存入,但是有什么意义存在呢?最后都还是要成Object的,你还不如就直接是Object

解决方案

其实很简单,如果看过一些公开课想来就见过这样的用法。

代码语言:javascript
复制
public class Part<T extends Parent> {

    private T val;

    public T getVal() {
        return val;
    }

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

相比较于之前的Part而言,他多了<T extends Parent>的语句,其实这就是将基类重新规划的操作,就算被编译,虚拟机也会知道将数据转化为Parent而不是直接用Object来直接进行替代。同理就是使用<T super Parent>也有类似于这样的作用,之后会进行阐述。

应用场景

代码语言:javascript
复制
该部分的思路来自于:https://blog.csdn.net/qq_36898043/article/details/79655309

上面我们说过了解决方案,使用<T extends Parent>。其实这只是一种方案,在不同的场景下,我们需要加入不同的使用方法。另外官方也是提倡使用这样的方法的,但是我们为了避免我们上述的错误,自然需要给出一些使用场景了。

基于的其实是两种场景,一个是扩展型super,一个是继承型extends。下面都用一个列表来举例子。

统一继承顺序

代码语言:javascript
复制
// 承载者
class Plate<T>{
    private T item;
    public Plate(T t){item=t;}
    public void set(T t){item=t;}
    public T get(){return item;}
}

// Lev 1
class Food{}

// Lev 2
class Fruit extends Food{}
class Meat extends Food{}

//Lev 3
class Apple extends Fruit{}
class Banana extends Fruit{}
class Pork extends Meat{}
class Beef extends Meat{}

//Lev 4
class RedApple extends Apple{}
class GreenApple extends Apple{}

<T extends Parent>

继承型的用处是什么呢?

其实他期待的就是这整个列表的数据的基础都是来自我们的Parent,这样获取的数据全部人的父类其实都是来自于我们的Parent了,你可以叫这个列表为Parent家族。所以也可以说这是一个适合频繁读取的方案。

代码语言:javascript
复制
Plate<? extends Fruit> p1=new Plate<Apple>(new Apple());
Plate<? extends Fruit> p2=new Plate<Apple>(new Beef()); // 检查不通过

// 修改数据不通过
p1.set(new Banana());

// 数据获取一切正常
// 但是他只能精确到由我们定义的Fruit
Fruit result = p1.get();

<T super Parent>

扩展型的作用是什么呢?

你可以把它当成一种兼容工具,由super修饰,说明兼容这个类,通过这样的方式比较适用于去存放上面所说的Parent列表中的数据。这是一个适合频繁插入的方案。

代码语言:javascript
复制
// 填写Food的位置,级别一定要大于或等于Fruit
Plate<? super Fruit> p1=new Plate<Food>(new Apple());
// 和extends 不同可以进行存储
p1.set(new Banana());
// get方法
Banana result1 = p1.get(); // 会报错,一定要经过强制转化,因为返回的只是一个Object
Object result2 = p1.get(); // 返回一个Object数据我们已经属于快要丢失掉全部数据了,所以不适合读取

逆变与协变是什么?

代码语言:javascript
复制
从百度上只要这样输入关键词,java 逆变与协变 你就能得到类似与下方文字的统一解。

逆变与协变描述的是类型转换后的继承关系。

定义A,B两个类型,A是由B派生出来的子类(A<=B),f()表示类型转换如new List();

  1. 协变: 当A<=B时,f(A)<=f(B)成立。(String -> Object)
  2. 逆变: 当A<=B时,f(B)<=f(A)成立。(Object -> String)
  3. 不变: 当A<=B时,上面两个式子都不成立

先来解释一下,这几句话都是什么意思吧。

代码语言:javascript
复制
// 协变
List<? extends Fruit> fruit = new ArrayList<Apple>();
// 逆变
List<? super Apple> fruit = new ArrayList<Fruit>();
// 不变
List<Apple> fruit = new ArrayList<Apple>();

我想你一定会想问我,明明类似List<String> list = new ArrayList()或者Fruit[] fruit = new Apple[10];是可以编译成功的,但是不变的部分并不能编译成功这是为什么呢?

其实这和List内部自带协变机制是有关系,也就是上面说的不变,而如果这样写List<Fruit> fruit = new ArrayList<Apple>();就会编译出错的情况,换句话说就是ArrayList<Apple>ArrayList<Fruit>他们不是一家人了。而逆变和协变的机制的引入,就是为了让他们重新建立亲子关系。而这个步骤的完成,就需要使用到我们上面已经有所涉及的extendssuper来完成任务了。

知道了上述的这些基本知识,我们就来逻辑证明一下上面说的论据吧。

协变: 当A<=B时,f(A)<=f(B)成立

代码语言:javascript
复制
List list = new ArrayList();
List<Fruit> flist = new ArrayList<Apple>(); // 为了让这样的形式成立,有了我们下方的代码
List<? extends Fruit> fruitList = new ArrayList<Apple>();

温故一下上面的图

extends作为泛型中用于引入协变机制的关键词,他的作用域我们想来已经有所了解了,比较上述的两段代码,第一段是能够编译成功的,第二段则是编译失败的,这是为什么呢?我们已经说过了其实List内部并不像自带数组或者你直接创建一个对象一样直接完成变化,类似于T这样的泛型通配符,它在编译时并没有对这样的数据进行转化。

逆变: 当A<=B时,f(B)<=f(A)成立

代码语言:javascript
复制
List list =(List) new ArrayList();
List<Fruit> fruit = (ArrayList<Fruit>)new ArrayList<Apple>(); // 依旧编译错误,同理引进了super
List<? super Apple> fruit1 = new ArrayList<Fruit>();

同理为了解决这样的问题,Java引入了super关键词,作用域如下图:

至于什么是不变,我就不说了。想来读者从上面的内容看完之后,已经知道了不变这个概念大概对应的含义了,其实就是下面这段代码,只能等于本身。而本身的意思就是我们上面说过的,ArrayList<Apple>ArrayList<Fruit>他们不是一家人了。

代码语言:javascript
复制
List<Apple> list = new ArrayList<Apple>();

逆变与协变的作用是什么?

来了来了,又是一个重复的知识点。其实转化一下问题就是为什么要引入逆变与协变这两个机制呢?

先来想一下,泛型在运行时有什么问题? 很显然,泛型擦除嘛!!

那泛型擦除的具体表现是什么? 看过之前文章的读者肯定想骂我了,不就是变成了Object,最后通过强转再把类型转化回来嘛。

没错了,那我们得重新温习一下ArrayListget()源码先。

代码语言:javascript
复制
public E get(int index) {
            rangeCheck(index);
            checkForComodification();
            return ArrayList.this.elementData(offset + index); // 1 -->
        }

E elementData(int index) {
        return (E) elementData[index];
    }

这个整体部分一共调用了两个函数,而第二个函数,你清晰可见的进行了强制转化,为什么?因为他保存的是Object,而不是我们赋予的Apple啊。

关于插入操作,为什么要用逆变

通过引入协变的机制,List<? extends Fruit> fruits = new ArrayList<Apple>();意味着某个继承自Fruit的具体类型,知道了上界,但是下界处于一个完全未知的状态下了。在这样的情况下,最明显的情况就是你无法完成我们的插入操作了。

代码语言:javascript
复制
List<? extends Fruit> fruits = new ArrayList<Apple>();
fruits.add(new Apple());  // 编译报错
fruits.add(new Fruit());  // 编译报错
fruits.add(new Object());  // 编译报错

为什么会这样呢?其实很简单,因为你忘记自己是谁了,但是你知道上界,也就是知道你的爸妈是谁,或者说祖父祖母是谁。就像是族谱,最顶上的人是你的曾曾曾。。。。。祖父和祖母,而我们只是族谱最下面的那几个毛头娃儿,你知道曾祖母是你的长辈,但是曾祖母却不一定知道你是她哪一个血脉的孩子。

所以创建的时候,你要注意这样的一个问题List<? extends Fruit> fruits = new ArrayList<Apple>();,他是为我们这几个毛头娃儿所创建,他这次是Apple,另一个人就可能是Orange、还有人可能是Pear,这样会出现什么问题?这个List他到底该存Apple呢,还是Orange呢,还是Pear,不论存储了哪一个,另一个就可能成为备胎,为了避开这样的问题,就干脆不要存他们好了。

而逆变机制就不一样了,他已经知道了下界,但是却不知道上界,那这个时候他的子子孙孙他就认识了,但是长辈那一栏我们知道他们,但是他们有很多东西我们继承并发展了新的东西,与他们的匹配度不再相同,就不能让老一辈他们加入我们新生代的行列了,而子子孙孙拿走了你的全部,并且有着自己的新玩意儿(当然这些新玩意儿不再我们的考虑范围内了),所以你能看到这样的情况,这个List能够加入我们的子孙的新玩意儿,而排斥着老一辈的落后思想。

代码语言:javascript
复制
List<? super Apple> apples = new ArrayList<Fruit>();
apples.add(new Apple());
apples.add(new Jonathan());
apples.add(new Fruit());  // 编译错误

关于获取操作,为什么要用协变

代码语言:javascript
复制
List<? extends Fruit> fruits = new ArrayList<Apple>();
Apple apple = fruits.get(0);
Jonathan jonathan = fruits.get(0);  // 编译错误
Fruit fruit = fruits.get(0);

对于extends能够获得比大于或者等于他本身的数据,这是为什么?我们也说过了,他确定了上界,如果通过子类拿数据,就会出现数据不匹配的情况。所以自然而然的对这方面进行了限制。就比如说fruits.get(0)获取的其实是Apple的里一个子类B,那这个时候,我们假设第三行代码运行成功,那么数据就会出现不匹配,因为你无法保证子类Jonathan的数据和子类B完全保持一致,但是他们俩的数据一定和Apple保持一致,当然为了数据的进一步安全,一般采用的都是使用基类来获取我们的数据,就是Fruit

代码语言:javascript
复制
List<? super Apple> apples = new ArrayList<Fruit>();
Object jonathan = apples.get(0);
// 其余只能通过强制转化获得

而逆变的数据获取中,数据信息已经全部丢失,为什么这么说呢?因为我们虽然知道了下界,但是上界呢,他可能是Fruit,也有可能是万物的基类Object,在不知道上界,也就是父亲到底是谁的情况下,编译器直接设置成了Object,所以不再适合获取操作。

案例分析

在《Effective Java》给出过一个十分精炼的回答:producer-extends, consumer-super(PECS)

从字面意思理解就是,extends是限制数据来源的(生产者),而super是限制数据流入的(消费者)。而显然我们一些经常使用到的代码中也都是符合了这一规则。

代码语言:javascript
复制
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());
            }
        }
    }

Collections中的一个copy()函数,从字面意思我们应该就能知道把,就是复制的意思了,你看看他放入了什么玩意儿List<? super T> dest, List<? extends T> src,而src的数据类似就是协变,一个适合获取的方案。而比较器dest使用的就是super,因为要进行存储之类的操作。这也正好验证了我上述所说过的PECS

总结

归根结底,上面这样做法的一切原因是其实都是为了数据安全

下面总结了一条公式:

已知数据下界(super),应该细化存储;已知数据上界(extends),应该粗糙拿取。(其中细化就是指用子类或者本身存,粗糙就是用父类或者本身取)

wait!wait!wait!!!

疑惑:这里你是不是有点问题,感觉是不是和PECS这个原则有点不太一样呢? 我说extends是拿取,super是存储这不是正好反了吗?

其实是相同的,就那我们上面的案例来说好了。反证法来证明一下,Sun公司的工程师总比我资深吧,那这个玩意儿总该是对的,而且你在案例中也确实能看出是从src中获取数据,然后在des中存储对比,那说明一个问题,是不是应该把这个公式改成PSCE呢? No!No!No!!!

生产者是什么,是已经把东西做好的人,而extends正好满足了这个条件,你只管拿就好了。而消费者呢?显然就是那些能拿走这些东西的人,extends他能插入东西吗??要是你说行,你要自己写个代码试试,显然只能靠super来整啊。那也就说明PECS这个原则是正确的,而且提取的非常精炼,希望大神能够收下我的膝盖。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2020-04-22,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 DevGW 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 统一继承顺序
  • <T extends Parent>
  • <T super Parent>
  • 协变: 当A<=B时,f(A)<=f(B)成立
  • 逆变: 当A<=B时,f(B)<=f(A)成立
  • 关于插入操作,为什么要用逆变
  • 关于获取操作,为什么要用协变
相关产品与服务
数据保险箱
数据保险箱(Cloud Data Coffer Service,CDCS)为您提供更高安全系数的企业核心数据存储服务。您可以通过自定义过期天数的方法删除数据,避免误删带来的损害,还可以将数据跨地域存储,防止一些不可抗因素导致的数据丢失。数据保险箱支持通过控制台、API 等多样化方式快速简单接入,实现海量数据的存储管理。您可以使用数据保险箱对文件数据进行上传、下载,最终实现数据的安全存储和提取。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档