Java 泛型
封面来源:碧蓝航线 铁血、音符 & 誓言 活动CG
本文参考:尚硅谷 宋红康 Java 零基础教程 P565-P576
其他参考链接:Java泛型(二) 协变与逆变
1. 为什么要使用泛型?
1.1 为什么要使用泛型
学习一个知识,得先明白这玩意能干嘛,为什么要用它。如果连这都不清楚,学习后又有什么用呢?
集合容器类在设计阶段 / 声明阶段不能确定这个容器到底实际存的是什么类型的对象,所以 在 JDK 1.5 之前只能把元素类型设计为 Object,JDK 1.5 之后使用泛型来解决这个问题。 因为这个时候除了元素的类型是不确定的,其他部分都是确定的,例如关于这个元素如何保存,如何管理等都是确定的,因此此时把元素的类型设计成一个参数,这个类型参数叫做范型。
Collection<E>
,List<E>
,ArrayList<E>
其中 <E>
就是类型参数,即泛型。
1.2 泛型的概念
所谓泛型,就是允许在定义类、接口时通过一个标识表示类中某个属性的类型或者是某个方法的返回值及参数类型。 这个类型参数将在使用时(例如,继承或实现这个接口,用这个类型声明变量创建对象时)确定(即传入实际的类型参数,也称为类型实参)。
从 JDK 1.5 以后,Java引入了“参数化类型( Parameterized type)”的概念,允许我们在创建集合时再指定集合元素的类型,正如:List<String>
, 这表明该 List 只能保存字符串类型的对象。
JDK 1.5 改写了集合框架中的全部接口和类,为这些接口、类增加了泛型支持,从而可以在声明集合变量、创建集合对象时传入类型实参。
1.3 泛型有无的区别
说了这个多,还是没说为啥要有泛型,直接 Object
不是也可以存数据吗?
主要原因有两点:
1、解决元素存储的安全性问题,就好比商品、药品是不同的类型,就不会弄错
2、解决获取数据元素时,需要类型强制转换的问题,好比不用每回拿商品、药品都要辨别
在 JDK 1.5 以前使用集合时,由于没有引入泛型,则会:
测试代码
在集合中没有使用泛型时:
1 | // 在集合中未使用泛型时 |
在集合中使用泛型,且使用迭代器进行遍历:
1 | // 在集合中使用泛型 |
需要注意的是: 泛型的类型必须是类,不能是基本数据类型,需要用到基本数据类型的位置,应该使用那个基本数据类型的包装类。
总的来说,使用泛型后有以下两个优点:
1、编译时就会进行类型检查,保证数据的安全
2、避免了强制类型转换
2. 自定义泛型
2.1 自定义泛型类
自定义泛型类:
1 | package com.yang.generic; |
继承了自定义泛型类的两个子类。
不再是一个泛型类:
1 | /** |
仍然是一个泛型类:
1 | /** |
测试代码:
1 | package com.yang.generic; |
如果子类在继承带泛型的父类时,指明了范型类型,则实例化子类对象时,不需要再指明泛型。
2.2 要点重点
1、泛型类可能有多个参数,此时应当将多个参数一起放在尖括号里。比如:<E1, E2, E3>
。
2、泛型类的构造器如下:public GenericClass(){}
,而不是 public GenericClass<E>(){}
。
3、实例化后,操作原来泛型的位置的结构必须与指定的泛型类型一致。
4、泛型不同的应用不能相互赋值,道理很简单,类型都不一样,赋值个 🔨 。
- 尽管在编译时
ArrayList<String>
和ArrayList<Integer>
是两种类型,但是在运行时只有一个ArrayList
被加载到 JVM中。
5、泛型如果不指定,将被擦除,泛型对应的类型均按照 Object
处理,但不等价于 Object
。
- 经验之谈: 泛型要使用就一路使用,不使用就一直别使用。
6、如果泛型类是一个接口或抽象类,则不可创建泛型类的对象。
7、JDK 1.7 后,有一个泛型简化操作:ArrayList<String> list = new ArrayList<>();
8、泛型的指定中不能使用基本数据类型,需要使用对应的包装类进行替代。
9、在类 / 接口上声明的泛型,在本类或本接口中即代表某种类型,可以作为非静态属性的类型、非静态方法的参数类型、非静态方法的返回值类型。注意: 在静态方法中不能使用类的泛型。
10、异常类不能是泛型的。
11、不能使用 new E[]
,原因也很简单,此时的 E 还是相当于一个变量,而使用 new 关键字时,需要保证类是指明的,但是可以:E[] elements = (E[])new Object[capacity];
- 参考:ArrayList 源码中 声明:
Object[] elementData
,而非泛型参数类型数组。
12、父类有泛型,子类可以选择保留泛型也可以选择指定泛型类型:
-
子类不保留父类的泛型:按需实现
- 没有类型,擦除
- 具体类型
-
子类保留父类的泛型:泛型子类
- 全部保留
- 部分保留
结论:子类必须是“富二代”,子类除了指定或保留父类的泛型,还可以增加自己的泛型。
2.3 自定义泛型方法
不是说方法中使用了类的泛型就叫做泛型方法,以下方法都不是泛型方法:
1 | public class Order<T> { |
泛型方法:在方法中出现了泛型的结构,泛型参数与类的泛型参数没有任何关系。换句话说,泛型方法所属的类是不是泛型类没有关系。
泛型方法示例:
1 | public class Order<T> { |
测试代码:
1 | // 泛型方法测试 |
需要注意的是: 在静态方法中不能使用类的泛型,但泛型方法是可以声明为静态的。 原因也很简单,泛型参数是在调用方法时确定的,并非在实例化类时确定。
2.4 使用场景
在实际业务的数据访问层中,会涉及到大量的增删改查,我们可以将一些简单的增删改查提取出来,并将其设置成泛型类,如:
1 | public class DAO<T> { |
这些简单的增删改查是可以复用的,基本上每个 DAO 中都有这些方法,因此将它们提取出来,后续使用的时候直接继承这个类就可以了。如:
1 | public class StudenDao extends DAO<Student> { |
3. 继承与泛型
假设 类A 是类 B 的父类,那么 G<A>
与 G<B>
有什么关系?
1 | // 类 A 是类 B 的父类,G<A> 与 G<B> 无子父类关系,二者是并列关系 |
上述代码在编译器中直接报错,编译不通过,证明是不能那么写的。
但是现在又有另外一种情况,类 A 是类 B 的父类,A<G>
与 B<G>
又有什么关系呢?
1 | // 类 A 是类 B 的父类,A<G> 是 B<G> 的父类 |
简单!👍
4. 通配符
4.1 简单使用
上文说到:类A 是类 B 的父类, G<A>
与 G<B>
是没有子父类关系的,但是它俩还是有一点关系的。这里涉及到通配符 ?
的使用:
1 | // 通配符 ? 的使用 |
4.2 写入与读取
在使用了通配符 ?
后,可以进行写入和读取数据吗?
在 4.1 的测试代码中进行修改,最终得到以下代码:
1 |
|
总结一下,对于 List<?>
不能向其内部添加数据(除了 null
,但是并没有什么用,谁没事添加这玩意),但可以读取其中的数据,读取的数据类型是 Object
。
4.3 存在限制条件
在有些代码中,我们可以看到以下类似的写法:
1 | List<? extends Person> list1 = null; |
这种写法就是一种存在限制条件的泛型写法。那么这种写法与原来的写法有什么区别呢?
现有以下两个类,其中 Student 类是 Person 类的子类:
1 | public class Person { |
然后我们可以编写一个测试代码来测试一下:
1 | /* |
有些地方会报错的原因听我细细道来:
1、list1 = list5
会报错,是因为:list1 的范围应当小于等于 Person,而 list5 的范围是 Object
2、list2 = list3
会报错,是因为:list2 的范围应当大于等于 Person,而 list3 的范围是 Student
3、Student student = list1.get(0);
会报错,是因为:list1 的范围应当小于等于 Person,但也有可能比 Student 的范围更小
4、Person p = list2.get(0);
会报错,是因为:list2 的范围应当大于等于 Person,但也有可能比 Person 更大
5、list1.add(new Student());
会报错,是因为:list1 的范围应当小于等于 Person,但也有可能比 Student 的范围更小
6、list2.add(new Student());
不会报错,是因为:list2 的范围应当大于等于 Person,写 Person 类及其子类当然是可以的,但是不能写范围比 Person 大的类(不能写 Person 的父类)。如果我初始化的类型是 A(A 是 Person 的父类),但是又 add
了 B(B 也是 Person 的父类),两个类型不一样,肯定是不能 add
Person 的父类。
5. 协变与逆变
5.1 泛型是不变的
一个问题,下面的代码会编译报错吗?
1 | List<Object> list = new ArrayList<String>(); |
会编译报错!
为什么呢?
假设不会编译报错,那么我们就可以进行以下操作:
1 | List<Object> list = new ArrayList<String>(); |
很显然,这是不对的。像上述代码一样,无法保证 get
出来的数据一定是 String
类型的,因此 Java 将泛型设置为不变的。
也就是说,List<Object>
和 ArrayList<String>
之间是没有关系的,他们并不等价。
从上述示例也可以看出,Java 的泛型没有 内建的协变类型,无法将 List<Object>
和 ArrayList<String>
关联起来。当然了,也没有 内建的逆变类型。
需要额外提一句,Java 中数组是有内建的协变类型,比如下面这样是不会编译报错的:
1 | Number[] array = {new Integer(1), new Double(1.2), new Float(1.23)}; |
假设又有这样一段代码:
1 | Number[] array = new Integer[10]; |
尽管编译时不会报错,但在运行时处理的是 Integer[]
,因此在向数组中放置异构类型时会抛出异常。
但有些情况下可能需要将一个 new ArrayList<String>
转换为 List<Object>
,应该咋办呢?
5.2 泛型的协变
从【4. 通配符】中可知,在泛型中可以使用 ?
通配符,并且可以搭配 extends
和 super
使用,利用通配符就可以实现协变和逆变。
协变与逆变的定义
逆变与协变用来描述类型转换(type transformation)后的继承关系,其定义:如果 A、B 表示类型,f(⋅)
表示类型转换,≤
表示继承关系(比如,A≤B
表示 AA 是由 BB 派生出来的子类);
f(⋅)
是逆变(contravariant)的,当A≤B
时有f(B)≤f(A)
成立;f(⋅)
是协变(covariant)的,当A≤B
时有f(A)≤f(B)
成立;f(⋅)
是不变(invariant)的,当A≤B
时上述两个式子均不成立,即f(A)
与f(B)
之间没有继承关系。
协变
先看一个例子:
1 | public static Double sum(List<Number> list) { |
很明显,这是对一个数字列表进行求和。现在我想这么去调用:
1 | List<Double> list = new ArrayList<>(); |
乍眼一看,没啥问题。但由于 泛型是不变的,因此这里调用 sum(list)
会编译报错。
我们希望传入 sum()
方法的参数是一个 List
列表,并且其内部的元素是 Number
的子类。说到子类,难免会想到 extends
关键词,因此这里可以使用通配符 ?
和 extends
结合,实现 协变:
1 | public static Double sum(List<? extends Number> list) { |
类似 <? extends T>
被称为 子类通配符,意味着这个泛型可以匹配 T
及其子类。
这样做也会带来一定的代价:无法向集合中再添加元素了。
以 List<? extends Number>
为例,它可以合法地指向 List<Double>
,这时向集合内添加诸如 Integer
、Float
等类型的数据显然是不合法的。除此之外,List<? extends Number>
还可以合法地指向 List<Integer>
、List<Float>
等集合,具体指向什么只有在调用方法的时候才知道,也就是说编译器不知道 List<? extends Number>
的具体类型是什么,因此一旦使用了这种向上转型,将丢失向集合内添加元素的能力。
假设 Number
是非抽象类型的父类,针对 List<? extends Number>
而言,向这个集合中添加 Number
类型的元素也是不行的,因为编译器不知道指向的具体类型是什么,同时泛型还是不变的。
简单来说,使用 <? extends T>
后,编译器 只知道类型的上界 是 T
,而无法知道下界是什么,因此也就无法向集合内添加元素了。
也不是说不能添加任何元素,null
还是可以的,只不过没人会那么做。
5.3 泛型的逆变
前面已经说过,下面这段代码是会编译报错的:
1 | List<Object> list = new ArrayList<String>(); |
利用 协变,可以只调用 get()
方法,而不能调用 add()
方法。
那我就想调用 add()
方法怎么办呢?
先看 JDK8 ArrayList
中新增的一个方法:
1 | public boolean removeIf(Predicate<? super E> filter) { |
这个方法表示传入一个过滤器 Predicate
,删除当前集合中符合过滤器条件的数据。
过滤器 Predicate
的泛型是 <? super E>
,类似 <? super E>
被称为 超类通配符,利用超类通配符实现了范型的 逆变。假设这个方法没有使用逆变,而是:
1 | public boolean removeIf(Predicate<E> filter) { |
假设当我们需要删除 Double
类型的集合中大于 0 的元素时,可以这么写:
1 | ArrayList<Double> doubleList = new ArrayList<>(); |
又假设需要删除 Integer
类型的集合中大于 0 的元素,就要这么写:
1 | ArrayList<Integer> integerList = new ArrayList<>(); |
由于泛型是不变的,如果想要复用 doublePredicate
也是不行的,像下面这样就会编译报错:
1 | Predicate<Double> doublePredicate = new Predicate<Double>() { |
我们知道 Double
和 Integer
都是 Number
的子类,如果可以将 Predicate
的泛型设置为 Number
类型,那不就可以对 Double
和 Integer
类型的集合复用了。
想法是很好的,同样由于泛型是不变的,在没有使用逆变的情况下,直接传入 Number
类型的 Predicate
也还是会报错。正因如此,removeIf()
方法的参数使用泛型的逆变,被设计为 Predicate<? super E>
,而不是 Predicate<E>
。
在使用了逆变的情况下,就可以这样操作 Double
和 Integer
类型的集合:
1 | Predicate<Number> predicate = new Predicate<Number>() { |
逆变也是有代价的,使用了逆变可以 确定类型的下界,而无法确定上界(与协变相反),因此使用了逆变将丧失获取该类型的能力。道理很简单,编译器不知道给定下界和 Object
(所有类型都是 Object
类型的子类)类型之间到底有怎样的继承关系,为了防止由于“泛型是不变的”带来的编译报错,编译器干脆就不允许在使用逆变后再获取该类型。
假设有一 Student
类,其父类为 Person
。利用这两个类型来看看使用了逆变后的得与失:
1 | public void test(List<? super Student> list) { |
可以看到使用逆变后,就无法从集合中再获取原类型了。当然,可以获取 Object
类。
除此之外,使用逆变后可以消费泛型。以上述为例,就是可以向集合内添加元素,但并不是任何元素都是可行的,只能添加给定类型及其子类的元素。原因同样是只确定了下界而没有确定上界。
5.4 PECS 原则
那什么时候使用协变,什么时候使用逆变呢?
《Effective Java》给出了一个原则:producer-extends, consumer-super(PECS)。
当需要生产一个泛型时(从泛型类获取指定类型的数据,并且不需要写入),可以使用 extends
,即协变。比如从集合中获取指定下标的元素。
当需要消费一个泛型时(需要向泛型类写入指定类型的数据,但不需要获取这种类型),可以使用 super
,即逆变。比如在集合中按照指定条件删除元素。
但如果又要写入数据,又要获取数据,就不能使用通配符 ?
,而是指定具体的泛型。
在 java.util.Collections
中有一个 copy()
方法:
1 | public static <T> void copy(List<? super T> dest, List<? extends T> src) { |
copy()
方法是将源列表中的元素拷贝到目标列表中,并且目标列表中每个复制元素的索引将与其在源列表中的索引相同。因此,目标列表必须至少与源列表一样长,如果更长,则目标列表中的其余元素不受影响。
显然是需要从 src
中获取需要拷贝的元素,即生产泛型,然后将获取到的元素放到 dest
中指定的下标位置,即消费泛型。因此 src
使用 extends
,而 dest
使用 super
。
6. 自限定类型
6.1 普通泛型类构成自限定
在使用 Java 泛型时经常会看到下述这样的“迷惑”写法:
1 | public class SelfBound<T extends SelfBound<T>> { } |
SelfBound
类的类型参数是 T
,T
由一个边界类限定,这个边界恰好又是参数类型为 T
的 SelfBound
,似乎变成了无限循环。这种写法被称为自限定类型(Self-Bound Types)。
看一个例子,使用普通泛型类构成自限定 :
1 | public class BasicHolder<T> { |
1 | public class Subtype extends BasicHolder<Subtype>{ |
1 | public class SimpleTest { |
Subtype
类继承了 BasicHolder
类的属性与方法,element
属性的类型具有 Subtype
类型,而不仅仅是 BasicHolder
。
上述示例代码中,BasicHolder
类变成了其所有子类的公共模板,对于子类所继承的属性与方法将使用准确的类型而不是基类。
像 class Subtype extends BasicHolder<Subtype>
这样就构成了 自限定,Subtype
从 BasicHolder
所继承来的 element
属性、set()
方法的参数、get()
方法的返回值都是 Subtype
,而不是 BasicHolder
,这样 Subtype
对象只允许与 SubType
对象进行交互,不允许其与 BasicHolder
的其他子类对象进行交互。
也就是说,自限定类型定义了一个基类,这个基类能够使用子类作为其参数、返回值类型、属性类型。
6.2 自限定与协变
使用自限定类型可以产生 协变参数类型,即:方法参数类型跟随子类而变化。当然,也可以产生 协变返回类型。
协变返回类型
不使用泛型时,子类重写基类的方法,返回更确切的类型:
1 | class Base {} |
使用泛型后,子类继承具有自定义类型的基类,子类所继承的方法将返回更确切的类型:
1 | interface GenericsGetter<T extends GenericsGetter<T>> { |
协变参数类型
在不使用泛型时,基类方法参数不能随着子类的类型发生变化。 方法只能重载不能重写。
比如:
1 | class OrdinarySetter { |
DerivedSetter
子类使用 base
和 derived
作为参数调用 set()
方法后,并不是只调用重写的方法,也调用了其基类的方法,从这里也可以论证方法只能重载而不能重写。
但在使用自定义限定类型后,子类所继承的方法不再以基类类型为参数,而是接受其本身作为参数。比如:
1 | interface SelfBoundSetter<T extends SelfBoundSetter<T>> { |
6.3 自限定类型的应用
我们知道在 JDK5 中引入了枚举,使用 enum
关键词就可以创建枚举类。这其实是一个语法糖,使用 enum
关键词后就相当于继承了 java.lang.Enum
基类。
JDK 中 Enum
是这样定义的:
1 | public abstract class Enum<E extends Enum<E>> |
很明显,Enum
的定义使用了自限定类型。
那为什么要这么声明呢?像这样不行吗?
1 | public abstract class Enum implements Comparable<E>, Serializable { } |
因为 Enum
是一个抽象类,我们使用的都是它的子类,比如:
1 | class Month extends Enum { } |
1 | class Week extends Enum { } |
我们会在 Month
中定义从一月到十二月共十二个枚举,在 Week
中定义从星期一到星期天共七个枚举。
Enum
实现了 Comparable
接口,因此枚举都是可比较的。比如可以在 Month
中比较一月和二月哪个大,也可以在 Week
中比较星期一和星期天哪个大,但是能比较 Month
中的一月和 Week
中的星期一哪个大吗?
如果使用自定义的 Enum
抽象类,那么这是可以比较的,但这显然不是我们所期望的。我们期望是:同一个 Enum
子类的实例进行比较,而不是两个不同的 Enum
子类实例进行比较,因为这是没有意义的。
因此 JDK 中 Enum
的定义使用了自限定类型,保证其子类所继承的 compareTo()
方法的参数是其本身。
总结下 JDK Enum 的设计思路
1、首先枚举是可以比较的,因此 Enum
没有定义成:
1 | public abstract class Enum extends Object { } |
2、我们希望枚举在比较时只能和枚举进行比较,而不是和任何类型,因此 Enum
没有定义成:
1 | public abstract class Enum<E> implements Comparable<E>, Serializable { } |
3、我们还希望只有同一个枚举类之间才能比较,而不是随便两个枚举类之间就可以比较,因此在 Enum
的定义中使用自类型限定。最终,Enum
就长这样:
1 | public abstract class Enum<E extends Enum<E>> |
6.4 为什么没有编译报错?
现有如下类和方法:
1 | static class Obj { |
以下使用方式不会编译报错:
1 | Obj obj = new Obj(); |
那么这样的使用会编译报错吗?
1 | func(obj, Obj::hashCode, Obj::toString); |
答案是也不会。
hashCode()
方法的返回值类型是 int
,toString()
方法的返回值类型是 String
,func()
方法的第二个和第三个 Function
类型的参数的第二个泛型参数都是 U
,表示方法的出参类型应该一致,但一个是 int
,一个是 String
,显然不一样,为什么不会编译报错呢?
这是因为在泛型推断时,将 U
推断为 Integer
和 String
的相同父类 Comparable
,因此不会编译报错。
由于所有类的“祖宗类”都是 Object
,因此在不加任何限定的情况下,func()
方法的第二个和第三个参数可以任意填写,比如一个返回 HashMap
,一个返回 HashSet
都不会编译报错:
1 | func(obj, obj1 -> new HashMap<>(), o -> new HashSet<>()); |
再回到最开始,如何使 func(obj, Obj::hashCode, Obj::toString);
的写法产生编译报错呢?
也就是说,int
类型只能和 int
类型比较,String
类型只能和 String
类型比较,根据本节的知识,很容易想到让泛型参数 U
构成自限定:
1 | private <T, U extends Comparable<U>, R> void func(T obj, |
7. 捕获转换
7.1 无界通配符
<? extends T>
被称为 子类通配符,它确定了类型的上界;<? super E>
被称为 超类通配符,它确定了类型的下界;而 <?>
被称为 无界通配符,它并没有确定类型的上界和下界。
可以在下面这两种场景下使用无界通配符:
- 正在编写一个可以使用
Object
类中提供的功能来实现的方法(所有类的父类都是Object
); - 代码在泛型中使用不依赖类型参数的方法。比如经常使用
Class<?>
就是因为类Class<T>
中的大多数方法不依赖并不于T
。
注意 List<?>
和 List<Object>
是不一样的。可以将 Object
类型或及其子类对象插入到 List<Object>
,但是对于 List<?>
就只能插入 null
。在调用 get()
获取元素时,它们调用 get()
方法的返回值都是 Object
。
List<?>
表示的是“具有某种特定类型的 List
,但这个类型是未知的”,因此可以将任何类型的 List
赋值给 List<?>
,对于 List<Object>
则不是这样。
7.2 捕获转换
通常情况下,使用原生类型和 <?>
没什么区别,但是使用 <?>
可以实现捕获转换。
捕获转换(Capture conversion)允许编译器为已捕获的通配符产生一个占位符类型名,以便对它进行类型推断。
直接上代码:
1 | public class Holder<T> { |
1 | public class CaptureConversion { |
示例代码中调用 f1()
时 IDEA 会进行警告,而调用 f2()
时却没有,这是因为 f2()
的 holder 参数可以捕获到原生类型中的参数类型,但自己却不知道,捕获到的参数类型可以转换成 f1()
中的确切类型。
捕获转换只适用在方法内部,并且需要传入确切的类型。需要注意的是,不能从 f2()
中返回 T,因为 T 对于 f2()
来说是未知的。