java泛型拔高:90%学习者不知道的泛型知识!泛型擦除目的与弊端、协变逆变&数组协变,PECS,泛型之桥接方法万字图文剖析。

泛型官方概述

泛型程序设计(genericprogramming)是程序设计语言的一种风格或范式。泛型允许程序员在强类型程序设计语言中编写代码时使用一些以后才指定的类型,在实例化时作为参数指明这些类型。各种程序设计语言和其编译器、运行环境对泛型的支持均不一样。Ada、Delphi、Eiffel、Java、C#、F#、Swift
和 Visual Basic .NET 称之为泛型(generics);ML、Scala 和 Haskell
称之为参数多态(parametric polymorphism);C++ 和 D称之为模板。具有广泛影响的1994年版的《Design
Patterns》一书称之为参数化类型(parameterized type)。

泛型回顾

什么是泛型?为什么要使用泛型?

泛型,即“参数化类型”。一提到参数,最熟悉的就是定义方法时有形参列表,普通方法的形参列表中,每个形参的数据类型是确定的,而变量是一个参数。在调用普通方法时需要传入对应形参数据类型的变量(实参),若传入的实参与形参定义的数据类型不匹配,则会报错。

那参数化类型是什么?以方法的定义为例,在方法定义时,将方法签名中的形参的数据类型也设置为参数(也可称之为类型参数),在调用该方法时再从外部传入一个具体的数据类型和变量。

泛型的本质是为了将类型参数化,
也就是说在泛型使用过程中,数据类型被设置为一个参数,在使用时再从外部传入一个数据类型;而一旦传入了具体的数据类型后,传入变量(实参)的数据类型如果不匹配,编译器就会直接报错。这种参数化类型可以用在类、接口和方法中,分别被称为泛型类、泛型接口、泛型方法。

关于泛型的基础知识不做过多赘述,一句话泛型即是数据类型的“标签”,作用于java中的三个地方:类、接口、方法。接下来进入正题:

泛型擦除的引出

先看一个例子:

在这里插入图片描述
图中,声明了两个集合List,List< Integer>和List< String>明明是两种不同泛型类型的List,List< String>只能存储字符串,List< Integer>,只能存储整型对象。我们通过 arrayString 对象和 arrayInteger 对象的 getClass() 方法获取它们的类信息并比较,按直觉应该是false,然而发现结果为true。彷佛集合的泛型不存在一般,这就是体现泛型擦除的一种体现。

什么是泛型擦除?

泛型的本质是将数据类型参数化,它通过擦除的方式来实现,即编译器会在编译期间擦除代码中的所有泛型语法并相应的做出一些类型转换动作。

换而言之,泛型信息只存在于代码编译阶段,在代码编译结束后,与泛型相关的信息会被擦除掉,专业术语叫做类型擦除。也就是说,成功编译过后,对于jvm来说,并没有什么泛型信息,所有的对象都是普通对象,所以压根就没有什么List< Integer>和List< String>类型,就只有List这一种类型,这种擦掉泛型参数后的类型,叫做原始类型(Raw Type)

再看一个例子,定义两个List和Map,用反射方法getTypeParamaters()想去拿到泛型的信息,如下:

在这里插入图片描述
得到却是泛型的一个参数格式,参数格式:[参数个数],而不是泛型的具体类型。

再看一个例子,假设定义一个泛型类如下:

public class Caculate<T> {
	private T num;
}

在该泛型类中定义了一个属性 num,该属性的数据类型是泛型类声明的类型参数 T ,这个 T 具体是什么类型,我们也不知道,它只与外部传入的数据类型有关。将这个泛型类反编译。

代码如下:

public class Caculate {
	public Caculate() {}// 默认构造器,不用管
	
	private Object num;// T 被替换为 Object 类型
}

可以发现编译器擦除了 Caculate 类后面的泛型标识 < T >,并且将 num 的数据类型替换为 Object 类型,而替换了 T 的数据类型我们称之为原始数据类型。
那么是不是所有的类型参数被擦除后都以 Object 类进行替换呢?

答案是否定的,大部分情况下,类型参数 T 被擦除后都会以 Object 类进行替换;而有一种情况则不是,那就是使用到了 extends 和 super 语法的有界类型参数(即泛型通配符,后面我们会详细解释)。

再看一个例子,假设定义一个泛型类如下:

public class Caculate<T extends Number> {
	private T num;
}

将其反编译:

public class Caculate {
	public Caculate() {}// 默认构造器,不用管

	private Number num;
}

可以发现,使用到了 extends 语法的类型参数 T 被擦除后会替换为 Number 而不再是 Object。

