Java泛型
本文大部分内容转载至Java 中的泛型(两万字超全详解)
1. 泛型概述
1.1 初识泛型
泛型(Generic):是 Java 5 中引入的一种特性,可以在编译阶段约束操作的数据类型,并进行检查。
泛型的格式:<泛型标识>
尖括号 <> 中的 泛型标识被称作是
类型参数
,用于指代任何数据类型。泛型标识是任意设置的(如果你想可以设置为 Hello都行),Java 常见的泛型标识以及其代表含义如下:
T :代表一般的任何类。
E :代表 Element 元素的意思,或者 Exception 异常的意思。
K :代表 Key 的意思。
V :代表 Value 的意思,通常与 K 一起配合使用。
S :代表 Subtype 的意思,文章后面部分会讲解示意。
PS:泛型只能支持引用数据类型
1.1.1 泛型的基本概念
泛型的主要特点是在定义时不指定具体的类型,而在使用的时候再指定类型的一种特性。这种特性提供了更大的代码复用和类型安全。在Java中,泛型是通过类型参数化来实现的,即通过在定义类、接口和方法时使用 类型参数
(如T
、E
、K
、V
等),然后在实例化或调用时指定具体的类型。
1.1.2 为什么要使用泛型?
- 类型安全:泛型能够在编译时检查类型,从而避免了运行时可能出现的
ClassCastException
。通过使用泛型,你可以确保集合中只包含特定类型的对象,从而减少了类型转换的需要。 - 代码重用:泛型允许你编写更加通用的代码。例如,你可以编写一个能够处理任何类型数据的泛型集合类,而不是针对每种数据类型编写一个专门的集合类。这大大提高了代码的可重用性。
- 减少代码冗余:使用泛型可以避免大量的显式类型转换代码。这不仅可以使代码更加简洁,还可以减少出错的可能性。
- 提高可读性:泛型的使用可以使代码更加清晰易懂。通过查看泛型类型的声明,你可以更容易地理解代码的预期用途和所处理的数据类型。
- 性能优化:在某些情况下,泛型可以通过避免不必要的装箱和拆箱操作来提高性能。这是因为泛型允许在编译时确定具体的类型,从而避免了运行时的类型转换开销。
1.2 泛型使用场景
没有泛型的时候,集合类型怎么破?
在 ArrayList 集合中,可以放入所有类型的对象,假设现在有以下一个存储了 String 类型 和 Integer 类型的对象的 ArrayList 集合。
代码如下:
1 | public class TestGenerics { |
可以看到在没有使用泛型的时候,迭代器也拿到了集合里的每一个对象并成功进行打印:
1 | aaa |
有人可以就要问了:这不是没毛病吗?又没报错还成功得到了结果。那为什么还要指定泛型呢?
不知道大家有没有注意到,在没有使用泛型的时候,当要拿到集合里面的每一个元素的时候迭代器默认会给我们返回 Object
对象,而正是这里有一个致命的缺陷 —— 多态的弊端是不能访问子类的特有功能。
例:假如想要得到上面集合里面每个元素的长度(调用 String 特有的 length()
方法)
1 | public class TestGenerics { |
输出结果:
上述代码在编译时没有报错,但在运行时却抛出了一个 ClassCastException
异常,其原因是 Integer 对象不能强转为 String 类型。
那如何可以避免上述异常的出现?即我们希望当我们向集合中添加了不符合类型要求的对象时,编译器能直接给我们报错,而不是在程序运行后才产生异常。这个时候便可以使用泛型
了。
使用泛型代码如下:
1 | public class TestGenerics { |
<String>
是一个泛型,其限制了 ArrayList 集合中存放对象的数据类型只能是 String,当添加一个非 String 对象时,编译器会直接报错。这样,我们便解决了上面产生的 ClassCastException
异常的问题(这样体现了泛型的类型安全检测机制)。
1.3 泛型的实际应用
泛型在Java编程中得到了广泛的应用,特别是在集合框架中。例如,ArrayList<T>
、HashSet<T>
等集合类都是泛型类,它们可以接受任何类型的对象作为元素,同时保证了类型安全。此外,泛型还可以用于自定义泛型类、泛型接口和泛型方法,以满足特定的编程需求,下面将正式介绍泛型的相关知识。
2. 泛型类
2.1 泛型类的定义
(1)类型参数用于类的定义中,则该类被称为泛型类。通过泛型可以完成对一组类的操作对外开放相同的接口。最典型的就是各种容器类,如:List、Set、Map等。
泛型类的基本语法如下:
1 | class 类名称 <泛型标识> { |
举例:
1 | public class Generic<T> { |
在泛型类中,类型参数定义的位置有三处,分别为:
- 非静态的成员属性类型
- 非静态方法的形参类型(包括非静态成员方法和构造器)
- 非静态的成员方法的返回值类型
(2)泛型类中的静态方法和静态变量不可以使用泛型类所声明的类型参数
代码如下:
1 | public class TestGenericsDemo<T> { |
- 泛型类中的类型参数的确定是在创建泛型类对象的时候(例如
ArrayList<Integer>
)。 - 而静态变量和静态方法在类加载时已经初始化,直接使用类名调用;在泛型类的类型参数未确定时,静态成员有可能被调用,因此泛型类的类型参数是不能在静态成员中使用的。
(3)静态泛型方法中可以使用自身的方法签名中新定义的类型参数(即泛型方法,后面会说到),而不能使用泛型类中定义的类型参数。
代码如下:
1 | public class TestGenericsDemo<T> { |
(4)泛型类不只接受一个类型参数,它还可以接受多个类型参数。
代码如下:
1 | public class MultiType<E, T> { |
2.2 泛型类的使用
在创建泛型类的对象时,必须指定类型参数 T 的具体数据类型,即尖括号 <> 中传入的什么数据类型,T 便会被替换成对应的类型。如果 <> 中什么都不传入,则默认是 <Object>
。
假设有个泛型类如下:
1 | public class Generic<T> { |
当创建一个 Generic<T> 类对象时,会向尖括号 <> 中传入具体的数据类型。
代码如下:
1 |
|
传入 String 类型时,原泛型类可以想象它会自动扩展,其类型参数会被替换。
自动扩展如下:
1 | public class Generic { |
- 可以发现,泛型类中的
类型参数 T
被 <> 中的 String 类型全部替换了。 - 使用泛型的上述特性便可以在集合中限制添加对象的数据类型,若集合中添加的对象与指定的泛型数据类型不一致,则编译器会直接报错,这也是泛型的类型安全检测机制的实现原理。
3. 泛型接口
泛型接口和泛型类的定义差不多,基本语法如下:
1 | public interface 接口名<类型参数> { |
举例如下:
1 | public interface Inter<T> { |
泛型接口中的类型参数,在该接口被继承或者被实现时确定。解释如下:
(1)定义一个泛型接口如下:
PS:在泛型接口中,静态成员也不能使用泛型接口定义的类型参数。
1 | interface IUsb<U, R> { |
(2)定义一个接口 IA 继承了泛型接口 IUsb,在接口 IA 定义时必须确定泛型接口 IUsb 中的类型参数。
代码如下:
1 | // 在继承泛型接口时,必须确定泛型接口的类型参数 |
(3)定义一个类 BB 实现了泛型接口 IUsb,在类 BB 定义时需要确定泛型接口 IUsb 中的类型参数。
代码如下:
1 | // 实现接口时,需要指定泛型接口的类型参数 |
(4)定义一个类 CC 实现了泛型接口 IUsb 时,若是没有确定泛型接口 IUsb 中的类型参数,则默认为 Object。
代码如下:
1 | // 实现泛型接口时没有确定类型参数,则默认为 Object |
(5)定义一个类 DD 实现了泛型接口 IUsb 时,若是没有确定泛型接口 IUsb 中的类型参数,也可以将 DD 类也定义为泛型类,其声明的类型参数必须要和接口 IUsb 中的类型参数相同。
代码如下:
1 | // DD 类定义为泛型类,则不需要确定接口的类型参数 |
补充上面(2)
上面(2)提到定义一个接口继承了泛型接口 IUsb,在接口定义时必须确定泛型接口 IUsb 中的类型参数。
1 | // 在继承泛型接口时,必须确定泛型接口的类型参数 |
也可以不确定泛型接口 IUsb 中的类型参数:定义一个接口 IB 实现了泛型接口 IUsb 时,若是没有确定泛型接口 IUsb 中的类型参数,也可以将 IB 接口也定义为泛型接口,其声明的类型参数必须包含和接口 IUsb 中的类型参数,并且可以允许 IB 接口继续扩展其他泛型参数
代码如下:
1 | // 在继承泛型接口时,可以允许接口继续扩展其他泛型参数 |
4. 泛型方法
4.1 泛型方法的定义
当在一个方法签名中的返回值前面声明了一个 <T>
时,该方法就被声明为一个泛型方法。<T>
表明该方法声明了一个类型参数 T,并且这个类型参数 T 只能在该方法中使用。当然,泛型方法中也可以使用泛型类中定义的泛型参数。
基本语法如下:
1 | public <类型参数> 返回类型 方法名(类型参数 变量名) { |
(1)只有在方法签名中声明了
<T>
的方法才是泛型方法,仅使用了泛型类定义的类型参数的方法并不是泛型方法。
举例如下:
1 | public class Test<U> { |
(2)泛型方法中可以同时声明多个类型参数。
举例如下:
1 | public class TestMethod<U> { |
(3)泛型方法中也可以使用泛型类中定义的泛型参数。
举例如下:
1 | public class TestMethod<U> { |
(4)特别注意的是:泛型类中定义的类型参数和泛型方法中定义的类型参数是相互独立的,它们一点关系都没有。
举例如下:
1 | public class Test<T> { |
上面代码中,Test<T>
是泛型类,testMethod()
是泛型类中的普通方法,其使用的类型参数是与泛型类中定义的类型参数。
而 testMethod1()
是一个泛型方法,它使用的类型参数是与方法签名中声明的类型参数。
虽然泛型类中定义的类型参数标识和泛型方法中定义的类型参数标识都为 <T>
,但它们彼此之间是相互独立的。也就是说,泛型方法始终以自己声明的类型参数为准。
PS:
<T>
表明该方法声明了一个类型参数 T,并且这个类型参数 T 只能在该方法中使用。- 为了避免混淆,如果在一个泛型类中存在泛型方法,那么两者的类型参数最好不要同名。
- 与泛型类的类型参数定义一样,此处泛型方法中的 T 可以写为
任意标识
,常见的如 T、E、K、V 等形式的参数常用于表示泛型。
补充一点:将静态方法声明为泛型方法
前面在泛型类的定义中提到,在静态成员中不能使用泛型类定义的类型参数,但我们可以将静态成员方法定义为一个泛型方法。
代码如下:
1 | public class Test<T> { |
4.2 泛型方法的使用
泛型类,在创建类的对象的时候确定类型参数的具体类型;泛型方法,在调用方法的时候再确定类型参数的具体类型。
泛型方法签名中声明的类型参数只能在该方法里使用,而泛型接口、泛型类中声明的类型参数则可以在整个接口、类中使用。
当调用泛型方法时,根据外部传入的实际对象的数据类型,编译器
就可以判断出 类型参数 T
所代表的具体数据类型。
举例如下:
1 | public class Demo { |
不难发现,当调用泛型方法时,根据传入的实际对象,编译器
会判断出类型形参 T 所代表的具体数据类型。
4.3 泛型方法中的类型推断
在调用泛型方法的时候,可以显式地指定类型参数,也可以不指定。
- 当泛型方法的形参列表中有多个类型参数时,在不指定类型参数的情况下,方法中声明的的类型参数为泛型方法中的几种类型参数的共同父类的最小级,直到 Object。
- 在指定了类型参数的时候,传入泛型方法中的实参的数据类型必须为指定数据类型或者其子类。
举例如下:
1 | public class Test { |
5. 类型擦除
5.1 什么是类型擦除
泛型的本质是将 数据类型参数化
,它通过擦除的方式来实现,即编译器会在编译期间擦除
代码中的所有泛型语法并相应的做出一些类型转换动作。
换而言之,泛型信息只存在于代码编译阶段,在代码编译结束后,与泛型相关的信息会被擦除掉,专业术语叫做 类型擦除
。也就是说,成功编译过后的 class 文件中不包含任何泛型信息,泛型信息不会进入到 运行时阶段
。
先看一个例子,假如我们给 ArrayList 集合传入两种不同的数据类型,并比较它们的类信息。
代码如下:
1 | public class GenericType { |
- 在这个例子中,我们定义了两个 ArrayList 集合,不过一个是
ArrayList<String>
,只能存储字符串。一个是ArrayList<Integer>
,只能存储整型对象。我们通过 arrayString 对象和 arrayInteger 对象的getClass()
方法获取它们的类信息并比较,发现结果为true
。 - 明明我们在 <> 中传入了两种不同的数据类型,按照上文所说的,它们的类型参数 T 不是应该被替换成我们传入的数据类型了吗,那为什么它们的类信息还是相同呢? 这是因为,在编译期间,所有的
泛型信息
都会被擦除,ArrayList<Integer>
和ArrayList<String>
类型,在编译后都会变成ArrayList<Object>
类型。
再看一个例子,假设定义一个泛型类如下:
1 | public class Caculate<T> { |
在该泛型类中定义了一个属性 num,该属性的数据类型是泛型类声明的类型参数 T ,这个 T 具体是什么类型,我们也不知道,它只与外部传入的数据类型有关。将这个泛型类反编译。
代码如下:
1 | public class Caculate { |
可以发现编译器 擦除
了 Caculate 类后面的泛型标识 <T>
,并且将 num 的数据类型替换为 Object 类型,而替换了 T 的数据类型我们称之为 原始数据类型
。
那么是不是所有的类型参数被擦除后都以 Object 类进行替换呢?
- 答案是否定的,大部分情况下,类型参数 T 被擦除后都会以 Object 类进行替换;而有一种情况则不是,那就是使用到了 extends 和 super 语法的
有界类型参数
(即泛型通配符
,后面我们会详细解释)。
再看一个例子,假设定义一个泛型类如下:
1 | public class Caculate<T extends Number> { |
将其反编译:
1 | public class Caculate { |
- 可以发现,使用到了 extends 语法的类型参数 T 被擦除后会替换为 Number 而不再是 Object。
- extends 和 super 是一个限定类型参数边界的语法,extends 限定 T 只能是 Number 或者是 Number 的子类。 也就是说,在创建 Caculate 类对象的时候,
尖括号 <> 中只能传入 Number 类或者 Number 的子类的数据类型
,所以在创建 Caculate 类对象时无论传入什么数据类型,Number 都是其父类,于是可以使用 Number 类作为 T 的原始数据类型
,进行类型擦除
并替换。(这一部分涉及到了泛型通配符,在下面还会具体介绍)
5.2 类型擦除的原理
假如我们定义了一个 ArrayList<Integer>
泛型集合,若向该集合中插入 String 类型的对象,不需要运行程序,编译器就会直接报错。这里可能有小伙伴就产生了疑问:
- 不是说泛型信息在编译的时候就会被擦除掉吗?那既然泛型信息被擦除了,如何保证我们在集合中只添加指定的数据类型的对象呢?
- 换而言之,我们虽然定义了 ArrayList< Integer > 泛型集合,但其泛型信息最终被擦除后就变成了
ArrayList< Object > 集合
,那为什么不允许向其中插入 String 对象呢?
Java 是如何解决这个问题的?
- 其实在创建一个泛型类的对象时, Java 编译器是先检查代码中传入
<T>
的数据类型,并记录下来,然后再对代码进行编译,编译的同时进行类型擦除
;如果需要对被擦除了泛型信息的对象进行操作,编译器会自动将对象进行类型转换。
可以把泛型的 类型安全检查机制
和 类型擦除
想象成演唱会的验票机制:以 ArrayList<Integer>
泛型集合为例。
- 当我们在创建一个
ArrayList<Integer>
泛型集合的时候,ArrayList 可以看作是演唱会场馆,而<T>
就是场馆的验票系统,Integer 是验票系统设置的门票类型; - 当验票系统设置好为
<Integer>
后,只有持有 Integer 门票的人才可以通过验票系统,进入演唱会场馆(集合)中;若是未持有 Integer 门票的人想进场,则验票系统会发出警告(编译器报错)。 - 在通过验票系统时,门票会被收掉(类型擦除),但场馆后台(JVM)会记录下观众信息(泛型信息)。
- 进场后的观众变成了没有门票的普通人(原始数据类型)。但是,在需要查看观众的信息时(操作对象),场馆后台可以找到记录的观众信息(编译器会自动将对象进行类型转换)。
举例如下:
1 | public class GenericType { |
擦除 ArrayList< Integer > 的泛型信息后,get() 方法的返回值将返回 Object 类型,但编译器会自动插入 Integer 的强制类型转换。也就是说,编译器把 get() 方法调用翻译为两条字节码指令:
- 对原始方法 get() 的调用,返回的是 Object 类型
- 将返回的 Object 类型强制转换为 Integer 类型
代码如下:
1 | Integer n = arrayInteger.get(0);// 这条代码底层如下: |
5.3 类型擦除小结
泛型信息(包括泛型类、接口、方法)只在代码编译阶段存在
,在代码成功编译后,其内的所有泛型信息都会被擦除,并且类型参数 T 会被统一替换为其原始类型
(默认是 Object 类,若有 extends 或者 super 则另外分析);- 在泛型信息被擦除后,若还需要使用到对象相关的泛型信息,编译器底层会
自动进行类型转换
(从原始类型转换为未擦除前的数据类型)。
6. 泛型通配符
6.1 泛型的继承
在介绍泛型通配符之前,先提出一个问题,在 Java 的多态中,我们知道可以将一个子类对象赋值给其父类的引用,这也叫向上转型
。
举例如下:
1 | public class GenericType { |
- 上面的代码很好得体现了 Java 的多态特性。
在 Java 标准库中的集合 ArrayList<T>
类实现了 List<T>
接口,其源码大致如下:
1 | public class ArrayList<T> implements List<T> { |
那现在我们思考一个问题,在
ArrayList<T>
泛型集合中,当传入<T>
中的数据类型相同时,是否还能将一个ArrayList<T>
对象赋值给其父类的引用List<T>
。
代码如下:
1 | public class GenericType { |
- 上面的代码没有问题, 即
ArrayList<T>
对象可以向上转型为List<T>
,但两者传入<T>
中的数据类型必须相同。
继续思考一个问题,已知 Integer 类是 Number 类的子类,那如果 ArrayList<> 泛型集合中,在 <> 之间使用
向上转型
,也就是将ArrayList<Integer>
对象赋值给List<Number>
的引用,是否被允许呢?
举例如下:
1 | public class GenericType { |
- 上面代码会报错,我们发现并不能把
ArrayList<Integer>
对象赋值给List<Number>
的引用,甚至不能把ArrayList<Integer>
对象赋值给ArrayList<Number>
的引用。这也说明了在一般泛型中,不能向上转型
。
这是为什么?如果我们假设
ArrayList<Integer>
可以向上转型为ArrayList<Number>
。
观察下面代码:
1 | public class GenericType { |
- 当我们把一个
ArrayList<Integer>
向上转型为ArrayList<Number>
类型后,这个ArrayList<Number>
集合就可以接收 Float 对象了,因为 Float 类是 Number 类的子类。 - 但是,
ArrayList<Number>
实际上和ArrayList<Integer>
是同一个集合,而在泛型的定义中,ArrayList<Integer>
集合是不可以接收 Float 对象的。这是因为,在使用 get() 方法获取集合元素的时候,编译器会自动将 Float 对象强转成 Integer 对象,而这会产生ClassCastException
异常。
正因如此,编译器为了避免发生这种错误,根本就不允许把 ArrayList<Integer>
对象向上转型为 ArrayList<Number>
;换而言之, ArrayList<Integer>
和 ArrayList<Number>
两者之间没有继承关系。
6.2 泛型通配符的引入
我们上面讲到了泛型的继承关系,ArrayList<Integer>
不是 ArrayList<Number>
的子类。
1、先看一个问题:假设我们定义了一个 Pair<T>
类,如下:
1 | public class Pair<T> { |
2、然后,我们针对 Pair<Number>
类型写了一个静态方法,它接收的参数类型是 Pair<Number>
。
代码如下:
1 | public class PairHelper { |
3、在测试类中创建一个 Pair<Number>
对象,并调用 addPair() 方法。
代码如下:
1 | public class Main { |
4、上面的代码正常编译运行。但我们发现,在实际创建 Pair<Number>
对象的时候,我们传入的实参 (1, 2) 实际上是 Integer 类型;那我们是否可以直接创建一个 Pair<Integer>
对象,并将其传给 add() 方法呢?
代码如下:
1 | public class Main { |
- 编译器会直接报错,原因是
Pair<Integer>
并不是Pair<Number>
的子类,而 addPair() 方法的形参数据类型为Pair<Number>
。因此,Pair<Integer>
对象不能传给 addPair() 方法。
那有没有办法使得 addPair() 方法可以接收 Pair<Integer>
对象?总不能重新定义一个新的 addPair() 方法来处理 Pair<Integer>
对象吧,这显然与 Java 中的多态理念相违背。
- 因此我们需要一个在逻辑上可以表示为
Pair<Integer>
和Pair<Number>
这两者的父类引用类型,由此,泛型通配符便应运而生。
6.3 什么是泛型通配符
在现实编码中,确实有这样的需求,希望泛型能够处理 某一类型范围内
的类型参数,比如某个泛型类和它的子类,为此 Java 引入了泛型通配符
这个概念。
泛型通配符有 3 种形式:
<?>
:被称作无限定的通配符。<? extends T>
:被称作有上界的通配符。<? super T>
:被称作有下界的通配符。
在引入泛型通配符之后,我们便得到了一个在 逻辑上
可以表示为某一类型参数范围的父类引用类型。举例来说,泛型通配符可以表示 Pair<Integer>
和 Pair<Number>
两者的 父类引用类型
。
接下来将分别介绍 3 种形式的泛型通配符。
6.4 上界通配符 <? extends T>
6.4.1 <? extends T> 的定义
上界通配符 <? extends T>
:T 代表了类型参数的上界,<? extends T>
表示类型参数的范围是 T 和 T 的子类。需要注意的是: <? extends T>
也是一个数据类型实参,它和 Number、String、Integer 一样都是一种实际的数据类型。
(1)在
泛型的继承
中我们说到,ArrayList<Integer>
和ArrayList<Number>
之间不存在继承关系。而引入上界通配符
的概念后,我们便可以在逻辑上将 ArrayList<? extends Number> 看做是ArrayList<Integer>
的父类,但实质上它们之间没有继承关系。
举例如下:
1 | public class GenericType { |
- 逻辑上可以将 ArrayList<? extends Number> 看做是
ArrayList<Integer>
的父类,因此,在使用了上界通配符 <? extends Number> 后,便可以将ArrayList<Integer>
对象向上转型
了。
(2)ArrayList<? extends Number> 可以代表
ArrayList<Integer>
、ArrayList<Float>
、… 、ArrayList<Number>
中的某一个集合
,但我们不能指定 ArrayList<? extends Number> 的数据类型。(这里有点难理解)
举个例子:
1 | public class GenericType { |
- 可以这样理解,ArrayList<? extends Number> 集合表示了:我这个集合可能是
ArrayList<Integer>
集合,也可能是ArrayList<Float>
集合,… ,还可能是ArrayList<Number>
集合;但到底是哪一个集合,不能确定;程序员也不能指定。 - 所以,在上面代码中,创建了一个 ArrayList<? extends Number> 集合 list,但我们并不能往 list 中添加 Integer、Float 等对象,这也说明了 list 集合并不是某个确定了数据类型的集合。
思考:那既然 ArrayList<? extends Number> 可以代表
ArrayList<Integer>
或ArrayList<Float>
,为什么不能向其中加入 Integer、Float 等对象呢?
- 其原因是 ArrayList<? extends Number> 表示的是一个
未知类型的 ArrayList 集合
,它可以代表ArrayList<Integer>
或ArrayList<Float>
… 等集合,但却不能确定它到底是ArrayList<Integer>
还是ArrayList<Float>
集合。 - 因此,
泛型的特性
决定了不能往 ArrayList<? extends Number> 集合中加入 Integer 、 Float 等对象,以防止在获取 ArrayList<? extends Number> 集合中元素的时候,产生ClassCastException
异常。
那为什么还需要引入 上界统配符
的概念?—— 为了拓展方法形参中类型参数的范围。
(1)在
泛型通配符的引入
部分,我们提出了一个问题,有没有办法使得 addPair(Pair<Number> p) 方法接收Pair<Integer>
对象?而在有了上界通配符的概念后,这个问题便有了解决办法,就是将 addPair() 方法改写。
代码如下:
1 | // 改写前 |
- 改写 addPair() 方法,用
<? extends Number>
替换了<Number>
,由于Pair<Integer>
可以向上转型
为 Pair<? extends Number> ,所以调用 addPair() 方法时,我们便可以传入Pair<Integer>
对象了。 - 除了可以传入
Pair<Integer>
对象,我们还可以传入Pair<Double>
对象,Pair<BigDecimal>
对象等等,因为 Double 类和 BigDecimal 类也都是 Number 的子类。
6.4.2 <? extends T> 的用法
上面说到,我们无法确定 ArrayList<? extends Number> 具体是什么数据类型的集合,因此其 add() 方法会受限(即不能往集合中添加任何数据类型的对象);但是可以往集合中添加 null,因为 null 表示任何类型。
我们可以调用 get() 方法从集合中获取元素,并赋值给集合中的最高父类 Number (即 <? extends T> 的上界
)。
(1)上界通配符 <? extends T> 的正确用法:
1 | public class Test { |
输出如下:
1 | 1 2 |
在 printIntVal() 方法中,其形参为 ArrayList<? extends Number>,因此,可以给该方法传入
ArrayList<Integer>
、ArrayList<Float>
等集合。需要注意的是:在 printIntVal() 方法内部,必须要将传入集合中的元素赋值给 Number 对象,而不能赋值给某个子类对象; 是因为根据 ArrayList<? extends Number> 的特性,并不能确定传入集合的数据类型(即不能确定传入的是
ArrayList<Integer>
还是ArrayList<Float>
)。假设在 printIntVal() 方法中存在下面代码:
1
Integer intNum = (Integer) number;
若是传入集合为
ArrayList<Float>
,则必然会产生ClassCastException
异常。
(2)上界通配符 <? extends T> 的错误用法:
1 | public class Test { |
- 在 ArrayList<? extends Number> 集合中,不能添加任何数据类型的对象,只能添加空值 null,因为 null 可以表示任何数据类型。
6.4.3 <? extends T> 小结
一句话总结:使用 extends 通配符表示可以读,不能写。
6.5 下界通配符 <? super T>
6.5.1 <? super T> 的定义
下界通配符 <? super T>
:T 代表了类型参数的下界,<? super T>
表示类型参数的范围是 T 和 T 的超类,直至 Object。需要注意的是: <? super T>
也是一个数据类型实参,它和 Number、String、Integer 一样都是一种实际的数据类型。
(1)ArrayList<? super Integer> 在逻辑上表示为 Integer 类以及 Integer 类的所有父类,它可以代表
ArrayList<Integer>
、ArrayList<Number>
、ArrayList<Object>
中的某一个集合
,但实质上它们之间没有继承关系。
举个例子:
1 | public class GenericType { |
- 逻辑上可以将 ArrayList<? super Integer> 看做是
ArrayList<Number>
的父类,因此,在使用了下界通配符 <? super Integer> 后,便可以将ArrayList<Number>
对象向上转型
了。
(2)ArrayList<? super Integer> 只能表示指定类型参数范围中的
某一个集合
,但我们不能指定 ArrayList<? super Integer> 的数据类型。(这里有点难理解)
看一个例子:
1 | public class GenericType { |
- 这里奇怪的地方出现了,为什么和ArrayList<? extends Number> 集合不同, ArrayList<? super Number> 集合中可以添加 Number 类及其子类的对象呢?
- 其原因是, ArrayList<? super Number> 的下界是
ArrayList<Number>
。因此,我们可以确定 Number 类及其子类的对象自然可以加入 ArrayList<? super Number> 集合中; 而Number 类的父类对象
就不能加入 ArrayList<? super Number> 集合中了,因为不能确定 ArrayList<? super Number> 集合的数据类型。
6.5.2 <? super T> 的用法
(1)下界通配符 <? super T> 的正确用法:
1 | public class Test { |
输出如下:
1 | [1, 1.1, 0, 1.0] |
- 与带有上界通配符的集合
ArrayList<? extends T>
的用法不同,带有下界通配符的集合ArrayList<? super Number>
中可以添加 Number 类及其子类的对象;ArrayList<? super Number>
的下界就是ArrayList<Number>
集合,因此,其中必然可以添加 Number 类及其子类的对象;但不能添加 Number 类的父类对象(不包括 Number 类)。
(2)下界通配符 <? super T> 的错误用法:
1 | public class Test { |
- 注意,
ArrayList<? super Number>
代表了ArrayList<Number>
、ArrayList<Object>
中的某一个集合,而 ArrayList< Integer > 并不属于ArrayList<? super Number>
限定的范围,因此,不能往 fillNumList() 方法中传入ArrayList<Integer>
集合。 - 并且,不能将传入集合的元素赋值给 Number 对象,因为传入的可能是
ArrayList<Object>
集合,向下转型可能会产生ClassCastException
异常。 - 不过,可以将传入集合的元素赋值给 Object 对象,因为 Object 是所有类的父类,不会产生
ClassCastException
异常,但这样的话便只能调用 Object 类的方法了,不建议这样使用。
6.5.3 <? super T> 小结
一句话总结:使用 super 通配符表示可以写,不能读。
6.6 无限定通配符 <?>
我们已经讨论了 <? extends T>
和 <? super T>
作为方法参数的作用。实际上,Java 的泛型还允许使用无限定通配符<?>,即只定义一个?
符号。
- 无界通配符
<?>
:?
代表了任何一种数据类型,能代表任何一种数据类型的只有 null。需要注意的是:<?>
也是一个数据类型实参,它和 Number、String、Integer 一样都是一种实际的数据类型。 - 注意:Object 本身也算是一种数据类型,但却不能代表任何一种数据类型,所以
ArrayList< Object >
和ArrayList<?>
的含义是不同的,前者类型是 Object,也就是继承树的最高父类,而后者的类型完全是未知的;ArrayList<?>
是ArrayList<Object>
逻辑上的父类。
(1)ArrayList<?> 在逻辑上表示为所有数据类型的父类,它可以代表
ArrayList<Integer>
、ArrayList<Number>
、ArrayList<Object>
中的某一个集合,但实质上它们之间没有继承关系。
举例如下:
1 | public class GenericType { |
- 上述代码是可以正常编译运行的,因为
ArrayList<?>
在逻辑上是ArrayList<Integer>
的父类,可以安全地向上转型
。
(2)
ArrayList<?>
既没有上界也没有下界,因此,它可以代表所有数据类型的某一个集合,但我们不能指定ArrayList<?>
的数据类型。
举例如下:
1 | public class GenericType { |
ArrayList<?>
集合的数据类型是不确定的,因此我们只能往集合中添加 null;而我们从ArrayList<?>
集合中取出的元素,也只能赋值给 Object 对象,不然会产生ClassCastException
异常(原因可以结合上界和下界通配符理解)。
(3)大多数情况下,可以用类型参数
<T>
代替 <?> 通配符。
举例如下:
1 | static <?> void isNull(ArrayList<?> list) { |
6.7 <? extends T>与<? super T> 对比
(1)对于<? extends T> 类型,编译器将只允许读操作,不允许写操作。即只可以取值,不可以设值。
(2)对于<? super T> 类型,编译器将只允许写操作,不允许读操作。即只可以设值(比如 set 操作),不可以取值(比如 get 操作)。
- 以上两点都是针对于源码里涉及到了类型参数的方法而言的。比如对于 List 而言,不允许的写操作有 add 方法,因为它的方法签名是
boolean add(E e);
,此时这个形参 E 就变成了一个涉及了通配符的类型参数; - 而不允许的读操作有 get 方法,因为它的方法签名是
E get(int index);
,此时这个返回值 E 就变成了一个涉及了通配符的类型参数。
作为方法形参,<? extends T> 类型和 <? super T> 类型的区别在于:
- extends T> 允许调用读方法 `T get()` 获取 T 的引用,但不允许调用写方法 `set(T) ` 传入 T 的引用(传入 null 除外)。
- super T> 允许调用写方法`set(T)`传入 T 的引用,但不允许调用读方法 T `get()` 获取 T 的引用(获取 Object 除外)。
先记住上面的结论,我们来看 Java 标准库的 Collections 类定义的 copy() 方法。
(1)copy() 方法的作用是把一个 List 中的每个元素依次添加到另一个 List 中。它的第一个形参是 List<? super T>,表示
目标 List
,第二个形参是 List<? extends T>,表示源 List
。
代码如下:
1 | public class Collections { |
- 我们可以简单地用 for 循环实现复制。在 for 循环中,我们可以看到,对于 <? extends T> 集合 src,我们可以安全地获取
类型参数 T
的引用(即变量 t),而对于 <? super T> 的集合 dest,我们可以安全地传入类型参数 T
的引用。
(2)copy() 方法的定义完美地展示了通配符 extends 和 super 的意图:
- copy() 方法内部不会读取 dest,因为不能调用 dest.get() 方法来获取 T 的引用(如果调用则编译器会直接报错)。
- copy() 方法内部也不会修改 src,因为不能调用 src.add(T) 方法(如果调用则编译器会直接报错)。
这是由
编译器检查
来实现的。如果在方法代码中意外修改了 src 集合,或者意外读取了 dest ,就会导致一个编译错误。
代码如下:
1 | public class Collections { |
- 根据上面介绍的,获取 <? super T> 集合 dest 的元素后只能赋值给 Object 对象,而不能赋值给其下界类型 T;我们不能向 <? extends T> 集合 src 中添加任何类型的对象,除了 null。
(3)copy() 方法的另一个好处是可以安全地把一个 List< Integer >添加到 List< Number >,但是
无法反过来添加
。
代码如下:
1 | // 将 List<Integer> 复制到 List<Number> |
- 这个很好理解,
List<Number>
集合中可能有 Integer、Float 等对象,所以肯定不能复制到List<Integer>
集合中;而List<Integer>
集合中只有 Integer 对象,因此肯定可以复制到List<Number>
集合中。
6.8 PECS 原则
我们何时使用 extends,何时使用 super 通配符呢?为了便于记忆,我们可以用 PECS 原则:Producer Extends Consumer Super。
即:如果需要返回 T
,则它是生产者(Producer),要使用 extends 通配符;如果需要写入 T
,则它是消费者(Consumer),要使用 super 通配符。
还是以 Collections 的 copy() 方法为例:
1 | public class Collections { |
- 需要返回 T 的 src 是生产者,因此声明为
List<? extends T>
,需要写入 T 的 dest 是消费者,因此声明为List<? super T>
。
7. 面试题
1、Java中的泛型是什么 ? 使用泛型的好处是什么?
- 泛型是一种参数化类型的机制。它可以使得代码适用于各种数据类型,从而编写更加通用的代码,例如集合框架。
- 泛型是一种编译时类型确认机制。它提供了代码编译期的类型安全,确保在泛型类型(通常为泛型集合)上只能使用正确类型的对象,避免了在运行时产生
ClassCastException
异常。
2、Java的泛型是如何工作的 ? 什么是类型擦除 ?
- 泛型的正常工作是依赖编译器在编译源码的时候,先进行类型检查,然后进行类型擦除并且在类型参数出现的地方插入强制转换的相关指令实现的。
类型擦除
:编译器在编译时擦除了代码中所有与泛型相关的信息,所以在运行时不存在任何泛型信息。例如 List< String > 类在运行时仅用一个 List 类型来表示。而为什么要进行擦除呢?这是为了避免类型膨胀
。
3、什么是泛型中的限定通配符和非限定通配符 ?
- 限定通配符对类型参数的范围进行了限制。有两种限定通配符,一种是
<? extends T>
,它通过确保泛型类型必须是T 的子类
来设定类型参数的上界;另一种是<?super T>
,它通过确保泛型类型必须是T 的父类
来设定类型参数的下界。 - 泛型类型必须使用
限定范围内
的类型来进行初始化,否则会导致编译错误。另一方面<?>
表示了非限定通配符,因为 <?> 可以用任意数据类型来替代。
4、List<? extends T> 和 List <? super T> 之间有什么区别 ?
- 这和上一题有联系,有时面试官会用这个问题来评估你对泛型的理解,而不是直接问你什么是限定通配符和非限定通配符。
- 这两个 List 的声明都是限定通配符的例子,List<? extends T> 可以接受任何继承自
T 的类型
的 List,而 List<? super T> 可以接受任何T 的父类
构成的 List。 - 例如:
List<? extends Number>
可以接受 List< Integer > 或 List< Float >;List <? super Number>
可以接受 List< Object > 但不能接受 List< Integer >。
5、如何编写一个泛型方法,让它能接受泛型参数并返回泛型类型?
- 编写泛型方法并不困难,你需要用泛型类型来替代原始类型,比如使用 T,E,K,V 等被广泛认可的
类型占位符
。泛型方法的例子请参阅 Java 集合类框架,最简单的情况下,一个泛型方法可能会像这样:
1 | public class TestMethod<U> { |
6、Java 中如何使用泛型编写带有类型参数的类?
- 这是上一道题的延伸,面试官可能会要求你用泛型编写一个类型安全的类,而不是编写一个泛型方法。关键仍然是使用泛型类型来代替
原始类型
,而且要使用 JDK 中采用的类型占位符
。举例如下:
1 | public class Generic<T> { |
7、编写一段泛型程序来实现 LRU 缓存?
- 对于喜欢 Java 编程的人来说这相当于是一次练习。提示,LinkedHashMap 可以用来实现固定大小的 LRU 缓存,当 LRU 缓存已经满了的时候,它会把最老的键值对移出缓存。LinkedHashMap 提供了一个称为 removeEldestEntry() 的方法,该方法会被 put() 和 putAll() 调用来删除最老的键值对。
8、你可以把 List<String>
传递给一个接受 List<Object>
参数的方法吗?
- 对任何一个不太熟悉泛型的人来说,这个Java泛型题目看起来令人疑惑,因为乍看起来 String 是 Object 的子类,所以
List<String>
应当可以向上转型
为List<Object>
。但是事实并非如此,List<String>
与List<Object>
之间没有继承关系,真这样做的话会导致编译错误。
1 | List<Object> objectList; |
9、Array 中可以用泛型吗?
- 这可能是 Java 泛型面试题中最简单的一个了,当然前提是你要知道 Array 事实上并不支持泛型,这也是为什么《Effective Java》 一书中建议使用 List 来代替 Array,因为 List 可以提供编译期的
类型安全保证
,而 Array 却不能。
10、Java 中 List< Object > 和原始类型 List 之间的区别?
- 原始类型和 <Object> 之间的主要区别是,在编译时编译器不会对原始类型进行类型安全检查,却会对泛型类型 <Object> 进行检查。<Object> 通过使用 Object 作为类型参数,可以告知编译器可以接收任何数据类型的对象,比如 String 或 Integer。 这道题的考察点在于对泛型中
原始类型
的正确理解。 - 它们之间的第二点区别是,你可以把
任何泛型类型
传递给接收原始类型 List 的方法,但却不能把List<String>
传递给List<Object>
的方法,因为会产生编译错误。举例如下:
1 | public class Test { |
11、Java 中 List<?> 和 List<Object>
之间的区别是什么?
- 这道题跟上一道题看起来很像,实质上却完全不同。List<?> 是一个不确定的未知类型的 List,而 List<Object> 是一个确定的 Object 类型的 List。
List<?>
在逻辑上是所有List<T>
的父类,你可以把List<String>
、List<Integer>
等集合赋值给List<?>
的引用;而List<Object>
只代表了自己这个泛型集合类,只能把List<Object>
赋值给List<Object>
的引用,但是List<Object>
集合中可以加入任意类型的数据,因为 Object 类是最高父类
。 举例如下:
1 | List<?> listOfAnyType; |
12、Java 中 List< String > 和原始类型 List 之间的区别。
- 该题类似于“
List<Object>
和原始类型 List 之间的区别”。泛型数据类型是类型安全
的,而且其类型安全是由编译器
保证的,但原始类型 List 却不是类型安全
的。你不能把 String 之外的任何其它类型的对象存入List<String>
中,而你可以把任何类型的对象存入原始 List 中。 - 使用泛型数据类型你不需要进行
类型转换
,但是对于原始类型,你则需要进行显式的类型转换
。举例如下:
1 | List listOfRawTypes = new ArrayList(); |