Java中的泛型 - 细节篇

前言

大家好啊,我是汤圆,今天给大家带来的是《Java中的泛型 - 细节篇》,希望对大家有帮助,谢谢

细心的观众朋友们可能发现了,现在的标题不再是入门篇,而是各种详细篇细节篇

是因为之前的几篇比较简单,所以叫做入门篇会合适点;

现在往后的都慢慢的开始复杂化了,所以叫入门就有点标题党了,所以改叫详细篇或者细节篇或者进阶篇等等

文章纯属原创,个人总结难免有差错,如果有,麻烦在评论区回复或后台私信,谢啦

简介

泛型的作用就是把类型参数化,也就是我们常说的类型参数

平时我们接触的普通方法的参数,比如public void fun(String s);参数的类型是String,是固定的

现在泛型的作用就是再将String定义为可变的参数,即定义一个类型参数T,比如public static void fun(T t);这时参数的类型就是T的类型,是不固定的

从上面的String和T来看,泛型有着浓浓的多态的味道,但实际上泛型跟多态还是有区别的

从本质上来讲,多态是Java中的一个特性,一个概念,泛型是真实存在的一种类型;

目录

下面我们详细说下Java中的泛型相关的知识点,目录如下:

  • 什么是类型参数
  • 为啥要有泛型
  • 泛型的演变史
  • 类型擦除
  • 泛型的应用场景
  • 通配符限定
  • 动态类型安全
  • 等等
正文中大部分示例都是以集合中的泛型为例来做介绍,因为用的比较多,大家都熟悉

正文

什么是类型参数

类型参数就是参数的类型,它接受类作为实际的值

白话一点来说,就是你可以把类型参数看作形参,把实际传入的类看作实参

比如:ArrayList中的类型参数E看做形参, ArrayList中的类String看做实参

如果你学过工厂设计模式,那么就可以把这里的ArrayList看做一个工厂类,然后你需要什么类型的ArrayList,就传入对应的类型参数即可

  • 比如,传入Integer则为ArrayList
  • 比如,传入String则为ArrayList

为啥要有泛型

主要是为了提高代码可读性和安全性

具体的要从泛型的演变史说起

泛型的演变史

从广义上来说,泛型很早就有了,只是隐式存在的;

比如List list = new ArrayList(); //等价于List list = new ArrayList<>();

但是这个时候的泛型是很脆弱的,可读性和安全性都很差(这个时期的集合相对于数组来说,优势还不是很大)

首先,填充数据时,没有类型检查,那就有可能把Cat放到Dog集合中

其次,取出时,需要类型转换,如果你很幸运的把对象放错了集合(有可能是故意的),那么运行时就会报错转换异常(但是编译却可以通过)

不过到了JDK1.5,出现了真正意义上的泛型(类型参数,用尖括号<>表示);

比如List集合类,其中的E就是泛型的类型参数,因为集合中都是存的元素Element,所以用E字母替代(类似还有T,S,K-key,V-value);