extends 和 super 是一个限定类型参数边界的语法,extends 限定 T 只能是 Number 或者是 Number 的子类。 也就是说,在创建 Caculate 类对象的时候,尖括号 <> 中只能传入 Number 类或者 Number 的子类的数据类型,所以在创建 Caculate 类对象时无论传入什么数据类型,Number 都是其父类,于是可以使用 Number 类作为 T 的原始数据类型,进行类型擦除并替换。(这一部分涉及到了泛型通配符,在下面还会具体介绍)

泛型擦除的目的

由上可以总结:

被擦除的泛型类和泛型方法,其中的泛型参数会被替换为它的第一个上界,没有边界时自然就是它的顶层父类Object,类型擦除后的代码,和jdk1.5之前泛型还没有发布时的一样,这就是泛型擦除的目的:为了保证已有的代码和类文件依然合法且能继续保证原有的代码含义,java就选择了类型擦除这种简单粗暴的方式,而向低版本兼容!

泛型擦除的弊端

在这里插入图片描述

虽然java讲泛型代码给jvm调用时直接擦除,和以前的代码保持了一致,如此实现泛型时简单了,但是给使用者带来了局限性和麻烦之处:

> 1.泛型参数不支持基本参数,本质是一个Object引用类型

在这里插入图片描述

> 2.运行时你只能对原始数据类型进行检测,无法对带泛型的数据进行判断

在这里插入图片描述

> 3.不能实例化泛型类型参数:(泛型本是一个标签,从概率上理解逻辑也不通)

在这里插入图片描述

> 4.不能实例化泛型数组,与3本质一致:

在这里插入图片描述
如果T是String[],泛型擦除后会是Object,Object[]无法强转为String,会抛出异常。
就算通过类型强转,绕过编译器检查,但是返回数组时还是会抛出异常:
在这里插入图片描述

泛型真的被擦除掉了吗?

通过反射的方式来开挂

> 1.通过反射的方式来获取泛型信息

在这里插入图片描述

挂在类上的泛型,通过getGenricSuperclass()方法来进行获取:
挂在方法返回值的泛型,通过getReturnType()方法来进行获取:

在这里插入图片描述
挂在局部变量表的泛型信息,操作复杂一点:
需要通过操作字节码的工具如:javasssist
在这里插入图片描述

> 2.通过反射的方式绕过编译器对List集合存入整形数据18

在这里插入图片描述

> 3.通过反射的方式拿到泛型类型:

public class Lzqlist<T> {
    private  final T lzq;

    public  Lzqlist(T lzq){
        this.lzq=lzq;
    }

    public void getLzqClass(){
        System.out.println(lzq.getClass());
    }
}

测试类:

public class Test {
    public static void main(String[] args) {
        Lzqlist<String> lzq=new Lzqlist<>("lzq");
        lzq.getLzqClass();
    }
}

输出结果为:class java.lang.String,间接拿到了泛型的类型

> 4.Spring中如何从Bean的父类拿到泛型类型,如何对类的属性进行依赖注入: 新建子类:

public class LzqArrayList extends Lzqlist<String>{
    public LzqArrayList(String lzq) {
        super(lzq);
    }
}

模拟Spring中如何从Bean的父类拿到泛型类型: ParameterizedType

public class Test {
    public static void main(String[] args)throws Exception {
        /*Lzqlist<String> lzq=new Lzqlist<>("lzq");
        lzq.getLzqClass();*/
        ParameterizedType parameterizedType=(ParameterizedType) LzqArrayList.class.getGenericSuperclass();

        for (Type actualTypeArguments   : parameterizedType.getActualTypeArguments()) {
            System.out.println(actualTypeArguments.getTypeName());
        }
    }
}

泛型信息在字节码文件.class中的保留

创建一个类:

在这里插入图片描述

> 查看字节码文件:

1.查看创建的ArrayList对象,没有带泛型,由此可见,泛型信息被擦除了:

在这里插入图片描述

2.接着来看字节码中的两个Table:

在这里插入图片描述

>结论:

第一个LocalVarable Table是在栈中的局部变量表,保存的是方法参数和方法的局部便办理,是main主方法及参数和main主方法的局部变量ArrayList;

第二个LocalVarableTybe Table是就是用来保存被擦除的泛型信息!我们可以得出结论:泛型擦了约等于没擦!
正因为泛型信息没有被擦除掉,我们才可以用反射的方式来获得泛型的相关信息。

3.定义在全局变量表中的泛型,定义在方法返回值中的泛型,定义在方法参数的泛型,定义在局部变量表中的泛型,按照图中依次顺序都是被保留下来了的:

在这里插入图片描述

从字节码文件的角度来理解泛型擦除

通过看一个字节码文件:

