关于泛型,有一道经典的考题:
public static void main(String[] args) {
List<String> list1= new ArrayList<String>();
List<Integer> list2= new ArrayList<Integer>();
System.out.println(list1.getClass() == list2.getClass());
}
- 1.
- 2.
- 3.
- 4.
- 5.
请问上面代码的输出结果是什么?
如果是了解泛型的同学会很容易答出:true,如果是不了解泛型的同学则很可能会答错。今天就和大家一起来重温一下Java泛型相关的知识。
一、什么是泛型?
泛型(generics)是 JDK 5 中引入的一个新特性, 泛型提供了编译时类型安全检测机制,该机制允许程序员在编译时检测到非法的类型。泛型的本质是参数化类型,也就是说所操作的数据类型被指定为一个参数。具有以下特点:
- 与普通的 Object 代替一切类型这样简单粗暴而言,泛型使得数据的类别可以像参数一样由外部传递进来。它提供了一种扩展能力。它更符合面向抽象开发的软件编程宗旨。
- 当具体的类型确定后,泛型又提供了一种类型检测的机制,只有相匹配的数据才能正常的赋值,否则编译器就不通过。所以说,它是一种类型安全检测机制,一定程度上提高了软件的安全性防止出现低级的失误。
- 泛型提高了程序代码的可读性,不必要等到运行的时候才去强制转换,在定义或者实例化阶段,因为 Cache
这个类型显化的效果,程序员能够一目了然猜测出代码要操作的数据类型。
泛型按照使用情况可以分为3种:泛型类、泛型方法、泛型接口。
1.泛型类
我们可以定义如下一个泛型类
/**
* @author machongjia
* @date 2021/12/28 20:02
* @description
*/
public class Generic<T> {
private T var;
public Generic(T var) {
this.var = var;
}
public T getVar() {
return var;
}
public static void main(String[] args) {
Generic<Integer> i = new Generic<Integer>(1000);
Generic<String> s = new Generic<String>("hello");
System.out.println(i.getVar());
System.out.println(s.getVar());
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
输出结果:
1000
hello
- 1.
- 2.
常用的类似于T这样的类型参数包括:
T:代表一般的任何类
E:代表 Element 的意思,或者 Exception 异常的意思
K:代表 Key 的意思。
V:代表 Value 的意思,通常与 K 一起配合使用
S:代表 Subtype 的意思
泛型类可以不止接受一个参数T,还可以接受多个参数,类似于下面这种:
public class Generic<E,T> {
private E var1;
private T var2;
public Generic(E var1, T var2) {
this.var1 = var1;
this.var2 = var2;
}
public static void main(String[] args) {
Generic<Integer,String> generic = new Generic<Integer,String>(1000,"hello");
System.out.println(generic.var1);
System.out.println(generic.var2);
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
2.泛型方法
public class Generic {
public <T> void testMethod(T t){
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
泛型方法与泛型类稍有不同的地方是,类型参数也就是尖括号那一部分是写在返回值前面的。
当然,声明的类型参数,其实也是可以当作返回值的类型的。
泛型类与泛型方法共存的情况:
public class Generic<T> {
public void testMethod(T t){
System.out.println(t.getClass().getName());
}
public <T> T testMethod1(T t){
return t;
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
上面代码中,Test1
3.泛型接口
泛型接口与泛型类的定义及使用基本相同。泛型接口常被用在各种类的生产器中,可以看一个例子:
//定义一个泛型接口
public interface Generator<T> {
public T next();
}
- 1.
- 2.
- 3.
- 4.
当实现泛型接口的类,未传入泛型实参时:
/**
* 未传入泛型实参时,与泛型类的定义相同,在声明类的时候,需将泛型的声明也一起加到类中
* 即:class FruitGenerator<T> implements Generator<T>{
* 如果不声明泛型,如:class FruitGenerator implements Generator<T>,编译器会报错:"Unknown class"
*/
class FruitGenerator<T> implements Generator<T>{
@Override
public T next() {
return null;
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
当实现泛型接口的类,传入泛型实参时:
/**
* 传入泛型实参时,定义一个生产器实现这个接口,虽然我们只创建了一个泛型接口Generator<T>
* 但是我们可以为T传入无数个实参,形成无数种类型的Generator接口。
* 在实现类实现泛型接口时,如已将泛型类型传入实参类型,则所有使用泛型的地方都要替换成传入的实参类型
* 即:Generator<T>,public T next();中的的T都要替换成传入的String类型。
*/
public class FruitGenerator implements Generator<String> {
private String[] fruits = new String[]{"Apple", "Banana", "Pear"};
@Override
public String next() {
Random rand = new Random();
return fruits[rand.nextInt(3)];
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
4.通配符?
通配符的出现是为了指定泛型中的类型范围,包含以下3 种形式。
- <?>被称作无限定的通配符。
- <? extends T>被称作有上限的通配符。
- <? super T>被称作有下限的通配符。
无限定通配符<?>
无限定通配符经常与容器类配合使用,它其中的 ? 其实代表的是未知类型,所以涉及到 ? 时的操作,一定与具体类型无关。
public void testWildCards(Collection<?> collection){
}
- 1.
- 2.
上面的代码中,方法内的参数是被无限定通配符修饰的 Collection 对象,它隐略地表达了一个意图或者可以说是限定,那就是 testWidlCards() 这个方法内部无需关注 Collection 中的真实类型,因为它是未知的。所以,你只能调用 Collection 中与类型无关的方法。
和相对应,前者?代表类型T及T的子类,后者?代表T及T的超类。
值得注意的是,如果用泛型方法来取代通配符,那么上面代码中 collection 是能够进行写操作的。只不过要进行强制转换。
二、什么是泛型的类型擦除?
Java泛型这个特性是从JDK 1.5才开始加入的,因此为了兼容之前的版本,Java泛型的实现采取了“伪泛型”的策略,即Java在语法上支持泛型,但是在编译阶段会进行所谓的“类型擦除”(Type Erasure),将所有的泛型表示(尖括号中的内容)都替换为具体的类型(其对应的原生态类型),就像完全没有泛型一样。理解类型擦除对于用好泛型是很有帮助的,尤其是一些看起来“疑难杂症”的问题,弄明白了类型擦除也就迎刃而解了。
- 泛型的类型擦除原则是:
- 消除类型参数声明,即删除<>及其包围的部分。
- 根据类型参数的上下界推断并替换所有的类型参数为原生态类型:如果类型参数是无限制通配符或没有上下界限定则替换为Object,如果存在上下界限定则根据子类替换原则取类型参数的最左边限定类型(即父类)。
- 为了保证类型安全,必要时插入强制类型转换代码。
- 自动产生“桥接方法”以保证擦除类型后的代码仍然具有泛型的“多态性”。
1.类型擦除做了什么?
上面我们说了,编译完成后会对泛型进行类型擦除,如果想要眼见为实,实际看一下的话应该怎么办呢?那么就需要对编译后的字节码文件进行反编译了,这里使用一个轻量级的小工具Jad来进行反编译,Jad的使用也很简单,下载解压后,把需要反编译的字节码文件放在目录下,然后在命令行里执行下面的命令就可以在同目录下生成反编译后的.java文件了:
jad -sjava Test.class
- 1.
好了,工具准备好了,下面我们就看一下不同情况下的类型擦除。
无限制类型擦除
当类定义中的类型参数没有任何限制时,在类型擦除后,会被直接替换为Object。在下面的例子中,
有限制类型擦除
当类定义中的类型参数存在限制时,在类型擦除中替换为类型参数的上界或者下界。下面的代码中,经过擦除后T被替换成了Integer:
擦除方法中的类型参数
比较下面两边的代码,可以看到在擦除方法中的类型参数时,和擦除类定义中的类型参数一致,无限制时直接擦除为Object,有限制时则会被擦除为上界或下界:
2.类型擦除带来了哪些局限性?
类型擦除,是泛型能够与之前的 java 版本代码兼容共存的原因。但也因为类型擦除,它会抹掉很多继承相关的特性,这是它带来的局限性。
理解类型擦除有利于我们绕过开发当中可能遇到的雷区,同样理解类型擦除也能让我们绕过泛型本身的一些限制。比如
正常情况下,因为泛型的限制,编译器不让最后一行代码编译通过,因为类似不匹配,但是,基于对类型擦除的了解,利用反射,我们可以绕过这个限制。
public interface List<E> extends Collection<E>{
boolean add(E e);
}
- 1.
- 2.
- 3.
- 4.
上面是 List 和其中的 add() 方法的源码定义。
因为 E 代表任意的类型,所以类型擦除时,add 方法其实等同于:
boolean add(Object obj);
- 1.
那么,利用反射,我们绕过编译器去调用 add 方法。
public class ToolTest {
public static void main(String[] args) {
List<Integer> ls = new ArrayList<>();
ls.add(23);
// ls.add("text");
try {
Method method = ls.getClass().getDeclaredMethod("add",Object.class);
method.invoke(ls,"test");
method.invoke(ls,42.9f);
} catch (NoSuchMethodException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (SecurityException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IllegalAccessException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IllegalArgumentException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (InvocationTargetException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
for ( Object o: ls){
System.out.println(o);
}
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
打印结果是:
23
test
42.9
1
2
3
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
可以看到,利用类型擦除的原理,用反射的手段就绕过了正常开发中编译器不允许的操作限制。