本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
引言
作为每天跟代码打交道的我们,相信对泛型这个技术并不陌生,可是你真的熟练掌握Java泛型了吗?一部分人可能会摇摇头,尽管每天都在用,但都是在使用别人封装的类库时才接触,单论直接对泛型的使用,在日常工作里鲜有涉及。
或许是因为初学Java时的不在意,又或者是教学老师的不细心,所以导致有些小伙伴对泛型的掌握和理解并不算深刻。因此,本文将从基操深入到原理,再以日常编码中的实践举例,携手诸君重温Java泛型体系。
事先声明:基础扎实者可直接跳到第二、三阶段开始阅读。
PS:个人编写的《技术人求职指南》小册已完结,其中从技术总结开始,到制定期望、技术突击、简历优化、面试准备、面试技巧、谈薪技巧、面试复盘、选
Offer
方法、新人入职、进阶提升、职业规划、技术管理、涨薪跳槽、仲裁赔偿、副业兼职……,为大家打造了一套“从求职到跳槽”的一条龙服务,同时也为诸位准备了七折优惠码:3DoleNaE
,近期需要找工作的小伙伴可以点击:s.juejin.cn/ds/USoa2R3/了解详情!
一、Java泛型机制概述
泛型(Generics
)是JDK5中引入的一个新特性,它允许将一个类型作为参数传递给类、接口、方法使用,这种特性被称为参数化类型。与编译时类型安全检测机制相结合,能实现在编译期检测代码中的非法类型,泛型给Java带来的好处如下:
- 类型安全:能在编译时进行类型检查,避免了运行时类型转换出错的可能性;
- 消除强制类型转换:泛型减少了代码中的显式强制类型转换,使代码更加清晰简洁;
- 提高代码的重用性:可以基于泛型封装更加通用、灵活的代码,从而在不同场景下复用;
- 更好的性能:在某些情况下,泛型可以避免基础类型自动装箱和拆箱,提高程序性能。
当然,Java在引入泛型之前,作为一门面向对象的编程语言,其中所具备的多态特性,也是泛化的一种体现,但通过多态机制去封装一个通用类库,使用起来会有些麻烦,下面来看看。
1.1、为什么需要泛型?
在泛型出现之前,如果我们封装一个通用的类库,如现在常用的List
集合,该如何进行设计?如下:
public class CustomList {
private Object[] elements;
private int index = 0;
public CustomList(int initialCapacity) {
this.elements = new Object[initialCapacity];
}
public void add(Object element) {
// 如果集合可用容量不够,则对数组进行双倍扩容
if (index >= this.elements.length) {
int length = this.elements.length;
Object[] newElements = new Object[length * 2];
System.arraycopy(elements, 0, newElements, 0, length);
elements = newElements;
}
this.elements[index++] = element;
}
public Object get(int index) {
// 如果传入的下标不存在,则抛出下标越界异常
if (index < 0 || index >= this.elements.length) {
throw new IndexOutOfBoundsException("max index : " + (this.elements.length - 1));
}
return this.elements[index];
}
}
自定义的CustomList
类作为一个容器,有可能用来承载任何类型的对象,为了确保通用性,内部使用Object
数组来存储元素。接着,为了能够正常读写容器,我们封装了add()、get()
两个方法,这两个方法的内在逻辑极其简单,向容器添加元素时,会先检查下是否还有空位置,没有则扩容,然后将元素添加到数组尾部。从容器获取元素时,先对传入的下标进行范围检查,合法则根据下标从数组里读取元素返回:
CustomList list = new CustomList(16);
list.add("竹子爱熊猫");
list.add(new Object());
String zhuZi = (String) list.get(0);
System.out.println(zhuZi);
// 强制转换第二个元素会报错
String obj = (String) list.get(1);
尽管目前可以通过add()、get()
来对容器进行读写,可显然存在两个致命问题!首先是读取数据时,每次需要对读到的元素进行强制转换才能使用;其次是写入数据时,无法限制向容器里写入的元素类型,我们可以将任意类型的对象往容器里塞,而当从容器读取元素强制转换时,读到预期外的类型就会出现ClassCastException
类型转换异常。
上面这样的结果,显然并非我们所需,我们更希望的是:在读取数据时无需手动转换元素;在写入时,如果有可能存在潜在的错误,理应在编译期就告知我们,而不是等到运行期间再抛出异常。而Java泛型的诞生,则完美的解决此问题。
1.2、Java泛型快速回顾
泛型是Java编程中不可或缺的一部分,能极大地增强Java
的类型系统和表达能力,在封装通用类库、框架及实践各种设计模式时被广泛应用,我们来看个泛型的例子:
public class ZhuZi<T> {
private T data;
public void setData(T data) {
this.data = data;
}
public T getData() {
return data;
}
}
public static void main(String[] args) {
ZhuZi<String> zs = new ZhuZi<>();
ZhuZi<Integer> zi = new ZhuZi<>();
}
上述案例中,ZhuZi
类定义了一个类型参数T
(也被成为类型占位符),这种写法则是所谓的泛型。再看类成员,其中定义了一个类型为T
的成员。在下面的main()
方法里使用ZhuZi
类时,在类名后面通过<>
尖括号指定了不同的类型,这是啥意思?很简单,假如ZhuZi
是一个箱子,里面的data
就是具体要放置的东西,而<>
里指定的类型,就相当于一个标签,表示zs、zi
这些具体的箱子究竟可以放什么物品进去。
zs.setData("竹子爱熊猫");
String zhuZi = zs.getData();
System.out.println(zhuZi);
// 强制塞入指定类型之外的数据会报错
zs.setData(new Object());
在我们使用这些带泛型的对象时,从其中读取数据再也无须强制转换。同样,强行往其中塞入非String
的数据,在编译期间就会报错。
1.3、泛型的三种作用域
上面简单回顾泛型的基本知识后,下面一起来看看泛型的三种作用域。
1.3.1、泛型类
当一个类中,某个变量或方法入/出参的类型不确定,就可以定义带有泛型的类来解决此困境,例如最常见的后端接口统一的返回结果类:
public class ServerResponse<T> implements Serializable {
private static final long serialVersionUID = 1L;
// 响应状态码
private String code;
// 响应消息
private String msg;
// 是否成功标识
private boolean isSuccess;
// 响应的数据
private T data;
// 链路追踪ID
private String traceId;
// 接口响应时间戳
private final long timestamp = System.currentTimeMillis();
}
上述ServerResponse
类就是泛型类的写法,即类名后面定义了一个类型参数T
,这个T
可以是任意符合Java命名规范的名字,但通常都是对应英文的缩写,如K=Key、V=Value、T=Type、E=Element……
。
在ServerResponse
类的内部,T
可以被当作字段的类型使用,也可以作为方法的形参定义、出参类型使用,不过要格外注意的是:普通类定义的类型参数,无法提供给类的静态成员使用,Why?这跟Java的类型推导机制有关,因为静态成员属于类,而泛型依赖于具体的类实例对象来推导,如果实例对象都没有创建,Java自然无法推导泛型对应的具体类型。
当然,定义了一个泛型类后,使用该类时可以不指定类型,如:
ServerResponse resp = new ServerResponse();
resp.setData(1);
resp.setData("竹子");
resp.setData(new Object());
这是什么原理呢?很简单,如果不通过<>
指定具体类型,那么泛型默认就为Object
类型,因为它是Java所有类的基类,意味着你可以向resp
塞入任何数据,只不过读取使用时,又需要强制转换后方能使用罢了。
1.3.2、泛型接口
泛型接口与泛型类的定义方式完全相同,只需在接口名后面跟上<x>
即可:
public interface PandaFactory<T> {
T getPanda();
}
众所周知,接口中定义的所有方法子类必须实现(静态方法与默认方法除外),而接口定义的泛型同样具备此特性,如果一个类想要实现某个泛型接口,那么该子类必须使用或继承(延续)该泛型,啥意思?如下:
public class BigPanda implements PandaFactory<String> {}
public class BigPanda implements PandaFactory {}
public class BigPanda<T> implements PandaFactory<T> {}
// 错误的写法
public class BigPanda implements PandaFactory<T> {}
正如代码中所示,第一种写法代表子类实现接口时,明确将泛型指定成了String
类型的实参;而第二种写法省略了泛型,则默认会使用Object
;第三种方式表示继承了接口的泛型,BigPanda
类成为一个泛型类;但最后一种写法会报错,因为这种写法既未使用泛型,也未继承接口泛型。
public class ZhuZi<A, B, C, D, E, F, G> {}
当然,不管是泛型类,还是泛型接口,都可以定义一或多个泛型参数,多个泛型之间用英文逗号分隔即可。
1.3.3、泛型方法
泛型方法允许在方法级别引入类型参数,从而编写出更加通用、灵活和可重用的代码。但是,泛型类和泛型接口的定义十分简单,而泛型方法却有些令人费解,这也是许多人未完全掌握的一种泛型方式,先来看个例子:
public <O> boolean equals(O obj1, O obj2) {
return obj1 instanceof CharSequence && obj2 instanceof CharSequence
? obj1.equals(obj2) : obj1 == obj2;
}
上述是一个比较两个对象是否相同的方法,如果传入的两个参数都为字符类型,则使用equals()
来比较,否则使用==
来进行比较。不过方法的内在逻辑并不重要,重点来看看这个方法的定义方式,与普通方法不同点在于:访问修饰符与方法出参类型之间,通过尖括号定义了一个泛型,而这种方法则被称之为泛型方法。
实例方法、接口方法、静态方法、抽象方法、常量方法……,Java中的任何方法都可以被声明为泛型方法,方法泛型与类泛型的区别在于:类泛型相当于全局变量,只要是当前类的实例成员(包括内部类里),都可以使用类上定义的泛型;而方法泛型等价于局部变量,只能在对应方法的形参、出参、方法体内使用。
方法同样可以定义多个泛型,格式也与之前类似,如下:
public static <K, V> V putMap(K key, V value) {
HashMap<K, V> map = new HashMap<>();
return map.putIfAbsent(key, value);
}
方法定义了多个泛型时,定义的泛型可以在方法作用域的任意位置出现,但要记住,如果定义了一个泛型,直接将其作为出参类型,这时将难以返回结果,例如:
public <P, R> R handler(P param) {
return ???;
}
这个方法定义了两个泛型,P
作为了方法形参类型,R
作为了方法出参类型,可对于编译器来说,它无法推断出这个R
的具体类型,这时只能强转或外部传入R
:
// 方式一:将方法返回结果强转成R类型
public <P, R> R handler(P param) {
return (R) new Object();
}
// 方式二:由外部传入R类型入参,并将该参数作为出参
public <P, R> R handler(P param, R result) {
return result;
}
最后,在调用泛型方法时,一般无需手动指定类型,例如前面的putMap()
方法,可以直接这么调用:
String value = putMap("name", "竹子爱熊猫");
这种方式编译器会自动推导传入的类型,不过也可以手动给定类型:
ZhuZi.<String, String>putMap("name", "竹子爱熊猫");
众所周知,Java中的.
代表调用方法的意思,而调用泛型方法时,只需在.
后面跟上具体的泛型就行。
二、泛型通配符与类型擦除
上阶段了解Java泛型的基本知识后,不难发现一个问题,不论是类、接口,还是方法定义的泛型,在传入类型参数时,都可以传递任意类型,这虽然能够极大增强代码的复用性、灵活性和类型安全性,但在某些情况下也会存在些许问题,好比你封装了一个通用方法,但它仅适用于处理某类特定的对象,这时该怎么办?来看例子:
public static <A, B> int sizeSum(A a, B b) {
// 求a、b两个集合的长度
}
你明确清楚这个sizeSum()
方法是为了封装给集合类使用,作用是求和两个集合的元素数量,这个需求很容易对吧?直接用a.size()
加上b.size()
就行,可当你尝试调用a、b
入参的size()
方法时,就会发现根本调用不了!
2.1、边界约束通配符
正如前面的案例所示,在有些特殊场景下,我们更希望能够限制泛型参数的类型,以确保类型安全或满足特定的功能需求。Java官方显然也想到了这点,所以提供了泛型边界约束机制来满足这些场景,而泛型的边界约束又分为上界、下界两种。
2.1.1、泛型上界(Upper Bound)
上界约束通过extends
关键字来指定泛型的上界,指定后则代表对应泛型参数必须是指定类型或该类型的子类型。也就是说,泛型上界可以用来限制泛型参数的类型范围,例如你声明某个泛型的上界为ZhuZi
,当你传入的类型并非ZhuZi
或它的子类时,就无法通过编译校验,这进一步确保类型安全。
其次,限制泛型上界后,还可以使用指定类型的方法或属性。以刚刚的例子说明,如果明确清楚这个方法是提供给集合类使用,那么可以这么操作:
public static <A extends Collection<E>, B extends Collection<E>, E> int totalSize(A a, B b) {
return a.size() + b.size();
}
这时大家会发现,a、b
入参又能正常调用size()
方法了,为啥?因为指定了A、B
泛型的上限为Collection
,这代表调用totalSize()
方法时,传递进来的对象都是Collection
自身或其子类,而size()
方法由Collection
定义,那么入参a、b
自然可以正常使用此方法!
综上,当你想要封装一个只能处理某个类型及其子类的方法时,就可以将泛型的上界定义成特定的类,既能在编译阶段就可以阻止不规范的类型传递,还能在泛型类、泛型方法内部调用特定类的方法~
2.1.2、泛型下界(Lower Bound)
上界用于约束泛型的上限,下界约束与之相反,它用于约束泛型的下限,我们可以通过super
关键字来指定泛型的下界,代表泛型参数必须是指定类型的超类型/父类型(包含自身)。不过与类型上界有所区别的是:super
关键字无法直接作用在泛型定义上,这是啥意思呢?如下:
public class ZhuZi<T super Number> {}
如果凭借前面使用extends
关键字的经验,super
应该是按上述形式来使用,可当你试图写出这样的代码时,就会发现Java语法糖并不支持,那该怎么用?通常来说,super
会结合Java8的函数式接口与Stream流来一起使用,好比封装一个通用的StreamUtils
工具类,其中需要实现过滤方法:
public static <T> List<T> filter(List<T> list, Predicate<? super T> predicate){
return list.stream().filter(predicate).collect(Collectors.toList());
}
观察后不难发现,泛型下界的正确使用方式是:在使用泛型参数传递<? super T>
,这里的?
放在后面讲,先来寥寥这个泛型下界,来看个简单例子:
public static void printNumbers(List<? super Number> numbers) {
numbers.forEach(System.out::println);
}
这是一个打印输出集合元素的方法,入参numbers
通过super
限制了泛型下界为Number
类型,这代表调用此方法至少要传递Number
类型的List
对象进来:
// 能正常编译的方式
List<Number> numbers = new ArrayList<>();
printNumbers(numbers);
List<Object> objects = new ArrayList<>();
printNumbers(objects);
// 无法通过编译的方式
List<Integer> ints = new ArrayList<>();
printNumbers(ints);
List<ZhuZi> zhuZis = new ArrayList<>();
printNumbers(zhuZis);
这里给出了四种调用printNumbers()
的方式,前面两种能正常编译通过,因为Number、Object
都符合下界约束。反观Integer、ZhuZi
类型,前者是Number
的子类型,后者与Number
两不相干,所以传入printNumbers()
方法就无法通过编译。
好了,看到上述效果后,有些人会疑惑,方法形参限制泛型的下限有啥用?想要弄明白还得来看方法内部:
public static void printNumbers(List<? super Number> numbers) {
// 能正常编译的代码
numbers.add(new Integer(1));
numbers.add(new Double("1.01"));
// 无法通过编译的代码
numbers.add(new Object());
}
与外部调用方法时完全相反,在printNumbers()
方法内部,就只能往numbers
里添加Number
类型及子类型的元素,当试图将Object
类型的元素加到集合时,反而会提示编译出错,Why
?因为对于方法内部来说,虽然外部可能会传入List<Object>
类型进来,但到底传不传编译器也不能确定,所以只能按保底的Number
来推测,Integer、Double
都属于Number
的子类,这时往List<Number>
类型的集合添加自然没有任何问题!
2.1.3、无界通配符(?)
泛型边界约束提供了额外的类型安全保证,并允许在代码中更灵活的使用泛型。不过要用哪种约束得取决于具体需求,比如你希望限制的类型范围、你想从泛型参数中读取还是写入数据等。
好了,在前面的案例中出现了一个?
,这是什么意思呢?未知通配符,也叫无界通配符,?
代表一个不确定的未知类型,通常用于泛型方法的形参定义、泛型的调用代码处,前者是指结合extends、super
关键字使用,如:
public static void printSize1(List<? extends Number> numbers) {
System.out.println(numbers.size());
}
public static void printSize2(List<? super Number> numbers) {
System.out.println(numbers.size());
}
那后面说的“泛型调用代码处”是啥意思?以之前提到的ServerResponse<T>
泛型类为例,该类作为接口统一返的结构体,其中的T
代表响应数据,可是当返回接口出错时,并不会产生响应数据怎么办?如下:
这样写虽然能编译通过,但IDE
一堆黄色的警告难免让强迫症患者看了头疼,怎么办?这时就可以使用无界通配符来解决:
public static ServerResponse<?> failed(String msg) {
ServerResponse<?> response = new ServerResponse<>();
response.setCode(ERROR_CODE);
response.setMsg(msg);
return response;
}
?
代表未知类型、任意类型,Java里什么能代表任意类型?有人可能会回答Object
!但很遗憾,Object
本身也是一种类型,只有null
才能代表任意类型!为此,ServerResponse<?>
这样定义,就说明response
对象的data
字段为空(这个场景其实ServerResponse<Void>
这样也行)。
除了上述场景外,还可以基于?
处理类型未知场景,如:
List<?> list;
list = new ArrayList<String>();
list = new ArrayList<Integer>();
正常情况声明List
对象,一旦将其泛型固定,对应变量就无法再重新赋值成其他泛型的集合,但使用?
来声明集合的泛型,上述list
变量则可以变更成任意泛型的集合对象。综上,?
通配符的第二种作用,就是针对处理这种类型未知或不关心具体类型的场景。
2.3、泛型擦除机制
在聊泛型擦除机制前,先来看一个案例:
List<String> strings = new ArrayList<>();
List<Integer> ints = new ArrayList<>();
System.out.println(strings.getClass() == ints.getClass());
这段代码是对比两个集合的Class
类型,按照主观思想来说,一个为字符串集合,一个为整数集合,两者类型自然不可能相等,可实际结果很打脸,strings、ints
的类型相等!为啥呢?因为Java中定义的泛型只存在于编译期间,经过编译器编译生成字节码后,代码中的泛型信息会被去掉,这种机制被称为泛型擦除。
运行期间并不保留泛型信息,所以案例中的strings、ints
对象。类型都为List
类型,输出的结果自然为true
。既然编译器抹除了泛型信息,可为啥代码还能正常运行呢?因为编译器会将泛型替换为原始类型,即替换成泛型的限定类型。而你代码中定义的任何一个泛型,都具备显式/隐式的限定类型。
2.3.1、验证类型擦除机制
刚刚说到,泛型擦除会干两件事,一是去掉定义的泛型(类型变量),二是将泛型替换为限定类型,现在来对这个结论进行验证:
public static void main(String[] args) {
// 复用之前案例中的ServerResponse来说明
ServerResponse<String> response = new ServerResponse<>();
response.setData("竹子爱熊猫");
Class<?> clazz = response.getClass();
System.out.println("response对象的类型为: " + clazz.getName());
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
// ServerResponse.data字段是使用泛型的字段,获取该字段的类型输出
if ("data".equals(field.getName())) {
System.out.println("data字段的类型为: " + field.getType().getName());
}
}
String data = response.getData();
System.out.println("data字段的值为: " + data);
}
/*
* 执行结果:
* response对象的类型为: com.zhuzi.generics.ServerResponse
* data字段的类型为: java.lang.Object
* data字段的值为: 竹子爱熊猫
* */
这个例子中,先定义了一个泛型为String
的response
对象,然后获取了该对象的类型,以及泛型字段data
的具体类型。尽管在定义response
对象时,将其声明成了ServerResponse<String>
类型,但从输出结果来看,尖括号里定义的泛型却被移除了……。最后,看到data
字段的类型,会发现它并不是预期中的String
,而是Object
类型,Why?
原因很简单,因为ServerResponse
类定义的泛型为T
,这里没有显式通过extends
关键字来限制类型,所以,T
代表无限定的类型变量,就会使用Object
来替换。好了,既然data
字段的类型会被替换成Object
,那为什么调用getData()
方法会返回String
呢?来看编译后的class
文件:
泛型擦除机制会将泛型替换成原始类型,为什么使用泛型的地方却依旧能获取到传入的类型,从上面反编译后的class
代码便能得知答案,因为编译器在擦除泛型信息的同时,还会将用到泛型的地方做隐式强转。
当然,前面定义的泛型T
,你也可以理解成T extends Object
,顺着这个思路往下捋:
public class ZhuZi<T extends Number> {
private T data;
}
请问这个例子中,ZhuZi.data
字段的类型会被替换成什么?答案显而易见,由于T
显式指定了限定类型为Number
,所以data
字段最终的类型就会是Number
,感兴趣可以自己验证下。
2.3.2、反射打破泛型编译校验
从上面得知,Java泛型机制只在编译期有效,在编译期间会对使用泛型的地方进行类型校验,比如你定义了一个List<Integer>
对象,往里面添加String
元素就会报错。可是到了运行期间,代码里的所有泛型信息会被擦除,意味着类型也不会再校验,那就意味着可以不走寻常路来打破泛型的类型约束。
// 正常创建一个整数型集合,并添加一个元素
List<Integer> list = new ArrayList<>();
list.add(123);
// 通过反射机制强行调用add()方法塞入字符串
Method add = list.getClass().getMethod("add", Object.class);
add.invoke(list, "竹子爱熊猫");
// 遍历list集合并输出所有元素
for (int i = 0; i < list.size(); i++) {
System.out.println(list.get(i));
}
/*
* 执行后的输出结果:
* 123
* 竹子爱熊猫
* */
观察上述代码,虽然正常情况下,泛型可以限制add()
方法添加的元素类型,可是当面对强大的反射机制时,会发现泛型失去了作用,我们仍然能够将字符串元素添加到list
集合。通过这个例子,能进一步佐证上阶段的观点:Java泛型会在运行时被擦除。
PS:虽然反射可以打破泛型约束,但最好不要这么做,如果外部在使用
forEach
循环遍历该集合,就会造成ClassCastException
异常,以上述案例来说,循环时预期对象是Integer
类型,结果突然冒出一个String
……
当然,这也是Java泛型为什么被叫做伪泛型的根本原因,因为Java泛型是在编译器层面实现的,到了运行期间并不会保留任何泛型信息。与之相对的则是C++
泛型,它是一种真正意义上的泛型,C++
通过模板实例化来实现泛型机制,编译器会根据每个模板参数生成对应的代码。这使得代码中的泛型信息可以完全保留,而并非局限于编译器有效。
三、泛型的细节与特殊问题
上面的内容对泛型使用及泛型擦除做了讲述,最后来讲一下使用泛型的一些细节与特殊情况,例如泛型支持可变形参:
public static <P> void print(P... params) {
for (P param : params) {
System.out.print(param);
}
}
// 调用时可以传递任意个参数
print("竹子", 2, "熊猫");
3.1、泛型不能指定基础类型
大家学习Java的前几节课,就学到了Java的八大基础数据类型,这也是日常编码使用最多的类型,可当你把泛型指定为基础数据类型时,就会发现编译无法通过:
ServerResponse<int> response = new ServerResponse<>();
为啥呢?跟Java泛型的实现原理有关,先来大体捋一下整个泛型的过程:
- 编码时在类上定义泛型参数,并在类中使用泛型声明字段属性;
- 使用泛型类、创建类实例对象时,通过
<>
指定具体类型; - 数据写入泛型字段时,编译器会检查值与指定类型是否匹配:
- 不匹配会提示语法错误,反之则会将给定值当作
Object
类型存储; - 当读取泛型字段时,编译器会隐式将
Object
强转为指定类型。
为什么泛型不支持基础数据类型,原因就在于最后两步,因为基础数据类型是原始类型,它们没有继承自Object
,也没有相应的方法和字段,所以无法强行转换成Object
:
double d1 = 8.8;
Object obj = (Object) d1;
double d2 = (double) obj;
这是double
原始类型和Object
类型相互强转的过程,这段代码其实现在能正常执行,毕竟Java有自动拆装箱机制。既然如此,泛型为啥不支持原始类型呢?因为自动拆装箱和泛型都是在Java5
引入的,泛型为了保持与旧版本Java代码的兼容性,自然不会允许传递原始类型,而必须要求传递原始类型的包装类型。
3.2、多泛型无法重载方法
指方法名相同、方法形参不同的情况被称为方法重载,而之前提到,泛型可以定义多个,那如果一个类定义了两个泛型,能否基于泛型实现方法重载呢?
public class GenericsTest<T1, T2> {
public void method(T1 t1) {}
public void method(T2 t2) {}
}
这个类定义了T1、T2
两个泛型,method()
分别使用不同的泛型进行重载,但这段代码无法编译通过,因为T1、T2
都没有限定类型,所以方法形参最终都会被转成Object
类型。这时,尽管两个方法使用不同泛型作为形参,但类型相同就无法满足方法重载的条件,除非其中某个泛型通过extends
关键字限定类型。
3.3、方法泛型高于类泛型
类和方法上都可以定义泛型,那么当类和方法的泛型名称重复时,会造成什么场景呢?
// 类上定义一个泛型T
public class GenericsTest<T> {
// 方法上又定义一个泛型T
public <T> List<T> newList() {
return new ArrayList<T>();
}
public static void main(String[] args) {
// 类泛型传入String
GenericsTest<String> test = new GenericsTest<>();
// 方法泛型传入Integer
List<Integer> list = test.<Integer>newList();
}
}
来看上述代码,类和方法各自定义了一个泛型T
,而后将T
作为newList()
方法的返回类型,这时看list
对象的类型:List<Integer>
,这代表啥意思?当泛型命名存在冲突时,方法定义的泛型(类型参数),优先级高于类定义的泛型。
3.4、泛型的类型不具备继承性
public static void print(List<Number> numbers) {
numbers.forEach(System.out::println);
}
这里定义了一个打印输出数值集合元素的方法,现在来调用一下:
public static void main(String[] args) {
List<Number> numbers = new ArrayList<>();
print(numbers);
List<Integer> ints = new ArrayList<>();
List<Double> doubles = new ArrayList<>();
print(ints);
print(doubles);
}
上面定义了三个集合,指定的泛型分别为Number、Integer、Double
,接着分别将numbers、ints、doubles
分别传入print()
方法,可结果很令人意外,将ints、doubles
传入print()
方法时编译无法通过,这是为什么?答案是为了保证数据安全,来看个例子:
List<Integer> ints = new ArrayList<>();
List<Number> numbers = ints; // 假设这是合法的
numbers.add(3.14); // 向List<Integer>中添加一个Double
Integer x = ints.get(0); // 运行时错误,因为实际上获取的是一个Double
如果泛型指定的类型支持继承,那么就会出现上述问题,因为Integer
是Number
的子类,所以ints
重新赋值给numbers
完全没问题,而Double
也是Number
的子类,往numbers
里面添加double
元素也合理。但要注意:Java中引用对象的赋值,并不是数据的深拷贝,而是指针的传递,为此,这里的numbers
本质还是ints
,最后直接从ints
里获取第一个元素,就会拿到前面塞入的3.14
,最终引发类型转换异常。
Java泛型为了避免上述问题,直接从源头打断了泛型指定类型的继承性。当然,虽然给定类型失去了继承性,但数据本身的继承性还在,如:
List<Number> numbers = new ArrayList<>();
numbers.add(1);
numbers.add(1.1);
numbers.add(1L);
上面定义了一个Number
类型的集合,这时往其中添加int、double、long……
这种数值元素都能正常编译。
3.5、泛型不能用于实例化
泛型赋予了Java语言动态性,那能否直接基于泛型去创建具体的对象呢?例如:
public class GenericsTest<E> {
private E element;
private E[] elements;
public E init() {
// 实例化一个泛型对象与泛型数组(编译无法通过)
element = new E();
elements = new E[16];
}
}
这样的想法很好,可是编译却无法通过,道理很简单,因为Java泛型只在编译期有效,而new
这个实例化对象的动作恰恰发生于运行期间,但这时泛型信息已经被移除了,所以没有办法实例化具体对象(包括数组)。
除此之外,如果你想创建一个泛型对象数组(专业叫法称为参数化泛型数组),这也是不行的,即:
// 编译无法通过
List<Number>[] lists = new ArrayList<>[10];
为什么语法糖不允许这么写,具体原因跟3.4
类似,Java数组是协变的,如果Integer
是Number
的子类型,那么Integer[]
也会是Number[]
的子类型,一旦允许创建泛型数组,又会导致类型安全性问题出现。
3.6、无法直接获取泛型的类型
泛型的本质是将类型参数化,而作为一个类型,理论上可以直接获取到它的class
,例如:
Class<Object> clazz = Object.class;
但由于泛型擦除机制的存在,我们并不能直接这样获取泛型的class
:
public class GenericsTest<T> {
// 无法通过编译
private Class<T> clazz = T.class;
}
所以,如果想要获取到使用泛型时传递的具体类型,只能基于数据去获取,或者外部显式传递进来,就像这样:
public class GenericsTest<T> {
private T data;
private Class<?> clazz;
// 基于泛型数据来获取class对象
public void initClass() {
if (null != this.data) {
clazz = this.data.getClass();
}
}
// 基于构造器让使用者显式传递
public GenericsTest(Class<?> clazz) {
this.clazz = clazz;
}
}
在实现某些逻辑需要拿到具体的类型时,只能通过这两种方式获取。同理,因为泛型信息会在运行期间移除,那判断类型时也无法判断泛型:
GenericsTest<String> test = new GenericsTest<>();
// 编译报错,因为运行期间没有泛型信息
if (test instanceof GenericsTest<String>) {}
如果需要判断一个泛型对象的类型,则只能使用无界通配符来描述,即test instanceof GenericsTest<?>
。
3.7、泛型封装通用方法实战
好了,前面讲了许多泛型的细节与特殊问题,最后来基于泛型封装一个常用、通用的方法,即Bean
拷贝场景,在日常编码设计中,都会将对象分为BO、VO、DTO、DO、PO……
各种模型,为了满足不同业务,数据会在这些对象之间流转。
可是挨个属性Get/Set
属实麻烦,在平时大家使用较多的就是Spring
提供的BeanUtils
这个工具类,但这个工具类用起来还是有点繁琐,比如:
User user = userMapper.selectById(userId);
UserVO userVO = new UserVO();
BeanUtils.copyProperties(user, userVO);
正如上述所示,每次都得手动new
出目标对象才行,而且BeanUtils
也没提供集合拷贝的方法,因此,我们就可以基于泛型封装两个通用方法:
/**
* Bean拷贝工具类
*/
public class BeanCopyUtil {
/*
* 拷贝单个Bean对象
* */
public static <T> T copy(Object source, Class<T> clazz) {
if (null == source) {
return null;
}
T target;
try {
target = clazz.newInstance();
} catch (InstantiationException | IllegalAccessException e) {
throw new RuntimeException("bean copy exception: " + e.getMessage());
}
BeanUtils.copyProperties(source, target);
return target;
}
/*
* 拷贝Bean对象集合
* */
public static <T> List<T> copyList(List<T> sourceList, Class<T> clazz) {
if (null == sourceList || 0 == sourceList.size()) {
return null;
}
List<T> targetList = new ArrayList<>();
for (T source : sourceList) {
T target = copy(source, clazz);
targetList.add(target);
}
return targetList;
}
}
基于这两个封装的方法,能特别方便的应对平时Bean
拷贝场景,用起来也格外简单:
// 拷贝单个Bean对象
User user = userMapper.selectById(userId);
UserVO result = BeanCopyUtil.copy(user, UserVO.class);
// 拷贝Bean对象集合
List<User> users = userMapper.selectList();
List<UserVO> results = BeanCopyUtil.copyList(users, UserVO.class);
四、Java泛型机制总结
OK,前面的内容,从一开始的泛型基本知识,聊到了泛型边界约束、泛型擦除机制,以及到后面的泛型使用细节与常见问题,本篇基本对Java泛型有了全面覆盖,无论你之前是否真正掌握了泛型,相信认真读完所有内容后,一定对泛型有了更深层次的理解。
泛型作为Java语言非常重要的特性,它能极大地增强语言表达能力,也能避免封装通用时,类型强制转换带来的繁琐步骤,还能提高代码安全性、灵活性与复用性。不过本文只在最后给出了一个泛型实战案例,在日常编码中,泛型有更为广泛的使用场景,比如与Java8
的新特性结合等等,而这些则需要诸位在日常工作中慢慢探索啦~