java泛型
泛型程序设计
什么是泛型程序设计?
泛型程序设计(generic programming)是程序设计语言的一种风格或范式。泛型允许程序员在强类型程序设计语言中编写代码时使用一些以后才指定的类型,在实例化时作为参数指明这些类型。各种程序设计语言和其编译器、运行环境对泛型的支持均不一样。Ada、Delphi、Eiffel、Java、C#、F#、Swift 和 Visual Basic .NET 称之为泛型(generics);ML、Scala 和 Haskell 称之为参数多态(parametric polymorphism);C++ 和 D称之为模板。具有广泛影响的1994年版的《Design Patterns》一书称之为参数化类型(parameterized type)。
为什么要使用泛型程序设计?
泛型编程可以实现通用算法
在有泛型类之前,泛型程序设计是用继承实现的。程序员必须使用
Object
编写使用于多种类型的代码,这很繁琐,也很不安全。通过使用泛型,程序员可以实现通用算法,这些算法可以处理不同类型的集合,可以自定义,并且类型安全且易于阅读。泛型程序设计意味着编写的代码可以对多种不同类型的对象重用。
编译时的强类型检查
泛型要求在声明时指定实际数据类型,Java 编译器在编译时会对泛型代码做强类型检查,并在代码违反类型安全时发出告警。早发现,早治理,把隐患扼杀于摇篮,在编译时发现并修复错误所付出的代价远比在运行时小。
1
2
3ArrayList<String> files = new ArrayList<>();
files.add("...") //true
files.add(1) //can't避免了类型转换
未使用泛型:
1
2
3List list = new ArrayList();
list.add("hello");
String s = (String) list.get(0);使用泛型:
1
2
3List<String> list = new ArrayList<String>();
list.add("hello");
String s = list.get(0); // no cast
定义简单泛型
泛型类
泛型类就是有一个或多个类型变量的类。
在泛型出现之前,如果一个类想持有一个可以为任意类型的数据,只能使用 Object
做类型转换。
- 一个类型参数
1 | public class Pair<T>{ |
这个类中引入了一个类型变量T,用尖括号<>括起来,放在类名的后面。
- 泛型类可以有很多不同类型的变量
例如我们定义的Pair类,第一个字段和第二个字段可以使用不同类型
1 | public class Pair<T, U>{ |
常见的做法是类型变量使用大写字母,而且很简短。
java库中使用变量E表示集合的元素类型,
K和V分别表示表的键和值的类型。
T(U、S)表示“任意类型”。
- 泛型类可以类型嵌套
1 | public class Demo { |
可以用具体的类型替换类型变量来实例化泛型类型。
如:
1 | public class Demo { |
可以把实例化的结果想象成一个普通类。
泛型类相当于普通类的工厂。
泛型接口
接口也可以声明泛型。
泛型接口语法形式:
1 | public interface Content<T> { |
泛型接口有两种实现方式:
- 实现接口的子类明确声明泛型类型
1 | public class GenericsInterfaceDemo01 implements Content<Integer> { |
- 实现接口的子类不明确声明泛型类型
1 | public class GenericsInterfaceDemo02<T> implements Content<T> { |
泛型方法
还可以定义带有类型参数的方法。
泛型方法可以在泛型类或普通类中定义。
类型变量放在修饰符后面,返回类型前面
1 | class ArrayAlg{ |
变量类型的限定
有时,类或方法需要对类型变量加以约束。
1 | class ArrayAlg{ |
这里有个问题:变量smallest的类型为T,意味着它可以使任何一个类的对象,我们并不能保证T所属的类有一个compareTo方法。
解决方法是限制T只能是实现了Comparable接口的类
1 | public static <T extends Comparable> T min(T[] a) ... |
一个类型变量或通配符可以有多个限定
T extends Comparable & Serializable
限定类型用&分隔。可以根据需要拥有多个接口超类型,但最多有一个限定可以是类,且该类必须是限定列表中的第一个限定。
泛型代码和虚拟机
类型擦除
Java 语言引入泛型是为了在编译时提供更严格的类型检查,并支持泛型编程。不同于 C++ 的模板机制,Java 泛型是使用类型擦除来实现的,使用泛型时,任何具体的类型信息都被擦除了。
那么,类型擦除做了什么呢?它做了以下工作:
- 把泛型中的所有类型参数替换为 Object,如果指定类型边界,则使用类型边界来替换。因此,生成的字节码仅包含普通的类,接口和方法。
- 擦除出现的类型声明,即去掉
<>
的内容。比如T get()
方法声明就变成了Object get()
;List<String>
就变成了List
。如有必要,插入类型转换以保持类型安全。 - 生成桥接方法以保留扩展泛型类型中的多态性。类型擦除确保不为参数化类型创建新类;因此,泛型不会产生运行时开销。
类型变量未给出限定时
让我们来看一个示例:
1 | public class GenericsErasureTypeDemo { |
示例说明:
上面的例子中,虽然指定了不同的类型参数,但是 list1 和 list2 的类信息却是一样的。
这是因为:使用泛型时,任何具体的类型信息都被擦除了。用Object代替了类型变量T。这意味着:
ArrayList<Object>
和ArrayList<String>
在运行时,JVM 将它们视为同一类型。在list2中我们不能直接填入其他类型的数据,但是可以通过反射强行添加。
类型变量给出了限定
如:
1 | public class Interval<T extends Comparable & Serializable> implements Serializeable{ |
这时,会用Comparable 替换类中的T。
当限定类型切换为<T extends Serializable & Comparable>时,原始类型会用Serializable替换T,编译器会在必要时向Comparable插入强制类型转换。为了提高效率,应该将标签接口(即没有方法的接口)放在限定列表的末尾。
转换泛型表达式
编写一个泛型方法调用时,如果擦除了返回类型,编译器会插入强制类型转换。
例如:
1 | Pair<Employee> buddies = ...; |
getFirst类型擦出后返回类型是Object。编译器会自动插入转换到Employee的强制类型转换。即,编译器把这个方法调用转换为两条虚拟机指令:
- 对原始方法Pair.getFirst的调用。
- 将返回的Object类型强制转换为Employee类型。
转换泛型方法
类型擦除会造成多态的冲突,而JVM解决方法就是桥接方法。
现在有这样一个泛型类:
1 | class Pair<T> { |
然后我们想要一个子类继承它。
1 | class DateInter extends Pair<Date> { |
在这个子类中,我们设定父类的泛型类型为Pair<Date>
,在子类中,我们覆盖了父类的两个方法,我们的原意是这样的:将父类的泛型类型限定为Date,那么父类里面的两个方法的参数都为Date类型。
所以,我们在子类中重写这两个方法一点问题也没有,从他们的@Override
标签中也可以看到,一点问题也没有,实际上是这样的吗?
分析:实际上,类型擦除后,父类的的泛型类型全部变为了原始类型Object,所以父类编译之后会变成下面的样子:
1 | class Pair { |
再看子类的两个重写的方法的类型:
1 |
|
先来分析setValue方法,父类的类型是Object,而子类的类型是Date,参数类型不一样,这如果是在普通的继承关系中,根本就不会是重写,而是重载。
1 | // 自己的 |
我们在一个main方法测试一下:
1 | public static void main(String[] args) throws ClassNotFoundException { |
如果是重载,那么子类中两个setValue方法,一个是参数Object类型,一个是Date类型,可是我们发现,根本就没有这样的一个子类继承自父类的Object类型参数的方法。所以说,确实是重写了,而不是重载了。
为什么会这样呢?
原因是这样的,我们传入父类的泛型类型是Date
,Pair<Date>
,我们的本意是将泛型类变为如下:
1 | class Pair { |
然后在子类中重写参数类型为Date的那两个方法,实现继承中的多态。
可是由于种种原因,虚拟机并不能将泛型类型变为Date,只能将类型擦除掉,变为原始类型Object。这样,我们的本意是进行重写,实现多态。可是类型擦除后,只能变为了重载。这样,类型擦除就和多态有了冲突。JVM知道你的本意吗?知道!!!可是它能直接实现吗,不能!!!如果真的不能的话,那我们怎么去重写我们想要的Date类型参数的方法呢?😢
于是JVM采用了一个特殊的方法,来完成这项功能,那就是桥方法。
首先,我们用javap -c className的方式反编译下DateInter子类的字节码,结果如下:
1 | class com.tao.test.DateInter extends com.tao.test.Pair<java.util.Date> { |
从编译的结果来看,我们本意重写setValue和getValue方法的子类,竟然有4个方法,其实不用惊奇,最后的两个方法,就是编译器自己生成的桥方法。可以看到桥方法的参数类型都是Object,也就是说,子类中真正覆盖父类两个方法的就是这两个我们看不到的桥方法。而打在我们自己定义的setvalue和getValue方法上面的@Override只不过是假象。而桥方法的内部实现,就只是去调用我们自己重写的那两个方法。
关于setValue()方法:
1 | public void setValue(Date value){...} |
桥方法内部其实就是调用了我们自己的 setValue 方法,这样就避免了在重写的时候我们还能调用到父类的方法。解决类型擦除与多态之间的冲突。
关于getValue()方法:
1 | // 自己定义的方法 |
子类中的桥方法Object getValue()
和Date getValue()
是同时存在的,可是如果是常规的两个方法,他们的方法签名是一样的,也就是说虚拟机根本不能分别这两个方法。如果是我们自己编写Java代码,这样的代码是无法通过编译器的检查的,但是虚拟机却是允许这样做的,因为虚拟机通过参数类型和返回类型来确定一个方法,所以编译器为了实现泛型的多态允许自己做这个看起来“不合法”的事情,然后交给虚拟器去区别。
如果这是一个普通的继承关系:
那么父类的setValue方法如下:
1 | public Object getValue() { |
而子类重写的方法是:
1 | public Date getValue() { |
这在普通的类继承中也是普遍存在的重写,这就是协变。
总之
对于Java泛型转换,需要记住以下几个事实:
- 虚拟机中没有泛型,只有普通的类和方法。
- 所有的类型参数都会替换为它们的限定类型。
- 会合成桥方法来保持多态。
- 为保持类型安全性,必要时会插入强制类型转换。
泛型类型的继承规则
考虑一个类和一个子类,如Employee和Manager。Pair<Manager>是Pair<Employee>的一个子类吗?
答案是:“不是”。
无论S和T有什么关系,通常Pair<S>和Pair<T>都没有任何关系。
这里就看到泛型类型和java数组之间一个重要的区别,可以将一个Manager[]数组赋给一个类型为Employee[]的变量。
正是由于泛型时基于类型擦除实现的,所以,泛型类型无法向上转型。
向上转型是指用子类实例去初始化父类,这是面向对象中多态的重要表现。
这是因为,泛型类并没有自己独有的 Class
类对象。比如:并不存在 Pair<Manager>.class
或是 Pair<Employee>.class
,Java 编译器会将二者都视为 Pair.class
。
泛型不能用于显式地引用运行时类型的操作之中,例如:转型、instanceof 操作和 new 表达式。因为所有关于参数的类型信息都丢失了。当你在编写泛型代码时,必须时刻提醒自己,你只是看起来好像拥有有关参数的类型信息而已。
泛型类可以扩展或实现其他的泛型类,就这一点而言,它们与普通的类没有什么区别。例如:ArrayList<T>类实现了List<T>接口。这意味着一个ArrayList<Manager>可以转换为一个List<Manager>。但是,ArrayList<Manager>不是一个ArrayList<Manager>或List<Manager>。
通配符类型
概念
在通配符类型中,允许类型参数发生变化。
类型通配符一般是使用 ?
代替具体的类型参数。例如 List<?>
在逻辑上是 List<String>
,List<Integer>
等所有 List<具体类型实参>
的父类。
上界通配符
可以使用上界通配符来缩小类型参数的类型范围。
它的语法形式为:<? extends 父类>
可以表示所有继承了该父类的子类。
我们可以通过使用通配符来向上转型。
例如:
1 | //已经实例化3个Manager m1, m2, m3; |
为什么最后一行编译不通过呢?
我们来看类型Pair<? extends Employee>
它的方法如下:
? extends Employee getFirst()
void setFirst(? extends Employee)
这样将不可能调用setFirst
方法。编译器只知道需要Employee的某个子类型,但不知道具体是什么类型。毕竟?
不能匹配。
而getFirst
就不存在这个问题:将getFirst
的返回值赋给一个Employee引用是完全合法的。
下界通配符
下界通配符将未知类型限制为该类型的特定类型或超类类型。
🔔 注意:上界通配符和下界通配符不能同时使用。
它的语法形式为:<? super 子类>
它将类型限制为该子类的所有父类。
带有超类型限定的通配符的行为与上面所讲的上界通配符相反。可以为方法提供参数,但不能使用返回值。
例如Pair<? super Manager>
有如下方法:
void setFirst(? super Manager)
? super Manager getFirst()
编译器无法知道setFirst
方法的具体类型,因此不能接受参数类型为Employee
或Object
的方法调用。只能传递Manager
的对象,或者某个子类型对象。另外,如果调用getFirst
,不能保证返回对象的类型,只能把它赋给一个Object
。
直观的讲,带有超类型限定的通配符允许你写入一个泛型对象,而带有子类型限定的通配符允许你读取一个泛型对象。
无限定通配符
无界通配符作用:
- 接受任何泛型类型数据
- 实现不依赖于具体类型参数的简单方法,如非空判断,size(),clear() 等方法
- 用于捕获参数类型并交由泛型方法进行处理
语法形式:<?>
例如:Pair<?>
它有以下方法:
? getFirst()
void setFirst(?)
getFirst
的返回值只能赋给一个Object
。setFirst
方法不能被调用。甚至不能用Object调用。
Pair\<?>
和Pair
的本质区别在于:可以用任意Object对象调用原始Pair类的setFirst方法。
限制与局限性
🔔 泛型类的构造器不能加入<>括起来的类型参数。
🔔 泛型的指定中不能使用基本数据类型,基本类型不是 Object 子类,应该使用包装类替换。
🔔 不能创建类型参数的实例。如果我们确实需要实例化一个泛型,可以通过反射实现。
🔔 在类/接口上声明的泛型,在本类或本接口中即代表某种类型,可以作为非静态属性的类型、非静态方法的参数类型、非静态方法的返回值类型。但在静态方法中不能使用类的泛型。
🔔 不能catch 或 throw泛型类的对象。事实上,泛型类扩展Throwable都不合法。
即
1 | public class Problem<T> extends Exception { |
不会通过编译。
但是在异常声明中可以使用类型变量。下面方法是合法的。
1 | public static<T extends Throwable> void doWork(T t) throws T { |
上面的这样使用是没问题的。
🔔 如果泛型结构是一个接口或抽象类,则不可创建泛型类的对象。
🔔 泛型不同的引用不能相互赋值。
例如Pair<String>
的对象不能赋值给Pair<Integer>
的对象。
🔔 实例化后,操作原来泛型的结构必须与指定的泛型类型一致。
🔔 泛型无法使用 Instance of 和 getClass() 进行类型判断
🔔 泛型如果不指定,将被擦除,泛型对应的类型均按照Object
处理,但不等价于Object
🔔 仅仅是泛型类相同,而类型参数不同的方法不能重载。
例如:
1 | public class Example { |
🔔 关于泛型数组
不能创建参数化类型的数组
1 | var table = new Pair<String>[10]; //ERROR |
这是因为擦除之后,table的类型转换为Object[],数组会记住它的元素类型,如果试图存储其他类型的元素,会抛出异常。不过对于泛型类型,擦除使得这种机制无效。对于Object[0] = new Pair<Employee>()
尽管能通过数组存储的检查,仍会导致一个类型错误。所以,不允许创建泛型数组。
可以声明通配类型的数组,然后进行强制转换。
1 | var table = (Pair<String>[]) new Pair<?>[10]; //OK,但不安全 |
所以,如果想要收集参数化类型对象,简单的使用ArrayList更有效。
参考资料及博客:
《java核心技术卷I》
Java基础——泛型机制详解 链接:https://pdai.tech/md/java/basic/java-basic-x-generic.html
深入理解Java泛型 链接:https://dunwu.github.io/javacore/basics/java-generic.html