在很久之前,我自己写了一篇关于Java泛型基础的文章,里面介绍了最基本的Java泛型知识,我们只要熟悉一下里面的内容,就可以把泛型应用起来。然而,泛型在Java中算是一个比较大的课题,所以,光是掌握之前所习得的基础知识是不足以让自己灵活运用泛型特性的。Java泛型有很多坑与细节需要我们深入地了解。
原生类型(Raw Type)
当你在使用泛型并且不给它指定类型实参的时候,那这种被构建出来的类型被称作原生类型(raw type)或原始类型,有的地方还把它称呼为原生态类型。原生类型是非参数化类型,原生类型表示它持有任何Object
类型。
这十分简单,我们在使用泛型的时候也肯定接触过原生类型。
1 | public class Client { |
这里创建一个ArrayList
对象,在使用泛型ArrayList<E>
的时候我不给它指定类型实参,这就表示我正在使用原生类型。
编译器允许我们使用原生类型,但这便缺失了泛型特性所提供的编译时类型检查。代码不是一定会出错,但是存在转换类型失败的风险,编译器深知这一点,所以编译器不会对这种代码报错,随之而来的是各种编译器发出的警告。有警告我们就要小心了,毕竟这表示有潜在的错误随时爆发出来。当然,你非常自信并肯定这些代码100%不会在运行时报错,你可以无视这些警告;甚至有强迫症,看见警告就不爽的可以用注解SuppressWarnings
消除警告。
但是,只要代码还是具有潜在出错风险,总有一天它都会出错的,正如上面所演示的代码一样。假设,我们认为rawList
里面只存有Integer
元素,但由于某些未知的原因,rawList
中混入了其他类型的元素,这样上面这行代码就会在运行时引发类型转换异常。所以如果你想rawList
只持有Integer
类型的元素,你只能自己手工检测每一个通过add
方法添加进去的元素是否是Integer
类型,并且,每次从rawList
中获取元素都要自己将它手动转型为Integer
,但即使如此,当别人使用你提供的rawList
或许就不会遵守这个约定了,因为人都是容易健忘,一个不小心就跳坑里了。
所以,我们在使用原生类型并使其正确无误地工作每一个细节都必须小心翼翼地处理,但Java引入泛型后就将这些繁琐而重复的工作交给了编译器去做了。
在Java SE5之前所编写的非泛型库里面所有的容器类型都是非泛型类型(即那些非泛型的容器类型正是被现在的我们称呼为“原生类型”),因为当时泛型特性还没被添加到Java当中。但当Java迎来SE5时代,原本存在于Java中的非泛型库被设计人员重新用新加入的泛型特性重写了。但为了兼容以前使用非泛型库的遗留代码,所以才将原来非泛型库的特性保留下来,并为了与泛型特性区分开来,这就给了它们原生类型这一个称呼。这种在新增特性后将旧特性保留下来并且让它能够与新特性一起使用的需求叫做移植兼容性(migration compatibility)。现在大家基本上都用泛型了,除了维护老版本的遗留代码以及特殊需求外,我们是很少会要使用到原生类型了。
但最后我只能说:
除非使用原生类型是最好的解决方案,我们都应该坚持使用参数化的泛型,确保代码的安全性。
如果使用原生类型,就失掉了泛型在安全性和表述性方面的所有优势,同时,为了营造出这种优势,我们还必须亲自做很多额外的工作。
原生类型与Object
参数化类型的区别
原生类型表示可以持有任意类型的对象,在这一点上Object
参数化类型也是如此,这是它们两的相同点。然而它们两之间有2个区别:
- 前者是逃避了类型检查,而后者则明确告知编译器它持有的是任意类型。
前者是所有参数化类型的父类型,而后者并不能作为所有参数化类型的父类型。
比如:原生类型List
是List<Integer>
、List<Long>
等类型的父类型,而List<Object>
并不能作为这些类型的父类型。
类型擦除(Type Erasure)
在接触Java泛型不久之后,随着我对Java泛型的逐渐深入钻研,同时以前我也学习过C++,所以,我在使用Java泛型有时候会不自觉地写出这样的代码。
1 | public class GenericHolder<T> { |
像上面这种创建泛型数组的语句在C++中看起来是那么的普通自然,但在Java中编译器是会报错的。如果你不了解类型擦除,不管你以前是否学习过C++的泛型,在使用Java泛型有时会觉得不爽甚至碰壁。
初次接触类型擦除
我们知道,每种类型都对应拥有只属于它自己的Class
对象。但在泛型中,这条规则就不再适用了。
在彻底了解泛型类型擦除之前,我们很自然认为ArrayList<String>
和ArrayList<Integer>
是不同的类型,应该分别拥有只属于自己的Class
对象。
1 | public class ErasedTypeEquivalence { |
输出结果:
true
java.util.ArrayList
java.util.ArrayList
程序运行所得出的结果为true
,所以之前的预想是错误的。
我们可以发现ArrayList<String>
和ArrayList<Integer>
的类型都是java.util.ArrayList
原生类型。
从程序的运行结果就可以推导出2条结论:
使用多种类型实参参数化同一泛型创建出对应的参数化类型时,这些参数化类型实际上都是同一种类型,即是它们共享同一份字节码。
比如:List<String>
和List<Integer>
两者的对象共享同一份字节码List.class
。同一泛型中的静态数据成员被所有该泛型类型的参数化类型所共享。
深入了解类型擦除原理
泛型的类型擦除底层实现在这里就不进行详述,这些高深的底层细节超出了本文的讨论范围。但我们心中必须要清楚的一点就是:
Java泛型是伪泛型,类型实参的类型信息在编译的过程中会被擦除掉,而这个过程就是类型擦除,在编译后的字节码文件中,所有泛型类型都已经替换为对应的原生类型,并在相应的地方插入了强制转换。所以在运行时,所有泛型的类型信息对于JVM是不可见的。更通俗直白地讲,类型擦除就是将泛型代码转换为非泛型代码的过程。
类型擦除这个过程并不神秘,正如之前所说的,编译器只是把代码从泛型转换为非泛型。我们可以自己手工来模拟类型擦除这个过程。
下面是一个普通的泛型类。
1 | public class Holder<T> { |
而经过类型擦除后,在运行时,代码就会变成这样。
1 | public class Holder { |
此处对于Holder<T>
泛型类来说,类型擦除做的正是这样的工作:
- 把形式类型形参
T
、E
等等占位标识符替换成Object - 在需要强制转型的地方加入强转代码。
查看Holder<T>
类的字节码文件可以验证一下。在命令行中运行该命令javap -c -v -p Holder>bytecode.txt
。
1 | public class com.makwan.a_test.Holder<T extends java.lang.Object> extends java.lang.Object |
我这里用的是JDK1.8
,或许你使用其他版本的JDK所得出的字节码不同。
首先看到常量池中的一条指令。1
#73 = Utf8 <T:Ljava/lang/Object;>Ljava/lang/Object;
字节码中的T
只是个占位符,但它实际代表的类型是java.lang.Object
类型。
再看到get
方法和set
方法其中几条指令,里面也有一些指明object
属性类型为java.lang.Object
的证据。1
2
3
4
5
6
7
8
9
10
11public void set(T);
descriptor: (Ljava/lang/Object;)V
Code:
stack=2, locals=2, args_size=2
2: putfield #23 // Field object:Ljava/lang/Object;
public T get();
descriptor: ()Ljava/lang/Object;
Code:
stack=1, locals=1, args_size=1
1: getfield #23 // Field object:Ljava/lang/Object;
接着,再把目光转向main
方法中的几条指令。
1 | public static void main(java.lang.String[]); |
第4行表示调用Holder
泛型类的set
方法,所接收的参数为java.lang.Object
类型;
第5行表示调用Holder
泛型类的get
方法,返回值的类型为java.lang.Object
;
第6行是检查get
方法所返回的java.lang.Object
对象是否能够正确是转型为java.lang.String
类型。
正因为编译器自动给此处插入强转类型的代码,所以才会有这条检查类型转换是否正确的指令。
从上面的字节码结果看来,我们手工进行类型擦除与编译器进行类型擦除几乎是一模一样的。
或许你会注意到字节码中这个细节Holder<T extends java.lang.Object>
。
确实,这和泛型在类型擦除后,类型占位符为何会被替换为java.lang.Object
有关。但这涉及到Java泛型边界。
类型擦除原理通用解释:
泛型的类型形参将擦除到它被限制的第一个边界所代表的类型。
正如前面的泛型类Holder<T>
,默认情况下类型形参T
的第一个边界被限制为java.lang.Object
,所以T
会被擦除为java.lang.Object
。
类型擦除总结
这里所总结的内容正是官方文档《The Java® Language Specification Java SE 8 Edition》提供的,所以不必质疑它们正确与否。
类型擦除是一个从泛型到非泛型的映射过程。我们将类型T
的擦除写作|T|
。并且类型擦除映射定义如下:
- 参数化类型
G<T1, ..., TN>
的擦除是|G|
- 嵌套类型
T.C
的擦除是|T|.C
- 数组类型
T[]
的擦除是|T|[]
- 类型变量的擦除是其最左边的边界
- 至于任何其它类型的擦除都是该类型的本身
类型擦除同样会将泛型方法映射成非泛型方法。
类型擦除所引发的编程问题
前面的类型擦除原理让我们深刻地了解到:在运行时,泛型的类型形参所代表的是它被限定的第一个边界所表示的类型。
这样带来的问题就是:任何在运行时需要知道类型形参确切类型信息的操作都无法正确地工作。
1 | public class Erased<T, EXC extends Throwable> { |
类型形参T
就不用过多解释,而EXC
则是把泛型边界限定为Throwable
。这段小程序揭示了2个问题:
- 类型形参在静态相关的代码中不起作用,比如
static
修饰的方法和块。 - 在
instanceof
语句、new
语句以及捕获异常的catch
块中使用类型形参就会报错;而将类型形参用于类型强制转换会有警告。
类型擦除的补偿
Java泛型的类型擦除,给我们带来的问题可是相当的多,在使用Java泛型特性时我是十分不爽的,用起来十分别扭,这不能做那不能做。幸好,还有办法进行补救的,虽然这会绕很大的一个圈子,但总体来说是把之前所说的类型擦除所带来的问题给解决掉的。
创建泛型实例
1 | T var = new T(); |
解决这问题并不容易,而在《Thinking In Java》中描述了多个解决方案。但所有解决方案的总体思路是:传递一个工厂对象,并使用它来创建新的实例。
解决方案1:利用Class<T>
对象作为工厂对象用于创建实例,但使用该方案的限制点是类型T必须有默认构造方法。
1 | class ClassAsFactory { |
使用Class<T>
类型标识作为工厂确实可行,但还有个缺点就是当类型T没有默认构造方法时所引发的错误不能在编译期捕获。
解决方案2:传递实现指定工厂接口的显式工厂类对象用作创建实例。
1 | interface Factory<T> { |
正如之前传递Class<T>
对象作为工厂对象,碰巧这个工厂对象是Java语言内置的不需要我们手工创建。而这里就是手工创建工厂类以及它的工厂对象,虽然所做的工作会更多,但这使得我们获得了编译期检查。
解决方案3:运用模板方法设计模式。
1 | abstract class GenericWithCreate<T> { |
创建泛型数组
一般我们想在使用泛型数组时,最好是使用Java内置的泛型容器ArrayList
代替,这个解决方案最实际也最简单。
但如果真的还要执意使用泛型数组,那就要好好研究一番才行。
正如前面的代码所演示,我们不能使用这种代码来创建泛型数组:1
T[] array = new T[SIZE];
但是使用强制转换类型代码就不会有错,编译器允许我们这样写:1
T[] arr = (T[]) new Object[SIZE];
这样的话,我们可以来尝试一下这样创建并使用泛型数组到底行不行。
1 | public class GenericArray<T> { |
输出结果:
gArr[0] = 0
gArr[1] = 1
gArr[2] = 2
gArr[3] = 3
gArr[4] = 4
gArr[5] = 5
gArr[6] = 6
gArr[7] = 7
gArr[8] = 8
gArr[9] = 9
[Ljava.lang.Object;@2a139a55
从程序代码以及输出结果看来:
把元素放进去以及取出来是完全没有问题的,然而调用rep
方法返回底层的泛型数组时,却出现了类型转换异常,所以说这种方法是行不通的。
出现这个错误的原因还是因为泛型类型擦除。
在运行时,array
的类型已经是Object[]
,到创建数组array = (T[]) new Object[size];
这一句实际上是将Object[]
转换为Object[]
,
而在调用rep
方法时编译器会在调用的地方会插入(Integer[])
这样的强制类型转换代码,
所以最后运行的时刻报错是应该的,因为我们一直在把原本为Object[]
类型的数组强制转换为Integer[]
类型。
或许你会想到用类型标识Class<T>
对象来挽救这一个尴尬的局面,这个确实可以,但是要额外使用JDK提供的API才行。
1 | import java.lang.reflect.Array; |
使用java.lang.reflect.Array
类的工具方法newInstance
就能创建指定Class<T>
对应类型的数组,最后再强制类型转换,将返回值强制转换为T[]
类型即可。这是在Java中使用泛型数组的唯一办法。
解决instanceof判断泛型对象的问题
类型擦除使得我们不能够把instanceof
应用于泛型对象,不过还好的就是这个问题很好解决,解决方案还是使用Class<T>
对象作为类型标识,然后就是调用Class<T>
对象的isInstance
方法进行判断,这个方法的带来的作用就跟instanceof
的一样。
1 | class Building { } |
至于异常与泛型之间出现的问题如何解决,后面会有说明。
边界(Boundary)
如你所见,之前也使用过边界这个特性,看到在类型形参处出现extends
这个关键字,那里就是正在使用边界特性了。
边界基础
边界的语法
限制边界的通用语法是:typeParameter extends typeNameA & typeNameB & typeNameC...
或typeParameterA extends typeParameterB
。
可以给类型形参限制一个边界也可以限制多个边界,在限制多个边界的时候我们要注意的是:
如果所要限制的边界类型同时有类类型以及接口类型时,第一个边界类型必须是类类型,而从第二个边界类型开始以及之后的边界类型不能指定为类类型,但可以指定为接口类型;这规则就像是Java的继承规则一样,不能多重继承,但能够实现多个接口,这很好理解。
边界的作用
边界可以限制泛型可以应用的类型,准确地说就是限制类型形参为某个类型子集,这样就可以使用这些类型子集所提供的方法。
比如:将泛型类型形参T
的边界限制为java.io.Serializable
类型,那T
所代表的类型就是java.io.Serializable
以及它的子类型。
代码示例
在演示边界之前,我们要首先做一些准备工作:
这里提供了1个普通类A
,2个接口B
和C
,1个实现了B
、C
两接口的普通类D
以及1个继承类A
且实现B
、C
两接口的普通类E
。
1 | class A { |
下面是限制了类型边界的泛型类,在限制多个边界时什么可以做什么不可以做正如之前所说的那样展示了出来。
1 | class SingleBoundary<T extends A> { |
最后就来使用这些带边界限制的泛型。
1 | public class Client { |
从程序代码来看,在我们对这些带边界限制的泛型进行参数化时:
- 对于单一边界,指定的类型实参只能是类型形参所被限制的类型以及它的子类型。
- 对于多边界,指定的类型实参只能是类型形参所被限制的所有类型的子集才行。
比如上面的代码,MultipleBoundary
泛型类所限制的边界是T extends A & B & C
,那么类型实参必须同时为类型A
、B
、C
的子类型才行。
边界细节
默认边界与类型擦除
之前在写类型擦除相关的内容时,就已经提到过边界了。
所以在这里,也要明确地重新说明一遍:在不对泛型的类型形参限制边界时,即默认情况下,类型形参的边界会被限制为java.lang.Object
。
比如,我们在声明泛型时光是声明了类型形参T
,实际上编译器默认给它限制了边界T extends java.lang.Object
。
多边界与类型擦除
同样地,如之前所说的:泛型的类型形参将擦除到它被限制的第一个边界所代表的类型。
问题:当类型形参被限制了多个边界的时候,在类型擦除后类型形参会被哪个边界所代表的类型代替呢?还会是用第一个边界的代替?
关于这个疑问,字节码能够给我们解答,这里查看代码演示时限定了多个边界的MultipleBoundaryB
泛型类的字节码。
1 | class com.gtr._test.MultipleBoundaryB<T extends com.gtr._test.B & com.gtr._test.C> extends java.lang.Object |
我们可以很清楚地看到bounded
的实际类型是自定义接口B
,这样从实践结果看来,类型形参确实擦除到它被限制的第一个边界所代表的类型
所以,调用bounded
的methodB
方法是最直接不过了,那通过bounded
调用methodC
方法为啥可行呢?毕竟经过类型擦除后,类型形参被替换成了自定义接口B
,bounded
的类型自然而然是B
。现在将目光转向methodC
的字节码,我们可以到非常关键的指令:
1 | 1: getfield #21 // Field bounded:Lcom/gtr/_test/B; |
getfield
指令很简单,它取出bounded
数据成员。checkcast
指令检测bounded
是否能够安全地强转为自定义接口C
类型。
所以这里实际上是编译器自动将类型为B
的bounded
转换成C
类型,在类型转换后便可以安然调用方法methodC
了。
所以我们可以对多边界与类型擦除进行总结了:
- 类型形参将擦除到它被限制的第一个边界所代表的类型,即编译后只用第一个边界所代表的类型替换掉类型形参。
- 当代码涉及到除第一个边界之外其他边界所代表的类型时,编译器会为其对应的边界所代表的类型进行强制类型转换,并检查转型是否成功。
边界与继承
这里使用《Thinking In Java》中的一个代码示例演示泛型边界与类继承结合使用。
首先是看到有关一些超能力的接口。各种超能力的描述正好是接口的名字,一看就知道了。
1 | interface SuperPower { } |
而下面的则是限制了边界的泛型类,这样给每一种超能力英雄都限定了特定的超能力。
1 | class SuperHero<POWER extends SuperPower> { |
这里是将某种超能力以及拥有某种超能力的英雄具体化。
1 | class SuperHearSmell implements SuperHearing, SuperSmell { |
最后,当然是使用这些超能力英雄类进行演示了,我们这里又发现了一个比较奇怪的东西——在声明List
泛型对象时所指定的类型实参出现?
这一个符号,似乎同时对它限定了边界,这是泛型中的通配符,之后就会重点介绍这个特性。
1 | public class EpicBattle { |
通配符(Wildcard)
通配符是Java泛型中的一个特性,它的出现能够解决很多问题。但这里并不会开门见山就讲通配符的任何特性。我们首先要搞清楚的是为何要用通配符这种特性,带着这种疑问去探讨通配符的特性会让我们的思路更加清晰。
为何需要通配符?
在日常的开发中或许我们会有这么一个技术需求:编写一个方法,此方法可以接受任意参数化的List
并遍历显示它所存储的元素。需求十分简单。
需求分析
再简单的需求我们都要进行分析,并明确思路,这样写代码时我们的思路才会清晰明了。上面的需求描述中我已经把其中我们必须关注的点给加粗了,更直白地说,用户可以给这个方法传入任意参数化的List
实参(List<Integer>
、List<Long>
、List<Double>
等等)。
从类型关系的角度上分析,可以推断出:这个方法形参的类型必须是所有List
参数化类型的父类型,但同时这种类型必须具有类型安全性的。正如大家所熟知的一个概念:在Java中,所有类型的父类型都是Object
类型,方法的形参类型是Object
,那这个方法就能接受任何类型的参数传递进来。这个推论正是实现该需求的关键所在。所以,最终的主要目标就是我们要想办法给这个方法的形参弄出一个类型,这个类型必须是所有List
参数化类型的父类型。
使用
Object
参数化类型
根据上面的推导,我们可能会这么认为:既然Object
是所有类型的父类型,那么List<Object>
肯定是所有List
参数化类型的父类型。但事实却不是这样的,即2个类型实参之间具有子类型关系,但使用它们对同一泛型进行参数化后的类型并没有保持这种子类型关系。这个和<原生类型与Object参数化类型的区别>这一小节中所描述的一样。
List<Object>
表示的只是它能够存储任意元素类型,而并没有表示它是所有参数化类型的父类型。所以这种解决方案是完全行不通的。
使用原生类型
直接使用原生类型是最简单的手段。在接触通配符特性之前,这个解决方案估计每个人一开始都能想到的。因为不管我们声明List
为哪种参数化类型,它最终都会被擦除为原生类型List
。泛型有子类型化(subtyping)的规则。原生类型List
正是所有List
参数化类型的父类型。所以,我们只要利用这一个特性,将方法的参数类型声明为原生类型List
,不管用户提供什么List
参数化类型的对象,这个方法都能够接纳。
1 | import java.util.ArrayList; |
但这样做,我们就不是在使用泛型特性。这样的代码是在Java1.5之前即泛型特性出现之前所经常使用的,它们都没有编译时类型检查,类型错误在运行时我们才能够得知。现在还使用原生类型,编译器会给出警告,提醒你给这个泛型进行参数化。而到后来Java1.5泛型的出现,泛型特性正是为了将容器元素的类型检查从运行时提前到编译时。所以说,使用原生类型并不是一个完美的解决方案,虽然它能够满足需求,但存在一定的风险需要我们日后承担。
使用泛型方法
在告诉你使用原生类型并不是一种好的解决方案的时候,你可能立刻会想到这种解决方案,代码变成了这样。
1 | public class Client { |
代码能够正常运行,并且编译器也不会发出警告了,这个解决方案似乎是完美了。这里使用了泛型特性使得编译时有了类型检查,同时也兼顾了需求的实现。其实这是间接利用了原生类型的,对,还是类型擦除,运行时形参list
的类型正是原生类型List
,所以说这种解决方案是上一种的改进。但这并不是最佳的解决方案,利用泛型方法特性当然可以,但在真正的项目开发中,我们就有可能会遇到不可抗力因素导致我们无法使用泛型方法,并且每次使用泛型方法时为了代码更加清楚,都要指定不同的类型形参占位符,这显得很麻烦,所以这种解决方案有弊端并且缺失优雅性。
使用通配符
首先我们还是来看代码。
1 | public class Client { |
代码变化不大,只是方法参数的类型变成了List<?>
,其他一切不变,编译器没有发出警告并且代码还是能够正常运行。这说明了List<?>
这种类型有编译时的类型检查并且它是所有List
参数化类型的父类型。所以,使用通配符来实现这个需求是最完美的。
最终结论
上面的例子只是为了更具体地演示通配符的起到作用而已,并没有十分具体地说明通配符为何要出现,当然,你已经精通了其它的语言,或许从上面这么简单的例子推演你就知道在更广度的范畴上通配符是用于解决什么问题的。
我们都认识数组这种类型,在Java中,数组具有协变性(关于协变等内容可以看这里),简单点来说就是子类型数组可以赋值给父类型数组进行使用,比如将Integer
数组赋值给Object
数组,这样来使用数组不会有警告甚至报错,但会存在运行时出错的风险,所以数组的协变性是具有瑕疵的,虽然它能这么用,但我们必须为此而承受一定风险。
1 | Object[] objects = new Integer[5]; |
但是Java泛型就不可以做出像数组这样的行为,因为泛型没有内建的协变类型,Java泛型具有不可变性(不具备协变性和逆变性)。但是,为了兼容遗留代码而被保留下来的原生类型确实可以做出这样的行为,但同时我们也非常清楚使用它就像使用数组的协变性一样意味着代码变得不再安全。
所以,制定Java标准的那群人想出了个法子使得泛型像数组一样具有这些特性,同时这种代替原生类型的方案必须是绝对安全的:他们通过给泛型增加通配符特性使得泛型在参数化后具有协变性或逆变性。
1 | Lis<?> list = new ArrayList<Integer>(); |
泛型通配符的出现就是为了给泛型增加协变性和逆变性,即增加泛型的灵活性,同时保障代码安全性。
基本概念
接下来要讲的这些内容虽然是基础,但却是十分重要的,因为理解不当可能导致我们的思路会越走越偏。
首先得明白这些
- 泛型中携带
?
的类型实参被称为通配符或通配符类型,?
代表一个未知类型,而我们可以把这种参数化后的类型称作通配符参数化类型。 - 想要使用通配符,那在参数化泛型时所指定的类型实参携带
?
问号即可。
无界通配符(unbounded wildcards)
单独使用?
作为类型实参就是没有限制任何边界的通配符,简称无界通配符,正如前面所讲,它代表一个未知类型。
对于同一种泛型,无界通配符参数化类型是所有具体参数化类型的父类型,这就像原生类型所起到的作用一样,但不同的是这种方式是安全的。
1 | ArrayList<?> anyList = new ArrayList<Integer>(); |
使用无界通配符就像上面所看到这么简单快捷。所以,当你使用泛型并且不确定或者不关心它实际持有的类型,就可以用一个问号?
作为类型实参。
有界通配符(bounded wildcards)
有界通配符说明白点就是限制通配符?
的边界,这就像之前所学的给类型形参限制边界一样。通配符结合边界一起使用比类型形参结合边界一起使用有更多的细节需要我们把控,它们之间是有相同之处,但还是有挺大区别的。
语法
相比无界通配符,语法并不复杂,限定?
的边界要用到extends
和super
关键字。
- 上界通配符(upper bounded wildcard)通用语法:
? extends typeName
或? extends typeParameter
。 - 下界通配符(lower bounded wildcard)通用语法:
? super typeName
或? super typeParameter
。
与限制类型形参边界相比之下不同的就是额外多了一个新的super
关键字,并且不能给通配符限制多个边界。
作用
这里就举一些具体的例子并且结合数学中区间这个知识来理解它们。如果我们没有联合区间,理解起来或许就没有那么透彻,至少我是如此。
- 上界通配符:
? extends T
,子类型通配符
文字描述:T
限定了通配符?
即未知类型的上界(upper bound),这代表一个可能是T
或其子类型的未知类型。
区间描述:[T, T的子类型)
。
其实我们之前在单独使用通配符特性时,其实在这种默认情况下就有限制边界了。就像之前详细介绍边界相关内容时所提到的类型形参默认情况下的边界会被限定为Object
,即T extends Object
。通配符?
在默认情况下,它的边界也是被限制为Object
的,即? extends Object
,所以无界通配符真正代表的是一个可能是Object
或其子类型的未知类型。
- 下界通配符:
? super T
,超类型通配符
文字描述:T
限定了通配符?
即未知类型的下界(lower bound),这代表一个可能是T
或其父类型直到Object
的未知类型。
区间描述:[Object...T的父类型, T]
。
限定通配符边界的作用之一是:
限定通配符上界就是给泛型增加协变性(covariance),限定通配符下界就是给泛型增加逆变性(contravaiance)。
深层细节
前面的基础概念其实还有一些东西我是没有讲的,因为这是要等无界、有界两种形式的通配符都介绍完了接下来这部分内容才能统一起来讲。下面这些问题是学习通配符时必须解决掉的。
通配符参数化类型的真正含义
各类通配符表达什么意思我们都清清楚楚了,但使用它们进行参数化后的参数化类型到底表达什么意思呢?这是必须先要搞清楚的。
误区
刚开始学习通配符,并且思维并不是那么清晰的人很可能会钻以下这个牛角尖:
由于种种不明原因,学着学着就开始曲解通配符所要表达的意思了。我们都知道?
或? extends Object
代表一个未知类型,表面看上去它代表什么类型都可以,然后你可能会把它当作任意类型了。所以你会把<?>
读作可持有任意类型的参数化类型。同理,把<? extends T>
读作可持有任意T
或其子类型的参数化类型,把<? super T>
读作可持有任意T
或其父类型的参数化类型。
我们在用理解普通参数化类型的思维对通配符参数化类型进行解析:参数化泛型时给它指定什么类型实参,该参数化类型就持有什么类型;所以当指定的类型实参是通配符类型时,我们就习惯性地认为通配符参数化类型也持有通配符所代表的那些类型。
综上所述,你会把使用通配符进行参数化的类型读作一个可持有包含于指定类型区间之内的任意类型的参数化类型。
当我们理所当然地认为上面这种想法是正确的,我们就会尝试给它添加元素。而当我们去使用通配符特性时,就会立刻发现我们之前那些想法都是错的。比如:我们使用List<?>
,会发现除null
值外,任何类型的元素都不能通过它进行添加。
1 | List<?> list = new ArrayList<Integer>(); |
上面这代码正表示我们之前那个想法是严重错的。
解惑
我们之前的想法错误是因为我们惯性把理解普通参数化类型的思维代入到此处,同时忽略了描述通配符的关键词“一个”,并且过分着眼于List<?>
中的?
所代表的类型范围,这思路导致我们认为:既然普通参数化类型可以持有具体某种类型,而?
代表任意类型,那么List<?>
就可以持有任意类型。以上是造就错误想法的详细描述。所以说,通配符参数化类型是非常特殊的,所以我们不能继续用理解普通参数化类型的思维对它进行思考,同时我们的视线要关注每个词每个字,心急看漏了字理解错了就全盘皆输,不过我看到有些博文或书用任意一词来描述通配符是非常容易让初学者误入歧途的,所以就算是网上查阅资料自己也要多加思考多留个心眼。任意一词描述?
并没有错,但请加上“一个”,一个任意类型,这描述也是正确的。
让我们现在重回正轨。
?
并不是代表任意类型,它代表一个未知类型 或 一个任意类型,这是我一再重申的,说得更通俗直白,它不是代表任意类型或一大群类型,它只代表一个类型,这个类型是什么类型都可以,但我们不知道它具体是什么类型。 所以对于List<?>
所指的并不是该List
可持有任意类型,而指代的是该List
可持有一个未知类型。
综上所述,我们应该把使用通配符进行参数化的类型读作一个可持有某种包含于指定类型区间之内的未知类型的参数化类型。
更进一步
如果通配符参数化类型只是为了表达它自己持有某种未知类型,那么它对于我们来说也真的没什么用处。我之前写的一篇关于泛型基础的文章里有说过:类型实参分为引用类型和通配符。再结合通配符最开始的那一小节来看,那么现在看来它们对应的参数化类型各自应该有不同的具体作用才是。之前所反复强调:通配符就是为了给泛型增加协变性和逆变性,这只是通配符参数化类型真正作用的抽象描述,理解抽象的概念固然重要,不过我们要把通配符具体作用给理解了才能真正开始应用它。
两种类型实参分别发挥的作用:
- 引用类型作类型实参作用于内在,即描述该参数化类型可持有指定类型。
- 通配符作类型实参作用于外在,即对于同一泛型,描述该参数化类型可引用某种包含于指定类型区间之内的未知类型作为类型实参的参数化类型。
通俗来说就是前者描述一个参数化类型持有什么什么样的具体类型,后者就是描述两个参数化类型之间的存在着什么关系。
类型之间的关系
在我们接触完原生类型与五花八门的参数化类型之后,现在还有疑惑:哪些参数化类型可以或不可以赋予哪些参数化类型。其实要解决这个问题并不难,但首先要弄明白的是模糊类型与精确类型这2个概念。其实这2个概念都是我自己总结的,真正错对与否我现在还不知道,或许以后通过更加深入的学习后才能够了解到真相。只有把这2个概念了然于心,那么接下来我们所要面对的问题都是迎刃而解。
模糊类型:此处所谓的模糊类型指的是泛型的持有类型不是具体可知的,只知道它包含于某类型区间中。
所以说,原生类型、通配符参数化类型都是属于模糊类型。精确类型:与模糊相反,精确指的就是泛型的持有类型是具体可知的。
所以说,凡是用引用类型作类型实参的参数化类型都是属于精确类型。
分门别类后,要解决那个问题就简单了,只需要遵循以下2条规则即可:
- 精确类型可赋予模糊类型,反之则不然。
- 精确之间的赋予,两者必须一一对应;而模糊之间的赋予,两者必须有包含关系并且只遵循小模糊赋予大模糊(大小指的是类型区间的大小)。
通配符参数化类型的各种“限制”
如果你把前面的内容都弄懂了,那么这里要理解起来就更加简单了。
刚开始,当我们兴致勃勃地开始使用上下界通配符时,往往就开始碰壁了。当我们想通过上界通配符类型引用添加对象时(null除外),发现编译器是不允许的,当我们想从下界通配符类型引用获取对象时,发现从中获取的对象时,编译器能够得知其准确类型只能是Object
。这些就是使用它们时我们所会碰到的“限制”,但细想这些所谓的“限制”,真的可以把它们当作阻挡去路的绊脚石吗?
1 | static <T> T upperBoundedArg(List<? extends T> list, T item) { |
在回答之前,你可以先到这里看下有关数组协变性的内容,然后再看这里有关泛型协变性的内容,因为泛型协变性就是从数组中推演出来的。
从协变数组中我们可以发现1个问题:在利用数组的协变性时只能尽量把协变数组当作只读数组使用。
- 利用数组的协变性,对其写入元素就可能会引发错误
- 同理,使用具协变性的原生类型也有这个问题,我们在利用原生类型的协变性时也必须遵守尽量用作只读这个约定
- 而上界通配符是原生类型的最佳代替解决方案,所以它肯定把这个问题给解决掉了
- 那么制定Java标准的那群人是如何解决这个问题的呢?
这群制定标准的人干脆不让用户通过具有协变性的上界通配符参数化类型引用写入任何元素,这样用户在使用泛型协变性就不会遇到任何潜伏的麻烦了。
综上所述:有界通配符的“限制”其实是为了消除潜藏风险确保代码100%安全,让用户更专注代码逻辑实现,而不是浪费时间在这种隐藏的细枝末节上。
有界通配符的“限制”其实很容易分析出来,只要观察类型区间并结合之前所学的知识再从读与写两个方面切入就能解开这个问题了。
上界通配符参数化类型<? extends T>
:
- 有类型区间
[T, T的子类型)
,表示一个可能是T
或其子类型的未知类型 - 按照类型层次关系的角度来说,它所引用的参数化类型绝对是持有
T
(T
的子类型也可以看作是T
),所以通过它读取的对象肯定为T
类型 - 但并不能确定它所引用的参数化类型具体持有什么样的
T
类型,所以,通过它写入任何T
类型的对象都是不允许的
下界通配符参数化类型<? super T>
:
- 有类型区间
[Object...T的父类型, T]
,表示一个可能是T
或其父类型直到Object
的未知类型 - 我们都能确定它所引用的参数化类型所持有的类型只能是
T
、T
的父类型、
Object` - 但前2个都可以看作是
Object
,所以通过它来读取的对象肯定为Object
类型 - 并且,通过它来写入任何
T
类型的对象都是允许的
更加具体的说:上界通配符只能用作读,下界通配符一般只用作写
泛型的协变性与逆变性
正如之前所总结的那样,通配符就是为了给泛型增加协变性与逆变性,《Thinking In Java》中也有详尽例子了,所以我在这里还是继续沿用书中的例子。
首先,我们准备一些之后一直要用到的类型。
1 | class Fruit implements Comparable<Fruit> { |
协变性
正如前面所说的,限制通配符的上界就给泛型增加了协变性。
1 | public class GenericAndCovariance { |
上面的代码正如之前所说的“限制”一样:
限制通配符上界后,不管我们对其写入Fruit
类型或其子类型的对象,都会得到编译时错误,
编译器不允许我们通过具有协变性的参数化类型引用进行写入操作,但任何读取操作对于它来说是不成任何问题的。
逆变性
数组没有逆变性,但泛型就有。逆变性是协变性的对立面,所以它们之间的特性都是对立的。
正如协变性泛型是只读的,那么逆变性泛型就是只写的。
1 | public class GenericAndContravaiance { |
这里也正如之前所说的“限制”一样:
限制通配符下界后,编译器允许我们通过它读取对象,但对象的类型只为Object
,并且编译器也允许我们通过它来写入类型为Apple
的对象。
何时限制通配符的上界或下界?
再看完以上对于泛型通配符的各种描述后,你可能会不知所措,不知道自己应该什么时候去应用这种特性。
在《Effective Java》中正如此问题的完美答案,遇到以下2种情况,那你就可以运用这些特性了:
为了API获得最大限度的灵活性。
这里所要表达的意思就是给泛型API增加协变性或逆变性,让它能够更加通用。
PECS表示producer-extends,consumer-super。
如果确定参数化类型是生产者,就用extends
限制通配符的上界;如果参数化类型是消费者,就用super
限制通配符的下界。
更加通俗具体地理解就是参数化类型是只读的,那就用extends
限制通配符的上界;参数化类型是只写的,那就用super
限制通配符的下界。
如果现在面试的时候有这么一个试题:让你写一个工具方法可以返回Collection
参数中值最大的那个元素。
你可能会像下面这样做。
首先所能分析到的是这个工具方法肯定是一个泛型方法。
1 | public static <T> T max(Collection<T> c) { |
然后是分析到实现的时候就想我们该如何对Collection
中所有类型为T
的元素进行比较呢?
所以T
必须是实现java.lang.Comparable
接口,或者给max
方法传递一个java.util.Comarator
对象以对类型为T
的元素进行比较。
这里就选第一种方式,所以我们必须限制T
的边界。
1 | public static <T extends Comparable<T>> T max(Collection<T> c) { |
这似乎就是最终实现了,工具方法看上去完全可以拿来用了,演示所用的类型就是刚开始所定义的各种水果类型。
1 | public class Client { |
将max
方法应用于Fruit
列表没有任何问题,但将它用于Apple
列表时就产生问题了。
问题原因也正如报错信息所说:对于有界类型参数<T extends Comparable<T>>
来说,Apple
不是一个有效的代替类型。
只有Fruit
这个父类实现了Comparable
接口,其余子类没有实现对应的Comparable
接口,并且它们都是重用Fruit
中实现的compareTo
方法。
要解决这个问题最简单就是让所有Fruit
的子类都实现对应的Comparable
接口,这样将max
方法应用于任何Fruit
类型相关的集合都不会再有问题。然而有的时候出于一些原因我们是不能修改其他源代码的,所以这种解决方案是不可取的。
比如:有这么一种类型java.util.concurrent.ScheduledFuture
,现在将max
方法应用于参数化类型为List<ScheduledFuture<Integer>
的列表,这也会出现同样的问题。
因为这一种类型是这样定义的。
1 | package java.util.concurrent; |
这就像之前我们所定义的水果类型体系一样,只有父类型Delayed
扩展了Comparable
接口,而子类型ScheduledFuture
并没有扩展属于自己的Comparable
接口,所以这就导致我们所写的max
方法不能够在此正常使用了。
所以我们必须修改max
方法让它对这种情况也同样适用才行。解决方案很简单,只需要修改一处。
1 | public static <T extends Comparable<? super T>> T max(Collection<T> c) { |
方法内的实现没任何变动,就改了限定边界那一块,从原来的Comparable<T>
变成了Comparable<? super T>
。这样一改,之前出错的代码也能够正常使用了。
这个改动符合之前所说的2个点:给API增加了灵活性,代码变得更加通用,并且也正如《Effective Java》中所说Comparable
对于类型参数来说它永远是消费者,因此Comparable<? super T>
优先于Comparable<T>
,对于有同等功用的Comparator
来说也是一个道理。
此时,max
方法变得更加通用了,但它还不够完美,方法形参的类型还得再改一下。
我们也十分清楚,max
方法就是用于找出集合中值最大的那个元素并返回它。这也侧面说明,我们不能给传递进来的集合进行任何修改,这个集合只是一个生产者,所以最好让它变成只读的,确保集合不会在max
方法内部进行任何改动。
所以最终的max
方法是这样的。
1 | public static <T extends Comparable<? super T>> T max(Collection<? extends T> c) { |
方法形参的类型从Collection<T>
变成了Collection<? extends T>
,这对传递进来的集合有一种保护的作用,因为参数类型表明方法对传递进来的集合只能做读取操作。所以此处限制通配符上界并不是为了增加API的灵活性,而是因为它是一个生产者。
捕获转换
通配符参数化类型引用具体的参数化类型后,我们是否有办法再从这个通配符参数化类型引用得知具体参数化类型的持有类型是什么呢?
还真有,这种技术叫做捕获转换(capture conversion),这种技巧是利用了编译器的类型推断所衍生出来的。
1 | public class CaptureConversion { |