理解泛型,解决泛型擦除所带来的各类问题

泛型

在学习泛型的时候,我们是否会有这样的疑惑?

1.java是怎么实现泛型擦除的呢?

2.java是怎么进行自动类型转换的呢?

3.为什么泛型不支持基本类型int,string?

4.为什么不可以 T t=new T()?

5.既然会擦除(T)为(Object),那这样的强转还有什么意义?

6.为什么不能给静态泛型  public static T  t;为什么不行

。。。

当真正理解泛型,这些问题自然不再是问题。这篇文章我们就会一一为大家解惑。

前置知识

  1. 我们一定要搞清楚,编译,加载两者之间的关系。

编译:生成.class字节码文件的过程,.class字节码文件,这个文件记录了所有代码信息,可以反编译成我们真正运行的代码。这里一定要注意,.class文件记录的是类的所有信息,而不仅仅只是反编译成的代码,比如类名,方法名等等信息,都会记录。而最后生成的Class对象,也不仅仅是反编译出来的代码运行!!!,举个最简单的例子,我们可以通过Class对象获取类名,而仅仅是运行这个代码是做不到的。

加载:表示将.class字节码文件写入内存的过程,此过程一般是通过类加载器来完成,而类加载器又分为两种【事实上有四种,这里暂且这么认为】,一种是启动了类加载器。String,Integer这些java提供的类,这些类会在编译的时候就加载。而我们自己写的类是懒加载,也就是当我们使用到一个类时【主函数调用到】,会先去内存中找,找不到,再去class文件中找,找到后生成Class对象保存到内存。——————》由此我们知道,每个Class对象其实是通过.class文件生成的,.class文件记录了类的所有信息,每个类只有一个Class对象。

  1. 我们还需要理解编译逻辑
class Test{
    public void set(int a){
        return a;
    }
}
public class Main {
    public static void main(String[] args) {
        Test test=new Test();
        test.set(111);
    }
}

我们思考一个问题,当编译test.set(111);的时候,编译器是怎么知道我们只能传入int类型的,类型的限定是在Test类中,而方法的执行却在Main中!!!【编译相当于将信息保存下来了】

编译和加载是不一样的,加载我们奉行懒加载,而编译我们是一次性全部编译,流程是如下:

  • 编译器查看myClass的类型(在本例中是MyClass)。
  • 在MyClass类中查找名为test的方法,并检查其方法签名(方法名和参数类型列表)。在本例中,MyClass类中有一个签名为public int test(int a)的方法。
  • 编译器检查123这个参数值的类型(int),看它是否与方法签名中的int参数类型匹配。因为匹配,所以编译通过。
  • 编译器记录该方法调用的返回值类型是int。

接下来我们进入正式的泛型学习中。

1. 什么是泛型呢?我们先来看一下泛型的基本使用。

class MyArrayList<E> {
    public E set(E e) {
        return e;
    }
}
public class Main {
    public static void main(String[] args) {
        MyArrayList<String> list=new MyArrayList<>();
        list.set("aaa");
        list.set(123);//报错
    }
}

当我们在类上指定泛型,这个泛型就能当作类去使用。当我们指定list的泛型是<String>,那么E就只能接收String类型数据。这里主要体现了泛型的作用如下:

1. 限制传入数据类型

· 在new的时候限制传入类型,一旦传入非此类型数据,立马报错。 就类似int b =“abc”类型不能报错类似。

事实上,java泛型的设计非常烂,只是一项安全技术,在编译期检测你的类型不让你随意乱传递参数。这几乎就是java泛型的唯一功能【先这么说】。

注意,泛型只在此时起到了作用,之后的任何使用,都会进行泛型擦除,起不到任何作用【暂时先怎

么说,便于理解】。

2. 优化Object需要强制类型转换的问题

· 被使用者不知道使用者会传入什么类型数据,如果我们没有给集合指定类型,默认所有数据都是

Object,Object类作为所有类的父类,无法使用子类的特有方法【多态】。如果要使用,我们需要