这个时候,程序的健壮性就提高了,可读性和安全性也都很高,看一眼就知道放进去的是个啥东西(这个时期的集合相对于数组来说,优势就很明显了

现在拿List list = new ArrayList<>();来举例说明

首先,填充数据时,编译器自己会进行类型检查,防止将Cat放入Dog中

其次,取出数据时,不需要我们手动进行类型转换,编译器自己会进行类型转换

细心的你可能发现了,既然有了泛型,那我放进去的是Dog,取出的不应该也是Dog吗?为啥编译器还要类型转换呢?

这里就引出类型擦除的概念

类型擦除

什么是类型擦除?

类型擦除指的是,你在给类型参数赋值时,编译器会将实参类型擦除为Object(这里假设没有限定符,限定符下面会讲到)

所以这里我们要明白一个东西:虚拟机中没有泛型类型对象的概念,在它眼里所有对象都是普通对象

比如下面的代码

擦除前

public class EraseDemo {
    private T t;
    public static void main(String[] args) {
        
    }
    public T getT(){
        return t;
    }
    public void setT(T t){
        this.t = t;
    }
}

擦除后

public class EraseDemo {
    private Object t;
    public static void main(String[] args) {
        
    }
    public Object getT(){
        return t;
    }
    public void setT(Object t){
        this.t = t;
    }
}

可以看到,T都变成了Object

泛型类被擦除后的类型,我们一般叫它原始类型(raw type),比如EraseDemo擦除后的原始类型就是EraseDemo

相应的,如果你有两个数组列表,ArrayListArrayList ,编译器也会把两者都擦除为ArrayList

你可以通过代码来测试一下

ArrayList list1 = new ArrayList<>();
ArrayList list2 = new ArrayList<>();
System.out.println(list1.getClass() == list2.getClass());// 这里会打印true
上面提到的限定符是干嘛的?

限定符就是用来限定边界的,如果泛型有设置边界,比如,那么擦除时,会擦除到第一个边界Animal类,而不是Object

下面还是以上面的代码为例,展示下擦除前后的对比

擦除前:

public class EraseDemo {
    private T t;
    public static void main(String[] args) {
        
    }
    public T getT(){
        return t;
    }
    public void setT(T t){
        this.t = t;
    }
}

擦除后:

public class EraseDemo {
    private Animal t;
    public static void main(String[] args) {
        
    }
    public Animal getT(){
        return t;
    }
    public void setT(Animal t){
        this.t = t;
    }
}
这里的extends符号是表示继承的意思吗?

不是的,这里的extends只是表示前者是后者的一个子类,可以继承也可以实现

之所以用extends只是因为这个关键词已经内置在Java中了,且比较符合情景

如果自己再造一个关键词,比如sub,可能会使得某些旧代码产生问题(比如使用sub作为变量的代码)

为什么要擦除呢?

这其实不是想不想擦除的问题,而是不得不擦除的问题

因为旧代码是没有泛型概念的,这里的擦除主要是为了兼容旧代码,使得旧代码和新代码可以互相调用

泛型的应用场景

  • 从大的方向来说:

    • 用在类中:叫做泛型类,类名后面紧跟<类型参数>,比如ArrayList
    • 用在方法中:叫做泛型方法,方法的返回值前面添加<类型参数>,比如:public void fun(T obj)
是不是想到了抽象类和抽象方法?

​ 还是有区别的,抽象类和抽象方法是相互关联的,但是泛型类和泛型方法之间没有联系

  • 集中到类的方向来说:泛型多用在集合类中,比如ArrayList

如果是自定义泛型的话,推荐用泛型方法,原因有二:

  1. 脱离泛型类单独使用,使代码更加清晰(不用为了某个小功能而泛化整个类)
  2. 泛型类中,静态方法无法使用类型参数;但是静态的泛型方法可以

通配符限定

这里主要介绍, , 的区别

  • :这个是最常用的,就是普通的类型参数,在调用时传入实际的类来替换T即可,这个实际的类可以是T,也可以是T的子类
比如 List list = new ArrayList<>();,这里的String就是实际传入的类,用来替换类型参数T
  • :这个属于通配符限定中的子类型限定,即传入实际的类必须是T或者T子类

乍一看,这个有点像类型参数,都是往里放T或者T的子类;

但是区别还是挺多的,后面会列出

  • :这个属于通配符限定中的超类型限定,即传入实际的类必须是T或者T的父类
  • :这个属于无限定通配符,即它也不知道里面该放啥类型,所以干脆就不让你往里添加,只能获取(这一点类似

下面用表格列出, 的几个比较明细的区别

类型擦除 传入实参时,实参被擦为Object,但是在get时编译器会自动转为T 擦到T 擦到Object
引用对象 不能将引用指向子类型或者父类型的对象,比如:List list = new ArrayList();//报错 能将引用指向子类型的对象,比如:List list = new ArrayList(); 能将引用指向父类型的对象,比如:List list = new ArrayList();
添加数据 可以添加数据,T或者T的子类 不能 能,T或者T的子类

下面我们用代码来演示下

类型擦除:

// 类型,传入实参时,擦除为Object,但是get时还是实参的类型
List list1 = new ArrayList<>();// 合法
list1.add(new Dog());// 合法
Animal animal = list1.get(0); // 这里不需要强转,虽然前面传入实参时被擦除为Object,但是get时编译器内部已经做了强制类型转换

//  子类型的通配符限定,擦除到T(整个过程不再变)
List list2 = list1;// 合法
Animal animal2 = list2.get(0); // 这里不需要强转,因为只擦除到T(即Animal)

//  超类型的通配符限定,擦除到Object
List list3 = list1; // 合法
Animal animal3 = (Animal)list3.get(0); // 需要手动强制,因为被擦除到Object

将引用指向子类型或父类型的对象:

// 类型,不能指向子类型或父类型
List list = new ArrayList();// 报错:需要的是List,提供的是ArrayList

//  子类型的通配符限定,指向子类型
List list2 = new ArrayList();// 合法

//  超类型的通配符限定,指向父类型
List list3 = new ArrayList(); // 合法

添加数据

// 类型,可以添加T或者T的子类型
List list1 = new ArrayList<>();
list.add(new Dog());// 合法

//  子类型的通配符限定,不能添加元素
List list2 = new ArrayList();// 正确
list2.add(new Dog()); // 报错:不能往里添加元素

//  超类型的通配符限定,可以添加T或者T的子类型
List list3 = new ArrayList();
list3.add(new Dog()); // 合法,可以添加T类型的元素
list3.add(new Animal());//报错,不能添加父类型的元素

下面针对上面的测试结果进行解惑

先从的报错开始吧

为啥 类型的引用不能指向子类型,比如 List list = new ArrayList();

首先说明一点,Animal和Dog虽然是父子关系(Dog继承Animal),但是List List之间是没有任何关系的(有点像Java和Javascript)

他们之间的关系如下图

T引用

之所以这样设计,主要是为了类型安全的考虑

下面用代码演示,假设可以将List指向子类List

List list = new ArrayList();// 假设这里不报错
list.add(new Cat()); //这里把猫放到狗里面了

第二行可以看到,很明显,把猫放到狗里面是不对的,这就又回到了泛型真正出现之前的时期了(没有泛型,集合存取数据时不安全)

那为啥 就能指向子类型呢?比如 List list = new ArrayList();

说的浅一点,原因是:这个通配符限定出现的目的就是为了解决上面的不能指向子类的问题

当然,这个原因说了跟没说一样。下面开始正经点解释吧

因为这个通配符限定不允许插入任何数据,所以当你指向子类型时,这个list就只能存放指向的那个集合里的数据了,而不能再往里添加;

自然的也就类型安全了,只能访问,不能添加

为什么 不允许插入数据呢?

其实这个的原因跟上面的修改引用对象是相辅相成的,合起来就是为了保证泛型的类型安全性

考虑下面的代码

List list = new ArrayList<>();
list.add(new Cat());
list.add(new Dog());
Dog d = (Dog) list.get(0); // 报错,转换异常

可以看到,插入的子类很混乱,导致提取时转型容易出错(这是泛型的一个弊端,当然我们写的时候多用点心可能就不会这个问题)

但是有了之后,就不一样了

首先你可以通过修改引用的对象来使得list指向不同的Animal子类

其次你添加数据,不能直接添加,但是可以通过指向的Animal子类对象来添加

这样就保证了类型的安全性

代码如下:

// 定义一个Dog集合
List listDog = new ArrayList<>();
listDog.add(new Dog());

// 让通配符限定的泛型 指向上面的Dog集合
List list2 = listDog;
// 这时如果想往里添加数据,只需要操作listDog即可,它可以保证类型安全
listDog.add(new Dog());
// 如果自己去添加,就会报错
list2.add(new Dog());// 报错

一般用在形参,这样我们需要哪个子类型,只需要传入对应子类的泛型对象就可以了,从而实现泛型中的多态

为啥可以插入呢?

两个原因

  1. 它只能插入T或者T的子类
  2. 它的下限是T

也就是说你随便插入,我已经限制了你插入的类型为T或者T的子类

那么我在查询时,就可以放心的转为T或者T的父类

代码如下:

List listDog = new ArrayList<>();
listDog.add(new Dog());
listDog.add(new Cat()); // 报错
listDog.add(new Animal()); // 报错
Dog dog = (Dog) listDog.get(0); // 内部被擦除为Object,所以要手动强转
为啥 获取时,编译器会自动强转转换,到了这里 ,就要手动转换了呢?

这个可能是因为编译器也不确定你的要返回的T的父类是什么类型,所以干脆留给你自己来处理了

但是如果你把这个listDog指向一个父类的泛型对象,然后又在父类的泛型对象中,插入其他类型,那可就乱了(又回到的问题了,要自己多注意)

比如:

List list = new ArrayList<>();
list.add(new Cat()); // 加了Cat
// 指向Animal
List listDog = list;
listDog.add(new Dog());
list.add(new Cat()); // 报错
list.add(new Animal()); // 报错

Dog dog = (Dog) listDog.get(0); //报错:转换异常Cat-》Dog

所以建议在添加数据的时候,尽量集中在一个地方,不要多个地方添加,像上面的,要么都在里添加数据,要么都在中添加

动态类型安全检查

这个主要是为了跟旧代码兼容,对旧代码进行的一种类型安全检查,防止将Cat插入Dog集合中这种错误

这种检查是发生在编译阶段,这样就可以提早发现问题

对应的类为Collections工具类,方法如下图

类型安全检查

代码如下

// 动态类型安全检查,在与旧代码兼容时,防止将Dog放到Cat集合中类似的问题

// === 检查之前 ===
List list = new ArrayList();
// 添加不报错
list.add("a");
list.add(1);
// 只有用的时候,才会报错
Integer i = (Integer) list.get(0); // 这里运行时报错

// === 检查之后 ===
List list2 = Collections.checkedList(new ArrayList<>(), Integer.class);
// 插入时就会报错
list2.add("a"); // 这里编译时就报错,提前发现错误
list2.add(1);

总结

泛型的作用:

  1. 提高类型安全性:预防各种类型转换问题
  2. 增加程序可读性:所见即所得,看得到放进去的是啥,也知道会取出啥
  3. 提高代码重用性:多种同类型的数据(比如Animal下的Dog,Cat)可以集合到一处来处理,从而调高代码重用性

类型擦除:

​ 泛型T在传入实参时,实参的类型会被擦除为限定类型(即中的T),如果没有限定类型,则默认为Object

通配符限定:

  1. :子类型的通配符限定,以查询为主,比如消费者集合场景
  2. :超类型的通配符限定,以添加为主,比如生产者集合场景

后记

最后,感谢大家的观看,谢谢