在这里插入图片描述
我们知道泛型信息保存在字节码文件中的Signature区域中:
所以,对于jvm而言,它关心都是字节码文件中的code部分,为了向低版本兼容,这就是从字节码角度来理解泛型擦除!它只是擦除了部分泛型信息,不代表我们在运行时不能获取泛型信息,那么还有一个问题,既然jvm都擦除了泛型,为什么还要保留泛型信息呢?java官方文档给出的文档是:
在这里插入图片描述
调试器会用泛型信息来确定变量的值,而类和函数返回值的泛型信息,在我们工作中的工程代码有广泛的实践,最常见的就是json字符串的反序列化的场景:
在这里插入图片描述
它就是利用了字节码中Signature区域的泛型信息,取得具体信息并构造出对象!
然而,当你不涉及这些场景时,我们完全可以用ASM等字节码编辑器,去除多余的泛型信息,甚至还能优化jar包的大小。

泛型擦除总结

类型擦除是指运行时对jvm而言,泛型参数被擦除掉了,不代表泛型信息就从这个世界上完全消失了,也就是说,成功编译过后的 class 文件中不包含任何泛型信息,这句话是错的!我们可以通过反射机制恢复泛型信息,java的泛型机制是相对于其他语言一种妥协的产物,泛型本身是一种非常优秀且实用的编程范式,但java的类型擦除是不完美的,这一切是因为java设计者在jdk1.0短视造成的,换句话说,没有一门语言是完美的,对于缺陷,没有必要去遮掩,同样,不能因为某些缺陷就全盘否定一门语言的优秀!

泛型之不变&数组之协变

先了解类型系统中的一个重要概念:

在这里插入图片描述
java中泛型的?? extends T和? super T就是变型中的协变和逆变因为类型构造器是影响父子类型之间的决定因素。
在这里插入图片描述
我们可以拿一个Dog类继承Animal类,那么Dog就是Animal的子类,他们的关系是小于等于,f(type)表示类型构造器,一个已知的类型被f类型构造器处理后,就是一个崭新的类型,
协变:f(Dog)是f(Animal)的子类。
逆变:f(Animal)是 f(Dog)的子类。
不变:f(Dog)和f(Animal)没有关系。

在这里插入图片描述

在java中体现:

在这里插入图片描述

两者互相接收为协变或者是逆变,两者都不行,就是不变。

在这里插入图片描述
上述代码中,泛型能这样去体现多态吗?
答案是不行,编译失败,java泛型默认不支持协变,禁止协变!
为什么?
在这里插入图片描述
如上诉代码中,如果放入cat会导致类型错误!

那为什么java的数组又可以支持协变呢?
在这里插入图片描述
这是因为java为了安全考虑,jdk1.5之前还没有泛型,java设计者又希望对数组进行通用处理,如果数组不支持协变的话,那很多方法就会每一种需求类型编写每一个逻辑,工作量太大,没有意义:
在这里插入图片描述
但是数组支持协变就带来了弊端,安全隐患:
在这里插入图片描述
在赋值元素时,会抛出运行时异常!这是平时初学者容易踩的坑!
引出题目:下例那段代码会出错:
在这里插入图片描述
答案是第4行会抛出运行时异常,这是因为它本身是一个String数组,无法存储不同类型的数组,但是在第二行又没有报编译错误,第二行是数组支持协变的体现,所以,《Effetive Java》中说,允许java数组协变是Java的一种缺陷,如果在jdk1.0之前,java开发者重视泛型,就不会有这些缺陷!
再看数组协变例子:
在这里插入图片描述
在这里插入图片描述

泛型协变

为什么要让java泛型支持协变呢?三个好处:

1.多态:

在这里插入图片描述
想要接收父类的子类作为数据处理,因为java不支持协变,就需要为每一个子类编写对应的方法,不现实的 :
在这里插入图片描述
如何让java泛型支持协变?
加上——>
? extends 父类协变通配符,就可以接收父类的任意子类,直到null,用数学表现是(-∞,A],理解为总是可以找到比现在还小的数据!只能是最小值null可以写入!

上界通配符 <? extends T>:T 代表了类型参数的上界,<? extends T>表示类型参数的范围是 T 和 T 的子类。需要注意的是: <? extends T> 也是一个数据类型实参,它和 Number、String、Integer 一样都是一种实际的数据类型。
在这里插入图片描述
那么因为数组协变有弊端,所以java选择了一刀切的方式,即:
只要你声明了上界,除了null之外,无论你的类型是安全还是不安全,一律不准传给泛型,只准你读,不准你写。
在这里插入图片描述
我们可以调用 get() 方法从集合中获取元素,并赋值给集合中的最高父类 animal (即 <? extends T> 的上界)。

一句话总结:使用 extends 通配符表示可以读,不能写。

好处2.自己写好类的泛型上界后,限定类型参数:

在这里插入图片描述

好处3.在泛型类中直接访问上界类型方法:

