- 前言
- 探讨
- 泛型解决了什么问题?
- 扩展
- 引入泛型
- 什么是泛型?
- 泛型类
- 泛型接口
- 泛型方法
- 类型擦除
- 擦除的问题
- 边界
- 通配符
- 上界通配符
- 下界通配符
- 通配符和向上转型
- 泛型约束
- 实践总结
- 泛型命名
- 使用泛型的建议
- 参考资料:
前言
泛型是Java基础知识的重点,虽然我们在初学Java的时候,都学过泛型,觉得自己掌握对于Java泛型的使用(全是错觉),往后的日子,当我们深入去阅读一些框架源码,你就发现了,自己会的只是简单的使用,却看不懂别人的泛型代码是怎么写的,还可以这样,没错,别人写出来的代码那叫艺术,而我......
探讨
Java
语言为什么存在着泛型,而像一些动态语言Python
,JavaScipt
却没有泛型的概念?
原因是,像Java
,C#
这样的静态编译型的语言,它们在传递参数的时候,参数的类型,必须是明确的,看一个例子,简单编写一个存放int
类型的栈—StackInt
,代码如下:
public class StackInt {
private int maxSize;
private int[] items;
private int top;
public StackInt(int maxSize){
this.maxSize = maxSize;
this.items = new int[maxSize];
this.top = -1;
}
public boolean isFull(){
return this.top == this.maxSize-1;
}
public boolean isNull(){
return this.top <= -1;
}
public boolean push(int value){
if(this.isFull()){
return false;
}
this.items[++this.top] = value;
return true;
}
public int pop(){
if(this.isNull()){
throw new RuntimeException("当前栈中无数据");
}
int value = this.items[top];
--top;
return value;
}
}
在这里使用构造函数初始化一个StackInt
对象时,可以传入String
字符串吗?很明显是不行的,我们要求的是int
类型,传入字符串String
类型,这样在语法检查阶段时会报错的,像Java
这样的静态编译型的语言,参数的类型要求是明确的
泛型解决了什么问题?
参数不安全:引入泛型,能够在编译阶段找出代码的问题,而不是在运行阶段
泛型要求在声明时指定实际数据类型,Java 编译器
在编译时会对泛型代码做强类型检查,并在代码违反类型安全时发出告警。早发现,早治理,把隐患扼杀于摇篮,在编译时发现并修复错误所付出的代价远比在运行时小。
避免类型转换:
未使用泛型:
List list = new ArrayList();
list.add("hello");
String s = (String) list.get(0); //需要在取出Value的时候进行强制转换
使用泛型:
List list = new ArrayList();
list.add("hello");
String s = list.get(0); //不需要强制转换
重复编码::通过使用泛型,可以实现通用编码,可以处理不同类型的集合,并且类型安全且易于阅读。像上面的StackInt
类,我们不能针对每个类型去编写对应类型的栈,那样太麻烦了,而泛型的出现就很好的解决了这点
扩展
在上面的StackInt
类有一些不好的地方,那就是太具体了,不够抽象,不够抽象,那么它的复用性也是不高的,例如,在另外的场景下,我需要的是往栈里存String
类型的字符串,或者是其他类型,那么StackInt
类就做不到了,那么有什么方法能够做到呢?再写一个StackString
类,不可能,那样不得累死。那就只有引入基类Object
了,我们改进一下代码:
public class StackObject {
private int maxSize;
private Object[] items;
private int top;
public StackObject(int maxSize){
this.maxSize = maxSize;
this.items = new Object[maxSize];
this.top = -1;
}
public boolean isFull(){
return this.top == this.maxSize-1;
}
public boolean isNull(){
return this.top <= -1;
}
public boolean push(Object value){
if(this.isFull()){
return false;
}
this.items[++this.top] = value;
return true;
}
public Object pop(){
if(this.isNull()){
throw new RuntimeException("当前栈中无数据");
}
Object value = this.items[top];
--top;
return value;
}
}
使用StackObject
可以存储任意类型的数据,那么这样做,又有什么优点和缺点呢?
优点:StackObject
类变得相对抽象了,我们可以往里面存储任何类型的数据,这样就避免了写一些重复代码
缺点:
1、用Object
表示的对象是比较抽象的,它失去了类型的特点,那么我们在做一些运算的时候,可能会频繁的拆箱装箱的过程
看上面的例图,我们理解的认为存放了两个数值,12345
和54321
,将两个进行相加,这是很常见的操作,但是报错了,编译器给我们的提示是,+
操作运算不能用于两个Object
类型,那么只能对其进行类型转换,这也是我们上面说到的泛型能解决的问题,我们需要这样做,int sum = (int)val1 + (int)val2;
,同时在涉及拆箱装箱时,是有一定性能的损耗的,关于拆箱装箱
在这里不作描述,可以参考我写过的随笔—— 深入理解Java之装箱与拆箱
2、对于我们push
进去的值,我们在取出的时候,容易忘记类型转换,或者不记得它的类型,类型转换错误,这在后面的一些业务可能埋下祸根,例如下面这个场景:直到运行时错误才暴露出来,这是不安全的,也是违反软件开发原则的,应该尽早的在编译阶段就发现问题,解决问题
3、使用Object
太过于模糊了,没有具体类型的意义
最好不要用到Object
,因为Object
是一切类型的基类,也就是说他把一些类型的特点给抹除了,比如上面存的数字,对于数字来说,加法运算就是它的一个特点,但是用了Object
,它就失去了这一特点,失去类型特有的行为
引入泛型
什么是泛型?
泛型:是被参数化的类或接口,是对类型的约定
泛型类
class name { /* ... */ }
一般将泛型中的类名称为原型,而将 <>
指定的参数称为类型参数,<>
相当于类型的约定,T
就是类型,相当于一个占位符,由我们在调用时指定
使用泛型改进一下上面StackObject
类,但是,数组和泛型不能很好地结合。你不能实例化具有参数化类型的数组,例如下面的代码是不合格的:
public StackT(int maxSize){
this.maxSize = maxSize;
this.items = new T[maxSize];
this.top = -1;
}
Java
中不允许直接创建泛型数组,这是因为相比于C++
,C#
的语法,Java
泛型其实是伪泛型,这点在后面会说到,但是,可以通过创建一个类型擦除的数组,然后转型的方式来创建泛型数组。
private int maxSize;
private T[] items;
private int top;
public StackT(int maxSize){
this.maxSize = maxSize;
this.items = (T[]) new Object[maxSize];
this.top = -1;
}
实际上,真的需要存储泛型,还是使用容器更合适,回到原来的代码上,需要知道的是,泛型类型不能是基本类型的,需要是包装类
上面说到了Java
中不允许直接创建泛型数组,事实上,Java
中的泛型我们是很难通new
的方式去实例化对象,不仅仅是实例化对象,甚至是获取T
的真实类型也是很难的,当然通过反射的机制还是可以获取到的,Java
获取真实类型的方式有 3 种,分别是:
1、类名.class
2、对象.getClass
3、class.forName("全限定类名")
但是,在这里,1
和2
的方式都是做不到的,虽然我们在外边明确的传入了Integer
类型,new StackT
但是在StackT
类,使用T.class
还是获取不到真实类型的,第 2 种方式的话,并没有传入对象,前面也说到是没有办法new
方式实例化的,而通过反射机制是可以做到的,这里不作演示,需要了解的话可以参考 —— Java如何获得泛型类的真实类型、 Java通过反射获取泛型的类型
但是在C#
中的泛型以及C++
的模板,这是很容易做到的,所以说Java
的泛型是伪泛型,Java
并不是做不到像C#
一样,而是为了迁就老的JDK
语法所作出的妥协,至于上面为什么做不到这样,这就要说到泛型的类型擦除了。
再说类型擦除之前,先说一下泛型接口,和泛型方法吧
泛型接口
接口也可以声明泛型,泛型接口语法形式:
public interface Content {
T text();
}
泛型接口有两种实现方式:
- 实现接口的子类明确声明泛型类型
public class ContentImpl implements Content {
private int text;
public ContentImpl(int text) {
this.text = text;
}
public static void main(String[] args) {
ContentImpl one = new ContentImpl(10);
System.out.print(one.text());
}
}
// Output:
// 10
- 实现接口的子类不明确声明泛型类型
public class ContentImpl implements Content {
private T text;
public ContentImpl(T text) {
this.text = text;
}
@Override
public T text() { return text; }
public static void main(String[] args) {
ContentImpl two = new ContentImpl<>("ABC");
System.out.print(two.text());
}
}
// Output:
// ABC
泛型方法
泛型方法是引入其自己的类型参数的方法。泛型方法可以是普通方法、静态方法以及构造方法。
泛型方法语法形式如下:
public T func(T obj) {}
是否拥有泛型方法,与其所在的类是否是泛型没有关系。
泛型方法的语法包括一个类型参数列表,在尖括号内,它出现在方法的返回类型之前。对于静态泛型方法,类型参数部分必须出现在方法的返回类型之前。类型参数能被用来声明返回值类型,并且能作为泛型方法得到的实际类型参数的占位符。
使用泛型方法的时候,通常不必指明类型参数,因为编译器会为我们找出具体的类型。这称为类型参数推断(type argument inference)。类型推断只对赋值操作有效,其他时候并不起作用。如果将一个泛型方法调用的结果作为参数,传递给另一个方法,这时编译器并不会执行推断。编译器会认为:调用泛型方法后,其返回值被赋给一个 Object 类型的变量。
public class GenericsMethod {
public static void printClass(T obj) {
System.out.println(obj.getClass().toString());
}
public static void main(String[] args) {
printClass("abc");
printClass(10);
}
}
// Output:
// class java.lang.String
// class java.lang.Integer
泛型方法中也可以使用可变参数列表
public class GenericVarargsMethod {
public static List makeList(T... args) {
List result = new ArrayList();
Collections.addAll(result, args);
return result;
}
public static void main(String[] args) {
List ls = makeList("A");
System.out.println(ls);
ls = makeList("A", "B", "C");
System.out.println(ls);
}
}
// Output:
// [A]
// [A, B, C]
类型擦除
事实上,Java的运行大致可以分为两个阶段,编译阶段
,运行阶段
那么对于Java
泛型来说,当编译阶段过后,泛型 T 是已经被擦除了,所以在运行阶段,它已经丢失了 T 的具体信息,而我们去实例化一个对象的时候,比如T c = new T();
,它的发生时机是在运行阶段,而在运行阶段,你要new T()
,就需要知道 T 的具体类型,实际上这时候 T
是被替换成Integer
了,而JVM
是不知道T
的类型的,所以是没有办法实例化的。
那么,类型擦除做了什么呢?它做了以下工作:
- 把泛型中的所有类型参数替换为 Object,如果指定类型边界,则使用类型边界来替换。因此,生成的字节码仅包含普通的类,接口和方法。
- 擦除出现的类型声明,即去掉
<>
的内容。比如T get()
方法声明就变成了Object get()
;List
就变成了List
。如有必要,插入类型转换以保持类型安全。 - 生成桥接方法以保留扩展泛型类型中的多态性。类型擦除确保不为参数化类型创建新类;因此,泛型不会产生运行时开销。
让我们来看一个示例:
import java.util.*;
public class ErasedTypeEquivalence {
public static void main(String[] args) {
Class c1 = new ArrayList().getClass();
Class c2 = new ArrayList().getClass();
System.out.println(c1 == c2);
}
}
/* Output:
true
*/
ArrayList
和 ArrayList
应该是不同的类型。不同的类型会有不同的行为。例如,如果尝试向 ArrayList
中放入一个 Integer
,所得到的行为(失败)和 向 ArrayList
中放入一个 Integer
所得到的行为(成功)完全不同。但是结果输出的是true
,这意味着使用泛型时,任何具体的类型信息都被擦除了,ArrayList
和 ArrayList
在运行时,JVM 将它们视为同一类型class java.util.ArrayList
再用一个例子来对于该谜题的补充:
import java.util.*;
class Frob {}
class Fnorkle {}
class Quark {}
class Particle {}
public class LostInformation {
public static void main(String[] args) {
List list = new ArrayList<>();
Map map = new HashMap<>();
Quark quark = new Quark<>();
Particle p = new Particle<>();
System.out.println(Arrays.toString(list.getClass().getTypeParameters()));
System.out.println(Arrays.toString(map.getClass().getTypeParameters()));
System.out.println(Arrays.toString(quark.getClass().getTypeParameters()));
System.out.println(Arrays.toString(p.getClass().getTypeParameters()));
}
}
/* Output:
[E]
[K,V]
[Q]
[POSITION,MOMENTUM]
*/
根据 JDK 文档,Class.getTypeParameters() “返回一个 TypeVariable 对象数组,表示泛型声明中声明的类型参数...” 这暗示你可以发现这些参数类型。但是正如上例中输出所示,你只能看到用作参数占位符的标识符,这并非有用的信息。
残酷的现实是:在泛型代码内部,无法获取任何有关泛型参数类型的信息。
以上两个例子皆出《Java 编程思想》第五版 —— On Java 8
中的例子,本文借助该例子,试图讲清楚Java
泛型是使用类型擦除这里机制实现的,能力不足,有错误的地方,还请指正。关于On Java 8
一书,已在github
上开源,并有热心的伙伴将之翻译成中文,现在给出阅读地址,On Java 8
擦除的问题
擦除的代价是显著的。泛型不能用于显式地引用运行时类型的操作中,例如转型、instanceof 操作和 new 表达式。因为所有关于参数的类型信息都丢失了,当你在编写泛型代码时,必须时刻提醒自己,你只是看起来拥有有关参数的类型信息而已。
考虑如下的代码段:
class Foo {
T var;
}
看上去当你创建一个 Foo 实例时:
Foo f = new Foo<>();
class Foo 中的代码应该知道现在工作于 Cat 之上。泛型语法也在强烈暗示整个类中所有 T 出现的地方都被替换,就像在 C++ 中一样。但是事实并非如此,当你在编写这个类的代码时,必须提醒自己:“不,这只是一个 Object“。
继承问题
泛型时基于类型擦除实现的,所以,泛型类型无法向上转型。
向上转型是指用子类实例去初始化父类,这是面向对象中多态的重要表现。
Integer
继承了 Object
;ArrayList
继承了 List
;但是 List
却并非继承了 List
。
这是因为,泛型类并没有自己独有的 Class
类对象。比如:并不存在 List
或是 List
,Java 编译器会将二者都视为 List.class
。
如何解决上面所产生的问题:
其实并不一定要通过new
的方式去实例化,我们可以通过显式的传入源类,一个Class
的对象来补偿擦除,例如instanceof 操作,在程序中尝试使用 instanceof 将会失败。类型标签可以使用动态 isInstance()
,这样改进代码:
public class Improve {
//错误方法
public boolean f(Object arg) {
// error: illegal generic type for instanceof
if (arg instanceof T) {
return true;
}
return false;
}
//改进方法
Class clazz;
public Improve(Class clazz) {
this.clazz = clazz;
}
public boolean f(Object arg) {
return kind.isInstance(arg);
}
}
实例化:
试图在 new T()
是行不通的,部分原因是由于擦除,部分原因是编译器无法验证 T 是否具有默认(无参)构造函数。
Java 中的解决方案是传入一个工厂对象,并使用该对象创建新实例。方便的工厂对象只是 Class 对象,因此,如果使用类型标记,则可以使用 newInstance()
创建该类型的新对象:
class Improve {
Class kind;
Improve(Class kind) {
this.kind = kind;
}
public T get(){
try {
return kind.newInstance();
} catch (InstantiationException |
IllegalAccessException e) {
throw new RuntimeException(e);
}
}
}
class Employee {
@Override
public String toString() {
return "Employee";
}
}
public class InstantiateGenericType {
public static void main(String[] args) {
Improve fe = new Improve<>(Employee.class);
System.out.println(fe.get());
}
}
/* Output:
Employee
*/
通过这样改进代码,可以实现创建对象的实例,但是要注意的是,newInstance();
方法调用无参构造函数的,如果传入的类型,没有无参构造的话,是会抛出InstantiationException
异常的。
泛型数组:
泛型数组这部分,我们在上面说到可以通过创建一个类型擦除的数组,然后转型的方式来创建泛型数组,这次我们可以通过显式的传入源类的方式来编写StackT
类,解决创建泛型数组的问题,代码如下:
public class StackT {
private int maxSize;
private T[] items;
private int top;
public StackT(int maxSize, Class clazz){
this.maxSize = maxSize;
this.items = this.createArray(clazz);
this.top = -1;
}
public boolean isFull(){
return this.top == this.maxSize-1;
}
public boolean isNull(){
return this.top <= -1;
}
public boolean push(T value){
if(this.isFull()){
return false;
}
this.items[++this.top] = value;
return true;
}
public T pop(){
if(this.isNull()){
throw new RuntimeException("当前栈中无数据");
}
T value = this.items[top];
--top;
return value;
}
private T[] createArray(Class clazz){
T[] array =(T[])Array.newInstance(clazz, this.maxSize);
return array;
}
}
边界
有时您可能希望限制可在参数化类型中用作类型参数的类型。类型边界
可以对泛型的类型参数设置限制条件。例如,对数字进行操作的方法可能只想接受 Number
或其子类的实例。
要声明有界类型参数,请列出类型参数的名称,然后是 extends
关键字,后跟其限制类或接口。
类型边界的语法形式如下:
示例:
public class GenericsExtendsDemo01 {
static > T max(T x, T y, T z) {
T max = x; // 假设x是初始最大值
if (y.compareTo(max) > 0) {
max = y; //y 更大
}
if (z.compareTo(max) > 0) {
max = z; // 现在 z 更大
}
return max; // 返回最大对象
}
public static void main(String[] args) {
System.out.println(max(3, 4, 5));
System.out.println(max(6.6, 8.8, 7.7));
System.out.println(max("pear", "apple", "orange"));
}
}
// Output:
// 5
// 8.8
// pear
示例说明:
上面的示例声明了一个泛型方法,类型参数
T extends Comparable
表明传入方法中的类型必须实现了 Comparable 接口。
类型边界可以设置多个,语法形式如下:
注意:extends 关键字后面的第一个类型参数可以是类或接口,其他类型参数只能是接口。
通配符
通配符是Java
泛型中的一个非常重要的知识点。很多时候,我们其实不是很理解通配符?
和泛型类型T
区别,容易混淆在一起,其实还是很好理解的,?
和 T
都表示不确定的类型,区别在于我们可以对 T
进行操作,但是对 ?
不行,比如如下这种 :
// 可以
T t = operate();
// 不可以
? car = operate();
但是这个并不是我们混淆的原因,虽然?
和 T
都表示不确定的类型,T
通常用于泛型类和泛型方法的定义,?
通常用于泛型方法的调用代码和形参,不能用于定义类和泛型方法。用代码解释一下,回到文章最初说的栈类StackT
,我们以这个为基础来解释,上面的观点:
public class Why {
public static void main(String[] args) {
StackT stackT = new StackT<>(3, Integer.class);
stackT.push(8);
StackT stackT1 = new StackT<>(3, String.class);
stackT1.push("7");
test(stackT1);
}
public static void test(StackT stackT){
System.out.println(stackT.pop());
}
}
// Output: 8
以我们编写的StackT
类,进行测试,编写一个test
方法,传入参数类型StackT
,上面的程序正常输出字符串"7" ,这没有什么问题,问题在这里失去了泛型的限定,传进去的实参StackT1
,是被我们限定为StackT
,但是我们通过编译器可以看到stackT.pop()
出来的对象,并没有String
类型的特有方法,也就是说,它其实是Object
类
那么我们就需要修改test
方法的形参,改为:
public static void test(StackT stackT){
System.out.println(stackT.pop());
}
这样子就回到了我们问题的本质来了,将形参修改为StackT
,这起到了泛型的限定作用,但是会出现这样的问题,如果我们需要向该方法传入StackT
类型的对象 stackT
是,因为方法形参限定了StackT
,,这时候就报错了
这个时候就是通配符?
起作用了,将方法形参改为StackT>
就可以了,这也就确定了我们刚刚的结论,?
通配符通常是用于泛型传参,而不是泛型类的定义。
public static void test(StackT> stackT){
System.out.println(stackT.pop());
}
但是这种用法我们通常也不会去用,因为它还是失去了类型的特点,即当无界泛型通配符作为形参时,作为调用方,并不限定传递的实际参数类型。但是,在方法内部,泛型类的参数和返回值为泛型的方法,不能使用!
这里,StackT.push
就不能用了,因为我并不知道?
传的是Integer
还是String
,还是其他类型,所以是会报错的。
但是我们有时候是有这样的需求的,我们在接收泛型栈StackT
作为形参的时候,我想表达一种约束的关系,但是又不像StackT
一样,约束的比较死板,而Java
是面向对象的语言,那么就会有继承的机制,我想要的约束关系是我能接收的泛型栈的类型都是Number
类的派生类,即不会像?
无界通配符一样失去类的特征,又不会像StackT
约束的很死,这就引出了上界通配符的概念。
上界通配符
可以使用上界通配符
来缩小类型参数的类型范围。
它的语法形式为: extends Number>
public class Why {
public static void main(String[] args) {
StackT stackT = new StackT<>(3, Integer.class);
stackT.push(8);
StackT stackT1 = new StackT<>(3, String.class);
stackT1.push("7");
StackT stackT2 = new StackT<>(3, Double.class);
//通过
test(stackT);
test(stackT2);
//error
test(stackT1);
}
public static void test(StackT extends Number> stackT){
System.out.println(stackT.pop());
}
}
这样就实现了一类类型的限定,但是需求变更了,我现在希望的约束关系是我能接收的泛型栈的类型都是Number
类的父类,或者父类的父类,那么有上界,自然就有下界
下界通配符
下界通配符
将未知类型限制为该类型的特定类型或超类类型。
注意:上界通配符和下界通配符不能同时使用。
它的语法形式为: super Number>
public class Why {
public static void main(String[] args) {
StackT stackT1 = new StackT<>(3, Number.class);
stackT1.push(8);
StackT stackT2 = new StackT<>(3, Double.class);
StackT
这样子的话,就确保了我们的test
方法只接收Number
类型以上的方法。泛型的各种高级语法可能在写业务代码的时候可以规避,但是如果你要去写一些框架的时候,由于你不知道框架的使用者的使用场景,那么掌握泛型的高级语法就很有用了。
通配符和向上转型
前面,我们提到:泛型不能向上转型。但是,我们可以通过使用通配符来向上转型。
public class GenericsWildcardDemo {
public static void main(String[] args) {
List intList = new ArrayList<>();
List numList = intList; // Error
List extends Integer> intList2 = new ArrayList<>();
List extends Number> numList2 = intList2; // OK
}
}
通配符边界问题,关于一些更加深入的解惑可以参考整理的转载的文章——Java泛型解惑之上下通配符
泛型约束
- 泛型类型的类型参数不能是值类型
Pair p = new Pair<>(8, 'a'); // 编译错误
- 不能创建类型参数的实例
public static void append(List list) {
E elem = new E(); // 编译错误
list.add(elem);
}
- 不能声明类型为类型参数的静态成员
public class MobileDevice {
private static T os; // error
// ...
}
- 类型参数不能使用类型转换或
instanceof
public static void rtti(List list) {
if (list instanceof ArrayList) { // 编译错误
// ...
}
}
List li = new ArrayList<>();
List ln = (List) li; // 编译错误
- 不能创建类型参数的数组
List[] arrayOfLists = new List[2]; // 编译错误
- 不能创建、catch 或 throw 参数化类型对象
// Extends Throwable indirectly
class MathException extends Exception { /* ... */ } // 编译错误
// Extends Throwable directly
class QueueFullException extends Throwable { /* ... */ // 编译错误
public static void execute(List jobs) {
try {
for (J job : jobs)
// ...
} catch (T e) { // compile-time error
// ...
}
}
- 仅仅是泛型类相同,而类型参数不同的方法不能重载
public class Example {
public void print(Set strSet) { }
public void print(Set intSet) { } // 编译错误
}
实践总结
泛型命名
泛型一些约定俗成的命名:
- E - Element
- K - Key
- N - Number
- T - Type
- V - Value
- S,U,V etc. - 2nd, 3rd, 4th types
使用泛型的建议
- 消除类型检查告警
- List 优先于数组
- 优先考虑使用泛型来提高代码通用性
- 优先考虑泛型方法来限定泛型的范围
- 利用有限制通配符来提升 API 的灵活性
- 优先考虑类型安全的异构容器
参考资料:
深入理解 Java 泛型
On Java 8
Java泛型解惑之 extends T>和 super T>上下界限
7月的直播课——Java 高级语法—泛型