强制类型转换,这样势必会导致代码写死的情况。

· 事实上,泛型由于擦除机制,一样无法调用方法,依然要强制类型转换,只不过这个类型转换编译器

帮我们自动完成了,也就死自动类型转换。。。。。。【具体怎么转,往下看】

泛型擦除机制

1. 泛型只是一个编译期技术,也就是说,编译MyClass时,主函数可能压根还没有编译,更别说知道传入什么类型了!!!

为了正常编译,所有的<E>会被擦除,E会变成Object。

class MyClas<T>{
    T t;
}
//真正使用的时候其实会变成
class MyClas{
    Object t;
}

假如这里传入的泛型是<String>。t在使用过程中,其实依然是Object类型,是无法调用 T .String的特有方法的。

2.这里的擦除并不是完全擦除,而是转换成他的第一边界。(这个可以先不管,后面会说

接下来我们看一下以下代码

public class Main {
    public static void main(String[] args) {
        MyArrayList<String> list=new MyArrayList<>();
        list.add("aaa");
        String s = list.get(0);//按照之前的理解,这里应该是Object s才对啊。
    }
}

2.按照我们之前的理解,“aaa”存入之后,不是会变成Object类型吗?那为什么get返回的依然是String类型呢?

这里非常重要!

我们来看一下源码。

public class MyArrayList<E> {
    private Object[] elem;

    public E get(int index) {//返回E类型
        return (E)elem[index];//可以强转
    }  

    public void set(int index, T e) {//存入的时候就是泛型,只不过擦除之后变成Object,但是依然具备泛型的功能
        elem[index] = e;
     } 
}

对.class文件进行反编译之后其实会变成

public class Test {//<E>被擦除
    private Object[] elem;

    public Object get(int index) {//返回E类型
        return elem[index];//(E)被擦除
    }  

    public void set(int index, Object e) {//存入的时候就是泛型,只不过擦除之后变成Object,但是依然具备泛型的功能
        elem[index] = e;
     } 
}

我们会发现程序真正执行的代码返回的Object类型,那为什么可以用String接收呢?

这得益于编译器的“自动类型转换机制

我们看一下主函数 String s = list.get(0);这句化的字节码文件。

   L2
    LINENUMBER 7 L2
    ALOAD 1
    ICONST_0
    LDC "aaa"
    INVOKEVIRTUAL test111/MyArrayList.set (ILjava/lang/Object;)Ljava/lang/Object;
    CHECKCAST java/lang/String  //自动类型转换
    ASTORE 3

看第七行, java/lang/String这不就是泛型类型吗!其实这一段的意思其实就是自动类型转换。当虚拟机运行这段代码的时候,会根据这个信息,将Object转换成String。

从这里我们可以知道,自动类型转换是记录在主函数的!主函数在编译的时候还没有进行泛型擦除,所以当然知道泛型是什么!

我看了很多资料,很多都说是在return (E)elem[index]; (E)的位置插入(String)的类型转换,其实这样理解是有误差的,这一步其实是在运行的时候执行的,也就是虚拟机执行的,而不是编译的时候! 所以我一度以为自动类型转换的信息是记录在MyArrayList类里的,其实不是,这个类压根不知道泛型是什么类型!

3.那我们来看一下如果编译的时候是直接对MyArrayList类修改会发生什么问题?

  1. 这个类压根不知道泛型是什么,也即是根本不知道这里的T是String类型
  2. 同时这样做会破坏类的唯一性(产生类爆炸),如果我们

ArrayList<String> list=new ArrayList<>();

ArrayList<Integer> list=new ArrayList<>();

定义两个泛型,这两个类都是通过ArrayList来构建的,如果第一个<String>对原类进行了修改,那<Integer> 怎么办呢!

4.那么编译器又是怎么知道自己要转换,并且转换成什么类型呢?

当我们执行get方法时

,对于get方法的返回语句return (E)elem[index];,编译器会生成如下字节码(伪代码形式):

Copy codeaload_0
getfield Test.elem [Ljava/lang/Object;
iload_1
aaload
xxx // 这里插入了一个类型检查和转换的字节码指令
areturn

这里的xxx代表编译器根据上下文插入的类型检查和转换的字节码指令。这个指令的作用是:

  1. 检查elem[index]返回的对象是否可以安全地转换为泛型参数E的实际类型。
  2. 如果可以,则进行类型转换并返回转换后的值。
  3. 如果不可以,则抛出ClassCastException

简单说,就是class字节码文件中会记下来。而我们本来就传入了String泛型,他当然知道。

5.那(E)不应该也会被擦除成为(Object)吗,你这样强转不是没有意义吗?

public class MyArrayList<E> {
    private Object[] elem;

    public E get(int index) {//返回E类型
        return (E)elem[index];//可以强转
    }  
}

其实不然,虽然E会进行泛型擦除,但是在擦除前,T其实会被当作一个未知类型去处理的,get方法返回值是T,为了满足语法的统一性,我们需要对elem也进行强转,虽然这个强转没有意义。

我们再来看看一下代码。

public class Main {
    public static void main(String[] args) {
        MyArrayList<String> list=new MyArrayList<>();
        String aaa = list.set(0, "aaa");
        Integer test = list.test(123);
        String s = list.get(0);
    }
}
class MyArrayList<E> {
    private Object[] elem=new Object[10];
    public E set(int index, E e) {
        elem[index] = e;
        System.out.println(e.getClass());//插入这一句话
        return e;
    }
}

6.最后会输出class java.lang.String,按照我们之前的理论,e应该是Object类型,他从头到尾应该都不知道自己是String类型才对啊,为什么会这样呢?

我们举个例子就好理解了,

Object test=new String();

test.getClass();

这里获取的是String而不是Object,虽然我们用Object来保存String,这只是代表Object无法调用String的特有方法,String的特性只是被隐藏起来了,但是并没有消失。

通过这个图,简单的理解下。我们用Object来存储String,但是Object是父类,功能少,占用内存少,只能访问自己的那一块空间,一旦超出,就会内存越界。虽然Object访问不了String的特性,但是String的内存依然在那保留着。我们依然可以对Object进行强转,成为String。【当然内存的真实发呢配肯定没有这么简单】

同理到我们这里,虽然泛型所有都是Object,但是数据真实占用的内存是真实不变的,所以说,虚拟机依然可以通过访问这块空间,来确定实际类型!

所以e.getClass();的意思就是传入数据的类型。

再次声明一遍,数据本身的 内存/真实类型 是不会变的!

到这里为止我们的理论体系基本已经构建完成(当然后面还会介绍一些特性),到这里我们已经可以解决很多泛型问题了。

问题1:为什么泛型不支持基本类型?

按照前文,所有的泛型都是Object,而int,string此类基本类型是无法转换成Object的。

问题2:相信大家会有这样的疑问。为什么不能使用泛型的形参创建对象?T t=new T();————》Object t=new Object();

T t=new T();————》Object t=new Object();

看似好像可以,但是,编译时,T在擦除前,就是一个不确定类,只是起到一个占位标示作用。而具体的new()是需要具体的信息的。连new都new不了,压根就没有擦除成Object t=new Object();一说。

问题3:不能给静态成员变量定义泛型

class MyArrayList<E> {
    public static E e;//报错
    public static E set(int index, E e) {//报错
        return e;
    }
}

我们会发现,E是不能定义在static中的,会报“无法从 static 上下文引用 'test111.MyArrayList.this'”的错误。这是为什么呢?很简单,我们的E是定义在类上的,而静态就意味着,我们根本不需要创建对象,直接就可以调用,这显然是不允许的。无法引用上下文错误,也是因为这个,我们可以把静态代码块看作独立的一部分,他和其他非静态是隔离的【这样我为了保证其可以独立被调用所设计的】。

既然静态无法获取类上的泛型,那方法上的泛型能不能获取呢?【泛型方法后面会说,这边举例是为了更好的展示】

class MyArrayList<E> {
    public static <T> T set(int index, T t) {
        return t;
    }
}

这里会发现是没有问题的! 因为泛型本身就定义在了静态方法上,当然可以获取信息。

问题4:泛型引用问题,泛型不一致的类可以互相赋值吗?

// 错误写法1:间接传递(通常发生在方法传参,比如将stringList传给print(List<Object> list))
List<String> stringList = new ArrayList<>();
List<Object> list = stringList;
// 错误写法2:直接赋值
List<Object> list = new ArrayList<String>();

这两种写法都是不被允许的。这就有问题了,按照我们之前的理论,List<String> 会被擦除成List,List<Object>会被擦除成List,既然如此为什么不可以互相赋值呢?

这里要区分多态和泛型,ArrayList可以赋给List,是因为多态,而我们的泛型只是一种约束,他限定传入值的类型,当我们运行第五行时,左边告诉编译器你给我监管一下Object,后面告诉编译器给我监管一下String,所以为了避免这种冲突,编译器直接就拒绝这种写法,直接报错。

也就是说这个报错是编译器就错了,而不是擦除后的运行。

泛型体系已经构建起来了,泛型的一些问题我们也已经解决,接下来我们拓展一下泛型的使用。

为了解决问题四,我们希望指定一个泛型,编译器可以顺便帮我们监管一下他的所有子类,或者父类这要怎么做呢?

这就引出了我们的泛型的通配符

表示不确定的类型

它可以进行类型的限定

? extends E : 表示可以传递E或者E所有的子类类型【继承了E的所有类】

? super E :表示可以传递E或者E所有的父亲类型

class YE{}
class Fu extends Ye{}
class Zi extends Fu{}
class Student{}

public class Main{
    public static void main(String[] args){
        ArrayList<Ye> list1 = new ArrayList<>();
        ArrayList<Fu> list2 = new ArrayList<>();
        ArrayList<Zi> list3 = new ArrayList<>();
        ArrayList<Student> list4 = new ArrayList<>();

        method(list1);
        method(list2);
        method(list3);
        method(list4);//报错,因为Student没有继承Ye
    }
    public static void method(ArrayList<? Extends Ye> list){}//泛型通配符

}

当我们使用了<? Extends Ye>,这个时候,编译器就会帮我们监管所有继承了Ye的类。这样不就可以解决我们的问题了吗。

泛型都会被擦除成Object,如果我们想控制这种擦除,有没有办法实现呢?其实这样可以通过我们的通配符解决。

我们可以用在泛型定义中。

class MyArrayList<T extends String> {
    T t;
}
——————》经过擦除
class MyArrayList {
    String t;
}

这里的意思就是让T继承String,当泛型擦除的时候,所有的T都会被String替代。这就是我们上面所说的边界,T的边界从Object变为了String。

当我们在类上定义了泛型,那么就意味着,类中的所有泛型都要和类定义的一致,也就是所有方法只能操作同一种泛型,这样做当然很好,一定程度上保证了统一性,但是有时候我们希望有个别方法可以操作别的泛型怎么办呢?

这就需要用到我们的泛型方法

class MyArrayList<E> {
    public E get(int index) {
    }
    public static <T> T test(T t){
        return t;
    }
}

这里我们可以看到 <T> T 这里的意思就是定义了一个泛型T,并且返回值类型也是T。

这里的泛型T是只在此方法生效的,类似于方法的形参,只能在方法中使用。

这里T是区别于类的泛型的,即使我们写成E,和类一样,也不会影响其独立性。

既然独立与类,在使用时,又要怎么指定泛型的具体类型呢?

MyArrayList<String> mylist=new MyArrayList<>();
Integer result=mylist.test(123);

我们发现,从头到尾都没有指定方法泛型啊,只在类上指定了类的泛型是String。

其实不然,当我们调用test(123)时,编译器会自动检查,传入的是Integer【其实检查出来的是int,然后自动装箱了】然后编译器不就能确定泛型是Integer了吗。所以传入的泛型就是Integer。有因为这个方法返回值为T,所以还会自动类型转换。

通配符拓展

传入参数类型未知,我们可以用泛型,如果传入参数个数也未知呢?

通过...来实现。————他的底层其实就是数组。

class List{
    public static <E> void add(ArrayList<E> list,E...e){//通过三个点,表示可变参数
        for(E element : e){
            list.add(element);
        }
    }
}

//调用
public class Mian{
    public static void main(String[]args){
        ArrayList<Integer> list=new ArrayList<>();
        List.add(list,1,2,3,4,5);//我们可以传递任意个数参数
    }
}

泛型接口

如何使用带泛型的接口

  1. 实现类给出具体的类型——————————》如果实现类确定类型,使用此方式
  2. 实现类延续泛型,创建对象时再确定————》类型需要使用者提供,实现类不知道

class MyArrayList1 implements List<String>{
    
}

class MyArrayList1<E> implements List<E>{//将接口的泛型延续下来
    
}

public class Main{
    public static void main(String[] args){

        MyArrayList1 list=new MyArrayList1();//不需要指定泛型,会自动使用接口指定的String来分装类
        MyArrayList1<String> list=new MyArrayList1<>();//需要指定泛型
    }
}

我们能不能定义一个保存泛型的数组呢ArrayList<String>[] listArr=new ArrayList<>[5];?

泛型一般作用于集合,他和数组在设计上就是冲突的。

在 Java 中,不能直接创建泛型数组,包括 ArrayList<String>[] listArr = new ArrayList<>[5]; 这样的语法是不允许的。这是因为 Java 中的泛型数组存在类型擦除的问题,导致无法安全创建泛型数组。

在 Java 中,数组具有运行时类型信息而泛型在编译时会被擦除。这导致泛型数组的创建变得复杂且不安全。如果允许直接创建泛型数组,可能会导致类型安全性问题。

但是我们如果非要创建也可以。

可以声明带泛型的数组引用,但是不能直接创建带泛型的数组对象

public class Test{
    public static void main(String [] args){

        //error
        ArrayList<String>[] listArr=new ArrayList<>[5];

        //这样可以,但是不好
        ArrayList[] list=new ArrayList[5];
        ArrayList<String>[] listArr=list;

        //这样比较好
        ArrayList<String>[] listArr = new ArrayList[5];
    }
}

为什么第二种方式不好呢,因为第二种方式将list暴露出来了,会出现越过listArr,通过list直接赋值的情况。但是编译不会报错,泛型只会在定义泛型的那一句话中,进行检查。也就是说list和listArr是两个引用指向了一个数组,虽然后者指定了泛型,编译器会帮我们监管,但是前者依然可以使用。我们希望,当我们往listArr中存入不符合泛型的数据时,会立马标红,报错。【编译报错】

public class Test{
    public static void main(String [] args){

        //不好的true
        ArrayList[] list=new ArrayList[5];
        ArrayList<String>[] listArr=list;

        ArrayList<Integer> intList=new ArrayList<>();
        intList.add(0);

        list[0]=intList;//通过list直接赋值
        String s=listArr[0].get(0);//后面取到的是Integer,而泛型却是String

==============================================================================

        //好的true
        ArrayList<String>[] listArr = new ArrayList[5];
         ArrayList<Integer> intList=new ArrayList<>();
        intList.add(0);

        listArr[0]=intList;//这里会立马报错,因为listArr规定了String数组泛型,而这里是Integer数组
    }
}

总结:

实现泛型其实有两种思路,

一种是每写一个泛型,都定义一个与之对应的类【编译器自己完成】,但是这样存在一个问题,就是我们使用的泛型往往有很多,那么一个类要分出几十上百个类,这会发送类爆炸。非常占用内存。

第二种,就是就是进行类型擦除,也就是java采取的措施,将所有的泛型都擦除,这样无论我们定义多少个泛型,都是会有一个共同的原始类,这样就可以节省很多的空间。

第一次写文,如果错误,希望大家可以指出。

  • 18
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值