认识java的泛型机制
泛型机制(Generics)是一种编程语言特性。允许在编写代码时使用参数化类型。它允许开发者在设计类、接口和方法时使用类型参数,这些类型参数可以在使用时被实际的类型替换。泛型机制的主要目的是增加代码的灵活性、可重用性和类型安全性。
我们通过一个简单的例子认识java的泛型以及感受一下泛型的好处:
假设我们需要实现一个加法功能,支持多种数据类型进行相加。
我们可以使用重载写多个add方法:
1 | private static int add(int a, int b) { |
这样做每种类型都需要重载一个add方法;而通过泛型,我们可以复用为一个方法:
1 | //这里定义了一个泛型方法,返回值类型为double,方法接收的参数类型为Number类型及其子类。 |
再看这个例子,向一个集合dogs中添加3个Dog对象:
1 | class Dog { |
这样写存在什么问题呢?
- 需要手动进行类型转换:因为我们在声明List的时候并没有指定集合当中元素的类型,ArrayList只是维护了一个Object引用的数组。我们接收这个对象就需要进行一次类型转换:
Dog dog1 = (Dog)dogs.get(0);
进行强制类型转换效率较低,并且可能会抛出类转换异常ClassCastException
。而这个异常我们无法在编译中发现,只能在运行时才能发现,存在安全隐患。 - 不能对集合中元素的类型进行约束:在我们的需求中是往dogs中添加Dog对象。但是如果我向集合中添加其他类型的元素编译时却不会有任何错误提示:
dogs.add(new Cat("阿猫"))
,而是在运行时我们取到这个Cat类型的元素并使用(Dog)
进行转换时抛出来ClassCastException
。
这显然不是我们所期望的,如果程序有潜在的错误,我们更期望在编译时被告知错误,而不是在运行时报异常。
使用泛型解决这个问题:
1 | public static void main(String[] args) { |
在面向对象编程语言中,多态算是一种泛化机制。例如,你可以将方法的参数类型设置为基类,那么该方法就可以接受从这个基类中导出的任何类作为参数,这样的方法将会更具有通用性。此外,如果将方法参数声明为接口,将会更加灵活。
通过这两个例子,我们可以很好理解为什么使用泛型能增强代码的复用性和类型安全性。接下来我们进一步认识java当中泛型的使用方式。
泛型基本使用
泛型很好地增强了代码的灵活性和通用性。在泛型使用过程中,操作的数据类型被指定为一个参数,这种参数类型可以用在类、接口和方法中,分别被称为泛型类、泛型接口、泛型方法。
泛型类
将泛型使用在类上就称为泛型类。定义泛型类:
1 | class Point<T> { |
使用泛型类:在创建泛型类对象的时候指定具体类型
1 | public class Demo{ |
创建泛型类对象的时候还可以这样写:
Point<String> point = new Point<String>();
或者Point point = new Point<String>();
,但是一般的写法是在声明引用类型时(左边)指定泛型。具体原因学习完泛型的原理后就能理解。
也可以在类中用不同的泛型标识符指定多个泛型:
1 | class MyEntity<K,V> { |
泛型接口
当将泛型使用在接口上的时候就成为泛型接口,使用方法与泛型类相同。看一个例子:
1 | interface Info<T>{ // 在接口上定义泛型 |
泛型方法
泛型方法则是在调用的时候才指定具体的类型。可以在普通类中定义泛型方法,也可以在泛型类中定义。
语法格式是在方法签名的返回值类型之前指定泛型。举一个栗子:
1 | public class Test { |
在使用泛型方法的时候需要注意,在泛型类或者接口中带有泛型标识符方法并不一定是泛型方法。比如:
public void eat(E e){...}
这个方法并不是泛型方法,只是eat方法使用了泛型。
泛型的通配符与上下限
在我们阅读java代码的时候会遇到这种泛型:
1 | default void sort(Comparator<? super E> c) {……} |
在这里sort方法接收一个Comparator类型的参数c,而Comparator(比较器)是一个泛型接口,我们给比较器指定了泛型<? super E>
,表示Comparator可以接受E以及E的父类。这样做的好处是可以更灵活地使用比较器。这也是java泛型中的一个重要的知识点,泛型的上下限。我们来看一个例子:
1 | class A{} |
泛型并不具备“继承性”,比如:ArrayList<Object> obj = new ArrayList<String>();
❎ ,例子中func接收的List的泛型A,而传入函数的参数是List<B>
,虽然A与B有继承关系,但是泛型却不允许这样转换(为什么?可以留着这个问题,了解了泛型的原理之后再来思考)。这也是上个例子报错的原因。
但是程序确实需要这样的需求,比如在一个集合中添加某个类或者该类的实现类(举一个具体的例子,向一个List animal
中添加元素,可以是猫,狗,鸟…继承了Animal的类的对象)。直接使用泛型就会存在问题。
为了解决泛型中隐含的转换问题,Java泛型加入了类型参数的上下边界机制。<? extends A>
表示该类型参数可以是A(上边界)或者A的子类类型。编译时擦除到类型A,即用A类型代替类型参数。这种方法可以解决开始遇到的问题,编译器知道类型参数的范围,如果传入的实例类型B是在这个范围内的话允许转换,这时只要一次类型转换就可以了,运行时会把对象当做A的实例看待。
1 | public static void funC(List<? extends A> listA) { |
在使用泛型的时候,我们可以为传入的泛型类型实参进行上下边界的限制,如:类型实参只准传入某种类型的父类或某种类型的子类。
上限: <T extends Number>
1 | class Info<T extends Number>{ // 此处泛型可以是Number或者继承了Number的类,比如Integer,Double... |
下限: <? super String>
1 | class Info<T>{ |
如果对类型的上界或者下界有多个限制,可以使用 &
:
1 | public class Client { |
至此我们已经基本了解了在java中使用泛型的基本语法。接下来我们继续探究java泛型的实现原理以及使用的细节。
泛型的原理与使用细节
java的伪泛型 :类型擦除
java的泛型策略实际上是一种伪泛型。即在语法上支持泛型,但在编译阶段会将所有的泛型(尖括号括起来的内容)都还原成原始类型(Row Type)。比如List<Integer> list
在编译之后就变成:List list
。这也是类型擦除(type erasure)的含义。
为什么要使用类型擦除来实现泛型呢?这样实现有什么好处?
- 泛型机制是在jdk5引入的,为了兼容以前的版本,才采取了这种策略。这样使用新版的jdk写的程序编译之后与以前的代码是兼容的,就不需要重构旧的代码。
- 减轻 JVM 的负担,提高运行期的效率。如果 JVM 将泛型类型延续到运行期,那么到运行期时 JVM 就需要进行大量的重构工作。
接下来我们来看几个具体的类型擦除的例子:
假设我们重载了这两个方法,编译器却报错:
Erasure of method f1(List<String>) is the same as another method in type TypeEarse
即类型擦除后两个函数的签名是一致的,形参都变成 : List p1
这是两个一样的函数。
1 | public class Test { |
再考虑一下下面的程序:
1 | public class Test { |
程序中声明了一个存储Integer的ArrayList,直接调用add()方法只能存储整型变量。添加其他类型的元素: add("cjp")
会报错 :
The method add(Integer) in the type ArrayList<Integer> is not applicable for the arguments (String)
。而我们如果使用反射就可以在运行过程中向集合中添加字符型类型的元素。这表明了在编译期间,ArrayList<Integer> list
的类型是被擦除了的,还原成了原始类型:ArrayList
,里面维护的Object数组,我们可以正常添加任何类型的元素。
那么类型擦除是如何进行的?
类型擦除的原则:
消除类型参数声明,即删除
<>
及其包围的部分。根据类型参数的上下界推断并替换所有的类型参数为原生态类型:如果类型参数是无限制通配符或没有上下界限定则替换为Object,如果存在上下界限定则根据子类替换原则取类型参数的最左边限定类型(即父类)。
为了保证类型安全,必要时插入强制类型转换代码。
自动产生“桥接方法”以保证擦除类型后的代码仍然具有泛型的“多态性”。
例子:
擦除类定义中的类型参数 - 无限制类型擦除
当类定义中的类型参数没有任何限制时,在类型擦除中直接被替换为Object,即形如
<T>
和<?>
的类型参数都被替换为Object。擦除类定义中的类型参数 - 有限制类型擦除
当类定义中的类型参数存在限制(上下界)时,在类型擦除中替换为类型参数的上界或者下界,比如形如
<T extends Number>
和<? extends Number>
的类型参数被替换为Number
,<? super Number>
被替换为Object。(Number的父类为Object)擦除方法定义中的类型参数
擦除方法定义中的类型参数原则和擦除类定义中的类型参数是一样的,这里仅以擦除方法定义中的有限制类型参数为例。
泛型的编译期检查
我们前面已经知道了泛型会在编译阶段被擦除成原生类型。那么为什么当我们往List<Integer> list
这个集合中添加其它类型的元素的时候编译器会报错呢?编译期间不是都变成了Object了吗,我添加String为什么还会报错?
1 | public static void main(String[] args) { |
因为编译器会在编译之前先进行对泛型类型的检查,再进行类型擦除。那么这个检查是如何进行的呢?这个类型检查是针对谁的呢?我们先看看参数化类型和原始类型的兼容。
以 ArrayList举例子,以前的写法:
1 | ArrayList list = new ArrayList(); |
现在的写法:
1 | ArrayList<String> list = new ArrayList<String>(); |
如果是与以前的代码兼容,各种引用传值之间,必然会出现如下的情况:
1 | ArrayList<String> list1 = new ArrayList(); //第一种 情况 |
这样是没有错误的,不过会有个编译时警 : ArrayList is a raw type. References to generic type ArrayList<E> should be parameterized
。在第一种情况,可以实现与完全使用泛型参数一样的效果,第二种则没有效果。
因为类型检查就是编译时完成的,new ArrayList()只是在内存中开辟了一个存储空间,可以存储任何类型对象,而真正涉及类型检查的是它的引用,因为我们是使用它引用list1来调用它的方法,比如说调用add方法,所以list1引用能完成泛型类型的检查。而引用list2没有使用泛型,所以不行。
举例子:
1 | public class Test { |
通过上面的例子,我们可以明白,类型检查就是针对引用的,谁是一个引用,用这个引用调用泛型方法,就会对这个引用调用的方法进行类型检测,而无关它真正引用的对象。
泛型的多态与桥接方法
类型擦除会造成多态的冲突。例子:
1 | class Holder<T> { |
在这里我们定义了一个泛型接口,定义一个子类实现泛型接口,重写了接口的两个方法。我们的原意是这样的:将父类的泛型类型限定为Date,那么父类里面的两个方法的泛型参数<T>
都为Date类型。所以,我们在子类中重写这两个方法一点问题也没有,实际上,从他们的@Override
标签中也可以看到,一点问题也没有,实际上是这样的吗?
分析:实际上,类型擦除后,父类的的泛型类型全部变为了原始类型Object,所以父类接口编译之后会变成下面的样子:
1 | class Holder { |
而实现类重写的方法:
1 | public Date getValue() { |
在setValue方法中,父类方法与子类的形参的类型不同,这不是重写,而是重载。假如是重载,子类会有两个重载的setValue方法,我们测试一下:
1 | public class TestPolymorphic { |
说明实现类中并没有形参为?Object的setValue()方法。这并不是重载,而是真的重写了父类的方法。由于种种原因,虚拟机并不能将泛型类型变为Date,只能将类型擦除掉,变为原始类型Object。这样,我们的本意是进行重写,实现多态。可是类型擦除后,只能变为了重载。这样,类型擦除就和多态有了冲突。
于是JVM采用了一个特殊的方法,来解决泛型中多态的冲突,那就是桥接方法。
我们使用javap -c
对DateHolder进行反汇编:
1 | javap -c DateHolder |
从反编译的结果看,子类有4方法,其中最后的两个方法,就是编译器自己生成的桥方法。可以看到桥方法的参数类型都是Object,也就是说,子类中真正覆盖父类两个方法的就是这两个我们看不到的桥方法。而打在我们自己定义的setvalue和getValue方法上面的@Override只不过是假象。而桥方法的内部实现,就只是去调用我们自己重写的那两个方法。
所以,虚拟机巧妙的使用了桥方法,来解决了类型擦除和多态的冲突。
并且,还有一点也许会有疑问,子类中的桥方法Object getValue()
和Date getValue()
是同时存在的,可是如果是常规的两个方法,他们的方法签名是一样的,也就是说虚拟机根本不能分别这两个方法。如果是我们自己编写Java代码,这样的代码是无法通过编译器的检查的,但是虚拟机却是允许这样做的,因为虚拟机通过参数类型和返回类型来确定一个方法,所以编译器为了实现泛型的多态允许自己做这个看起来“不合法”的事情,然后交给虚拟器去区别。
泛型疑难杂症Q&A
如何理解类型擦除之后的原始类型?
原始类型 就是擦除去了泛型信息,最后在字节码中的类型变量的真正类型,无论何时定义一个泛型,相应的原始类型都会被自动提供,类型变量擦除,并使用其限定类型(无限定的变量用Object)替换。类型擦除之后代码是与没有泛型机制之前一样的(即使用Object类型来接收其它任意类型的变量)。
以
List<String>
,List<Integer>
为例,它们的原始类型都是List。在你声明泛型的时候,就可以根据类型类型擦除的原则确定该泛型的原始类型。在声明泛型类的对象或者调用泛型方法时,不指定泛型编译也可以通过,这时候的对象中的相关成员类型是什么?
注意区分一下泛型变量的类型和原始类型的概念。泛型变量的类型是指编译(类型擦除)之前,进行语法检查时使用的类型。原始类型是指泛型变量擦除去了泛型信息,最后在字节码中的类型变量的真正类型。
在泛型类或者泛型接口中,使用泛型类或者接口创建对象时不指定泛型,默认泛型类型为Object。
比如
1
2
3
4
5public static void main(String[] args) {
ArrayList arrayList = new ArrayList(); //创建ArrayList不指定泛型,则默认类型为Object
arrayList.add(1); //可以存储任何类型的变量
arrayList.add("cjp");
}在调用泛型方法时,可以指定泛型,也可以不指定泛型:
- 在不指定泛型的情况下,泛型变量的类型为该方法中的几种类型的同一父类的最小级,直到Object
- 在指定泛型的情况下,该方法的几种类型必须是该泛型的实例的类型或者其子类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19public class Test {
public static void main(String[] args) {
/**不指定泛型的时候*/
int i = Test.add(1, 2); //这两个参数都是Integer,所以T为Integer类型
Number f = Test.add(1, 1.2); //这两个参数一个是Integer,一个是Float,所以取同一父类的最小级,为Number
Object o = Test.add(1, "asd"); //这两个参数一个是Integer,一个是String,所以取同一父类的最小级,为Object
/**指定泛型的时候*/
int a = Test.<Integer>add(1, 2); //指定了Integer,所以只能为Integer类型或者其子类
int b = Test.<Integer>add(1, 2.2); //编译错误,指定了Integer,不能为Float
Number c = Test.<Number>add(1, 2.2); //指定为Number,所以可以为Integer和Float
}
//这是一个简单的泛型方法
public static <T> T add(T x,T y){
return y;
}
}泛型为什么没有继承的传递关系?
就比如前面举的例子
ArrayList<Object> obj = new ArrayList<String>();
❎在Java中,泛型是不支持协变的。这意味着即使
ArrayList<String>
是ArrayList<Object>
的子类型,ArrayList<String>
也不能赋值给ArrayList<Object>
。我们假设java允许这么做,再来看看这样会存在哪些问题。//TODO : 待理解
来自GPT的回答:
如果 Java 允许
ArrayList<String>
赋值给ArrayList<Object>
,那么可能会导致以下问题:类型安全性问题: Java 的泛型是在编译时进行类型检查的,而不是在运行时。如果允许将
ArrayList<String>
赋值给ArrayList<Object>
,则编译器无法在编译时捕获到潜在的类型错误。例如,当您尝试将一个Integer
对象添加到ArrayList<String>
中时,编译器不会报错,但在运行时会抛出ClassCastException
。破坏集合的封装性: 泛型的目的之一是增强代码的可读性和安全性,通过明确指定集合中元素的类型,可以更清晰地了解代码的意图。如果允许将
ArrayList<String>
赋值给ArrayList<Object>
,则破坏了集合的封装性,可能会导致代码更加难以理解和维护。破坏泛型的不变性: 泛型具有不变性,即泛型类型参数在赋值后不能被修改。如果允许将
ArrayList<String>
赋值给ArrayList<Object>
,则破坏了泛型的不变性,可能导致在编译时无法检测到潜在的类型不一致问题,从而引入安全隐患。
综上所述,虽然看起来允许
ArrayList<String>
赋值给ArrayList<Object>
可以提供一定的便利性,但这种做法会破坏泛型的安全性和封装性,可能引入类型错误和安全隐患。因此,Java 不允许这种类型的赋值。为什么泛型类型不能实例化?
T obj = new T()
❎因为在 Java 编译期没法确定泛型参数化类型,也就找不到对应的类字节码文件,所以自然就不行了,此外由于
T
被擦除为Object
,如果可以new T()
则就变成了new Object()
,失去了本意。 如果我们确实需要实例化一个泛型,应该如何做呢?可以通过反射实现:1
2
3
4static <T> T newTclass (Class < T > clazz) throws InstantiationException, IllegalAccessException {
T obj = clazz.newInstance();
return obj;
}List<?>、List<Object>、List<? extends Object>
有什么区别?泛型类型不同,
List<?>
使用<?>
通配符,接收任意类型;List<? extends Object>
的泛型参数允许接收Object及其子类。为什么泛型不能支持基本数据类型?
就像这个例子:
List<Integer> list = new ArrayList<>()
✅List<int> list = new ArrayList<>()
❎因为对泛型进行类型擦除之后成员类型是Object,而Object无法接收基本数据类型的变量:
int a = 10; Object obj = a;❎
声明和使用泛型数组存在哪些问题?
不能使用new创建泛型数组:
private T[] array = new T[10];
❎Cannot create a generic array of T
。使用new
创建数组是在内存开辟一块指定大小的内存,使用泛型无法确定开辟的内存大小。类型擦除后类型为Object[ ] , 为什么不直接规定这样创建就是开辟一个指定大小的Object数组呢?
这样规定就失去了泛型的意义。使用泛型是为了限定类型,如果规定
private T[] array = new T[10]
在编译之前也等价于private Object[] array = new Object[10]
,那么还是不能限定数组内的元素预期的类型,取元素的时候要进行类型转换。直接规定不允许这样使用才合理。看一下的例子:
1
2
3
4
5
6List<String>[] list11 = new ArrayList<String>[10]; //编译错误 Cannot create a generic array of ArrayList<String>,非法创建
List<String>[] list12 = new ArrayList<?>[10]; //编译错误 Type mismatch: cannot convert from ArrayList<?>[] to List<String>[],需要强转类型
List<String>[] list13 = (List<String>[]) new ArrayList<?>[10]; //OK,但是会有警告 Type safety: Unchecked cast from ArrayList<?>[] to List<String>[]
List<?>[] list14 = new ArrayList<String>[10]; //编译错误 Cannot create a generic array of ArrayList<String>,非法创建
List<?>[] list15 = new ArrayList<?>[10]; //OK
List<String>[] list6 = new ArrayList[10]; //OK,但是会有警告 Type safety: The expression of type ArrayList[] needs unchecked conversion to conform to List<String>[]//TODO : 理解为什么
静态方法中使用泛型会有什么问题?
泛型类中的静态方法和静态变量不可以使用泛型类所声明的泛型类型参数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public class UseGenericsInStatic<T> {
private T value;
private static T staticValuie; //报错:Cannot make a static reference to the non-static type T
//静态泛型方法
private static<E>Double test(E e) {
System.out.println("test , para: " + e);
return 66.6;
}
private static Double test1(T t) { //报错:Cannot make a static reference to the non-static type T
System.out.println("test1 , para: " + t);
}
}泛型类在创建对象的时候指定泛型的类型,而静态成员和静态方法在类加载(对象创建之前)的时候就加载完。在静态方法或者静态变量中使用泛型无法确定这个泛型参数是何种类型。(还是不要那样想:为啥擦除后是Object,不直接规定Object。这样就没必要存在泛型了!)
参考文章:
https://pdai.tech/md/java/basic/java-basic-x-generic.html