在这里插入图片描述
如果不声明上界,所有类型都会被擦成Object,你只能用Object的方法!

4.这里还可以引出对集合的设计问题:

在这里插入图片描述
java中集合的add方法参数是泛型E,contains和remove方法是单个Object o,这样设计的原因就是避免安全隐患,如果contains和remove方法参数是E,那声明了上界后,别人调用E的参数会引发编译错误,contains和remove这两种不会破坏类型参数的方法,自然就不能声明为E,集合协变后,这些方法就用不了了,集合也跟着崩溃了!
在这里插入图片描述

泛型逆变

泛型协变 extends 通配符表示可以读,不能写,一个集合都不能添加了,那么这个集合还有啥用呢?

我们用一个应用场景来回答:

在这里插入图片描述
需求来了,我想把Dog中开心的狗狗复制到另外一个集合,该怎么做?很简单:
在这里插入图片描述
定义两个参数,dest是目标集合,src是源集合,遍历开心的狗狗到另外一个集合就行了。
但是这里只能添加Dog类,将子类类型赋值给父类是再正常不过的事情,却不能添加其子类:
在这里插入图片描述
这时候我们用到协变通配符:
在这里插入图片描述
这样我们就可以传入Dog的子类了!
这时候src的扩展性很好了,可是dest参数的问题还没有解决!
在这里插入图片描述
dest现在只能接收Dog类型的集合,如果传递Dog父类的集合,就会编译失败,将对象放入父类集合也是很常见的操作:
在这里插入图片描述
这时候就引入了泛型逆变:
加上——>
? super T 协变通配符,就可以接收T任意的父类,直到null,用数学表现是[A,+∞),理解为总是可以找到T的父类!正是因为只能接收最多是T的父类的泛型,所以逆变可以添加元素,没有安全限制!不会有类型转换的风险。

下界通配符 <? super T>:T 代表了类型参数的下界,<? super T>表示类型参数的范围是 T 和 T 的超类,直至 Object。需要注意的是: <? super T> 也是一个数据类型实参,它和 Number、String、Integer 一样都是一种实际的数据类型。
在这里插入图片描述
需要注意:

易错1:

在这里插入图片描述
给逆变添加元素时,还是只能添加指定泛型或指定泛型的子类,不要将接收泛型类和添加元素类弄混了,比如添加new Cat()就会编译失败!

易错2:

在这里插入图片描述
逆变虽然没有协变只读不写的限制,但有利则有弊,正是因为逆变是接收父类的泛型类,所以在读取元素时,不能保证元素具体是什么类型,不知道具体到哪个点,所以只能用Object来接收对象,彷佛泛型失效了一样!

一句话总结:使用 super 通配符表示可以写,不能读,只能用Object来接收对象!
在这里插入图片描述

总结泛型协变逆变

只读不写时用协变;
只写不读时用逆变;
又想写又想读:准确的泛型!固定好的!

引出PECS,如图所示:

在这里插入图片描述
我们何时使用 extends,何时使用 super 通配符呢?为了便于记忆,我们可以用 PECS 原则:Producer Extends Consumer Super。
即:如果需要返回 T,则它是生产者(Producer),要使用 extends 通配符;如果需要写入 T,则它是消费者(Consumer),要使用 super 通配。

在这里插入图片描述

在这里插入图片描述

java中泛型标记符:

在这里插入图片描述

泛型之桥接方法

我们都知道,子类在重写父类方法时,必须和父类的方法保持相同的方法,参数列表和返回值,否则就不构成重写;

那么问题来了,当一个泛型父类或者接口的泛型参数被擦除掉了,那子类的重写方法就不能满足重写规则了,如下图所示:
父类的泛型T被擦除成了Object,参数列表不一致了,这显示违背了重写方法!但程序为什么还是能正常编译呢?
在这里插入图片描述
通过字节码我们可以发现,原来java为了解决类型擦除和重写的冲突,在编译阶段,编译器为这些被继承的子类创建一个合成方法,用来保持扩展泛型类型中的多态性,这个方法叫做泛型桥接方法!
在这里插入图片描述
桥接方法顾名思义,就是父类和子类的一座桥梁,编译器自动生成了在方法内部,委托了子类的原始方法,将参数前面带上泛型,绕过了类型擦除带来的影响,从而实现重写的规则。
在这里插入图片描述
其实除了带有泛型的方法,这种桥接形式还有体现,那就是当子类重写方法的返回类型是父类方法返回类型的子类时,编译器也会自动生成桥接方式来满足重写规则,这就是我们常用函数式接口写lambda表达式,也不要关心内部的参数形式。
在这里插入图片描述

Java实现泛型的三个方式

总结:
类型擦除,类型转换指令插入,还有桥接方法生成

  • 17
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值