泛型(Generics)是JDK 1.5时增加的编程语言中的一种特性,允许在编写代码时使用一种抽象的类型来代表实际的数据类型。也可以通俗地理解为将数据类型实现参数化,处理的数据类型不是固定的,而是可以作为参数传入。我们可以把“泛型”理解为数据类型的一个占位符(类似:形式参数),即告诉编译器,在调用泛型时必须传入实际类型。
类似于cpp中的模板(Templates),异曲同工,旨在“数据类型参数化”;
优点:
不使用泛型时,可以使用Object类型来实现任意的参数类型,但是在使用时需要我们进行强制类型转换。这要求编写程序的人明确知道实际类型,不然容易引起类型转换错误ClassCastException;这种错误在编译期无法识别,只有在运行期实际执行了语句后才能发现,加大了维护的工作量。
使用泛型后,可以在编译期识别出这种错误,有了更好的安全性;同时所有类型转换由编译期完成,在程序员看来都是自动转换,提高了代码的可读性;
来看看chat老师的话术:
类型擦除:
编码时采用泛型写的参数类型,编译期会在编译时将其去掉,即“类型擦除”。
泛型主要用于编译阶段,编译后生成的字节码.class文件不包含泛型中的类型信息,涉及类型转换仍然是普通的强制类型转换。类型参数在编译后会被替换成Object,运行时虚拟机JVM并不知道泛型。
即泛型主要是方便代码的编写,以及更好的安全性检测。
一、泛型类
把泛型定义在类上,基本的语法格式:
// 单个泛型标识
public class 类名<泛型标识符号> {
}
// 多个泛型标识
public class 类名<泛型标识符号,泛型标识符号> {
}
其中,定义泛型时,一般采用几个标记:E、T、K、V、N、?。他们约定俗称的含义如下:
泛型标记 | 对应单词 | 说明 |
E | Element | 在容器中使用,表示容器中的元素 |
T | Type | 表示普通的JAVA类 |
K | Key | 表示键,例如:Map中的键Key |
V | Value | 表示值 |
N | Number | 表示数值类型 |
? | 表示不确定的JAVA类型 |
举例:
public class Generic<T> {
private T value; // 泛型类中定义的涉及的数据类型都采用T
public T getValue() {
return value;
}
public void setValue(T value) {
this.value = value;
}
public static void main(String[] args) {
// 指定T为String
Generic<String> generic1 = new Generic<>();
generic1.setValue("kk");
String value1 = generic1.getValue();
System.out.println(value1);
// 指定类型为Integer
Generic<Integer> generic2 = new Generic<>();
generic2.setValue(25);
Integer value2 = generic2.getValue();
System.out.println(value2);
}
}
二、泛型接口
在接口上定义泛型,其声明方式和泛型类基本一致:
public interface 接口名<泛型标识符号> {
}
public interface 接口名<泛型标识符号,泛型标识符号> {
}
用法:
- 在实现接口时就传递具体数据类型;
- 实现接口时仍然使用泛型作为数据类型,而在真正使用时才传入指定数据类型;
举例:
// 定义泛型接口
public interface IGeneric<T> {
public abstract T getValue(T t);
}
// 用法一:在实现接口时就传递具体数据类型
public class IGenericImpl implements IGeneric<String>{
@Override
public String getValue(String name) {
return name;
}
}
// 用法二:实现接口时仍然使用泛型作为数据类型,而在真正使用时才传入指定数据类型
public class IGenericImpl1<T> implements IGeneric<T>{
@Override
public T getValue(T name) {
return name;
}
}
// 测试类
public class IGenericTest {
public static void main(String[] args) {
IGeneric<String> ig1 = new IGenericImpl();
System.out.println(ig1.getValue("kk"));
IGeneric<String> ig2 = new IGenericImpl1<>();
System.out.println(ig2.getValue("kkakoka"));
}
}
// 控制台输出
kk
kkakoka
剖析:
可以看到25行和28行的区别,25行后的类型IGenericImpl后是没有泛型<>的,说明这个类在实现了泛型接口IGeneric之后已经被当成一个普通类了(没有泛型加持),而28行由于IGenericImpl1在实现泛型接口时还是没有指定具体的数据类型,所以还是泛型类;
三、泛型方法
类上定义的泛型,在方法中也可以使用。但是,我们经常需要仅仅在某一个方法上使用泛型,这时候可以使用泛型方法。
调用泛型方法时,不需要像泛型类那样告诉编译器是什么类型,编译器可以自动推断出类型;
用法:
- 非静态方法可以使用泛型类中所定义的泛型,也可以将泛型定义在方法上。
//无返回值方法
public <泛型标识符号> void getName(泛型标识符号 name){
}
//有返回值方法
public <泛型标识符号> 泛型标识符号 getName(泛型标识符号 name){
}
- 举例:
public class MethodGeneric {
public <T> void setName(T name){
System.out.println(name);
}
public <T> T getAge(T age){
return age;
}
public static void main(String[] args) {
MethodGeneric methodGeneric = new MethodGeneric();
methodGeneric.setName("kk");
Integer age = methodGeneric.getAge(18);
System.out.println(age);
}
}
// 输出结果
kk
18
- 静态方法中使用泛型时有一种情况需要注意一下,那就是静态方法无法访问类上定义的泛型,所以必须要将泛型定义在方法上;
//无返回值静态方法
public static <泛型标识符号> void setName(泛型标识符号 name){
}
//有返回值静态方法
public static <泛型标识符号> 泛型表示符号 getName(泛型标识符号 name){
}
- 要点:静态方法不能访问类上定义的泛型!
- 举例:
public class MethodGeneric1<T> {
public static <T> T getName(T name){
return name;
}
public static <T> void setAge(T age){
System.out.println(age);
}
public static void main(String[] args) {
MethodGeneric1.setAge(18);
String name = MethodGeneric1.getName("kk");
System.out.println(name);
}
}
// 控制台输出
18
kk
3.1 泛型方法与可变参数
在泛型方法中,泛型也可以定义可变参数类型。
语法结构:
public <泛型标识符号> void showMsg(泛型标识符号... agrs){
}
举例:
// 定义参数为可变的泛型方法
public <T> void method(T ...args){
for (T item : args) {
System.out.print(item + " ");
}
System.out.println();
}
// 测试泛型方法的可变参数
String[] arr1 = new String[]{"k","kk","kkk"};
Integer[] arr2 = new Integer[]{1,2,3,4,5};
methodGeneric.method(arr1);
methodGeneric.method(arr2);
// 控制台输出
k kk kkk
1 2 3 4 5
四、通配符
4.1 无界通配符
“?” 表示类型通配符,用于代替具体的类型。它只能在“<>”中使用,可以解决当具体类型不确定的问题。
语法结构:
public void showFlag(Generic<?> generic){
}
// Generic类
public class Generic<T> {
private T value;
public T getValue() {
return value;
}
public void setValue(T value) {
this.value = value;
}
}
// 工具类ShowMsg
public class ShowMsg {
// 使用无界通配符
public void showValue(Generic<?> generic){
System.out.println(generic.getValue());
}
public static void main(String[] args) {
ShowMsg showMsg = new ShowMsg();
// String
Generic<String> generic1 = new Generic<>();
generic1.setValue("kk");
showMsg.showValue(generic1);
// Integer
Generic<Integer> generic2 = new Generic<>();
generic2.setValue(18);
showMsg.showValue(generic2);
}
}
// 控制台输出
kk
18
注:如果不使用<?>则会直接编译报错;
4.2 通配符的上限限定
对通配符的上限的限定:<? extends 类型>
?实际类型可以是上限限定中所约定的类型,也可以是约定类型的子类型;
public void showFlag(Generic<? extends Number> generic){
}
举例:
4.3 通配符的下限限定
对通配符的下限的限定:<? super 类型>
?实际类型可以是下限限定中所约定的类型,也可以是约定类型的父类型;
public void showFlag(Generic<? super Integer> generic){}
注意:super不用于泛型类的定义中!!(存疑)
举例:
五、类型擦除与桥方法
贴上参考链接:
5.1 协变与逆变
协变可以让泛型的约束更加宽松,但无法往原集合中添加元素。
逆变会使得无法获取原类型的对象。也即是:
因此在实际涉及泛型的编程中,我们要规避这两种情况,最大限度使用泛型。
PECS原则:全称为Producer Extends, Consumer Super,是一种在泛型编程中的约定,用于确定泛型类型参数的上界和下界。这个原则在Java等编程语言中,通过通配符(Wildcard)来表示不确定的类型参数,以指导我们在使用通配符时,如何为生产者(Producer)和消费者(Consumer)选择合适的边界。
在PECS原则中,“Producer Extends”意味着当你需要从一个容器中取出数据时,应该使用带有extends关键字的通配符,这样你可以取出该容器及其父类中的任何对象(extends的类型及其所有子类型对象)。“Consumer Super”则意味着当你需要向一个容器中添加数据时,应该使用带有super关键字的通配符,这样你可以向该容器及其子类中添加任何对象(super的类型及其所有父类型对象)。
这个原则的核心思想是基于Liskov替换原则(LSP),即所有出现基类(父类)的地方都可以用子类进行替换。在泛型编程中,通过使用PECS原则,我们可以更加灵活地处理不同的数据类型,同时保证类型安全。
在泛型编程实际情况中,PECS对应的情况只是其中一部分(不一定所有都是取出、添加元素);
- Producer Extends:
public class GenericTest3 {
public static void main(String[] args) {
List<Double> doubleList = new ArrayList<>();
doubleList.add(2.0d);
doubleList.add(4.0d);
doubleList.add(8.0d);
sum(doubleList);
}
// producer
public static double sum(List<? extends Number> list){
// list.add(4.0F);
double res = 0;
for (Number number : list){
res += number.doubleValue();
}
return res;
}
}
- Consumer Super:
public class GenericTest3 {
public static void main(String[] args) {
List<Double> doubleList = new ArrayList<>();
doubleList.add(2.0d);
doubleList.add(4.0d);
doubleList.add(8.0d);
//sum(doubleList);
add(doubleList);
}
// Consumer
public static void add(List<? super Double> list){
list.add(16.0d);
System.out.println(list);
}
}
实例1:ArrayList的拷贝构造方法
/**
* Constructs a list containing the elements of the specified
* collection, in the order they are returned by the collection's
* iterator.
*
* @param c the collection whose elements are to be placed into this list
* @throws NullPointerException if the specified collection is null
*/
public ArrayList(Collection<? extends E> c) {
Object[] a = c.toArray();
if ((size = a.length) != 0) {
if (c.getClass() == ArrayList.class) {
elementData = a;
} else {
elementData = Arrays.copyOf(a, size, Object[].class);
}
} else {
// replace with empty array.
elementData = EMPTY_ELEMENTDATA;
}
}
遵循着我们的PE原则,由于构造方法只需要取出传入的泛型参数类型集合的每一个元素,因此可以采用extends关键字来放宽传入类型限制,使得当前ArrayList所指定的泛型类型E及其子类型的集合对象都可以传入为其自身构造。
实例2:ArrayList中的removeIf()方法
@Override
public boolean removeIf(Predicate<? super E> filter) {
Objects.requireNonNull(filter);
// figure out which elements are to be removed
// any exception thrown from the filter predicate at this stage
// will leave the collection unmodified
int removeCount = 0;
final BitSet removeSet = new BitSet(size);
final int expectedModCount = modCount;
final int size = this.size;
for (int i=0; modCount == expectedModCount && i < size; i++) {
@SuppressWarnings("unchecked")
final E element = (E) elementData[i];
if (filter.test(element)) {
removeSet.set(i);
removeCount++;
}
}
...
}
由于该方法需要调用传入的泛型类型参数的方法,思考,若是传入的是当前ArrayList指定的泛型类型E的子类型,那么就会出现不适配的情况(用子类的方法操作父类的对象),因此这里的关键字应该是super(父类的方法操作子类的对象是兼容的)。
5.2 类型擦除
所谓类型擦除,指的是在Java编译器在编译带泛型类型参数时会擦除其类型信息,转化为它们的上界或者Object类型。是在编译期间将泛型类型转换为非泛型类型的一种机制。
实例1:验证编译阶段会对泛型类型参数进行类型擦除
public class GenericTest1 {
public static void main(String[] args) throws NoSuchMethodException {
new GenericTest1().testType();
}
public void testType(){
ArrayList<Integer> collection1 = new ArrayList<>();
ArrayList<String> collection2 = new ArrayList<>();
// judge if bytecodes is the same during compile
System.out.println( collection1.getClass() == collection2.getClass() );
System.out.println(collection1.getClass().getName());
System.out.println(collection2.getClass().getName());
}
输出结果:
true
java.util.ArrayList
java.util.ArrayList
可以看到,在编译后输出的类型collection1.getClass()已经擦除了泛型。
分析:不管泛型的类型形参传入时是哪一种类型实参,对于Java而言,它们依然被当做同一类型进行处理,在内存中也只会占用一块空间。我们讨论Java泛型时,其作用域是代码的编译阶段。在编译过程中,对于正确校验泛型的结果后会将泛型的相关信息擦除。也即是说,成功编译后的class文件中是不包含任何泛型信息的。
实例2:javap命令分析编译后的字节码
public static void main(String[] args){
List<String> stringList = new ArrayList<>();
}
}
对编译后的字节码用javap命令进行分析:
- Code中,关于new方法的描述可以看到有关泛型的信息已经被擦除;
- LocalVariableTypeTable中,Signature字段记录着泛型的具体信息;
又如:
class IntList extends ArrayList<Integer>{
List<String> toStringList(){
return new ArrayList<>();
}
}
通过javap对字节码进行分析可以得到:
在Signature中,额外记录了泛型的实际类型;
注意:
- 在静态方法、静态初始化块和静态变量的声明和初始化中不允许使用类型形参;
- 由于系统中不会真正生成泛型类,所有instanceof运算符后不能使用泛型类;
不同位置的泛型信息获取(反射):
5.3 桥方法
由于Java的泛型在运行时会发生类型擦除(Type Erasure),这可能导致在某些情况下,如使用反射调用泛型方法时,出现类型不匹配的问题。为了解决这个问题,Java编译器会自动生成一些桥接方法来确保类型安全。
具体而言,桥接方法是一种由编译器自动插入的方法,用于保持多态的正确性。当泛型类继承自非泛型类,并且重写了非泛型类中的方法时,由于Java的泛型类型擦除机制,直接在泛型类中添加这个方法会导致类型不匹配。为此编译器生成一个桥接方法,确保类型安全和多态的正确性。
作用:
- 类型安全:泛型桥方法的主要作用是保持类型安全性。通过添加桥方法,可以在运行时防止对不兼容的类型进行访问。这样可以避免在编译期间无法检测到的类型错误;
- 维护继承关系:泛型桥方法还用于维护泛型类或接口之间的继承关系。它们确保子类或实现类能够正确地覆盖父类或接口的泛型方法,并使用正确的类型参数;
实例1:
public class MyList<T> {
public void add(T element) {
// 添加元素的逻辑
}
}
// 子类继承泛型类,并覆盖泛型方法
public class StringList extends MyList<String> {
@Override
public void add(String element) {
// 添加元素的逻辑
}
}
在这个示例中,由于Java的泛型类型擦除机制,编译器会生成一个桥方法来确保类型安全性和兼容性。上述代码实际上被编译器转换为以下内容:
public class MyList {
public void add(Object element) {
// 添加元素的逻辑
}
}
public class StringList extends MyList {
@Override
public void add(Object element) {
add((String) element);
}
public void add(String element) {
// 添加元素的逻辑
}
}
在这个转换后的代码中,StringList 类包含了一个桥方法 add(Object element),它调用了真正的泛型方法 add(String element)。这样就保持了类型安全性,并且与父类的非泛型方法兼容。
通过生成泛型桥方法,Java编译器可以在继承和实现泛型类型时保持类型安全性和兼容性。这些桥方法在内部转换和维护泛型类型擦除的同时,提供了更好的类型检查和运行时类型安全性。
实例2:把桥方法揪出来
public class GenericTest4 {
public static void main(String[] args) {
Class<ServiceImpl> serviceClass = ServiceImpl.class;
Method[] methods = serviceClass.getDeclaredMethods();
for(Method method : methods){
System.out.println(method.getName() + " : " +method.getReturnType());
}
}
}
interface Service<T>{
T getData();
}
class ServiceImpl implements Service<Integer>{
@Override
public Integer getData() {
return Integer.MAX_VALUE;
}
}
输出结果:
getData : class java.lang.Integer
getData : class java.lang.Object
分析:可以看到实际上类中存在的方法有两个,除了我们自己写的类型为Integer的外,还有类型为Object的方法。这个Object方法即为桥接方法。
六、避雷区
下面是一些不能踩的雷点:
- 基本类型不能用于泛型Test<int> t; 这样写法是错误,我们可以使用对应的包装类Test<Integer> t ;
- 不能通过类型参数创建对象T elm = new T(); 运行时类型参数T会被替换成Object,无法创建T类型的对象,容易引起误解,java干脆禁止这种写法;
- 模糊性错误:
例:对于泛型类User<K,V>而言,声明了两个泛型类参数。在类中根据不同的类型参数重载show方法:
public class User<K, V> {
public void show(K k) { // 报错信息:'show(K)' clashes with 'show(V)'; both methods have same erasure
}
public void show(V t) {
}
}
由于泛型擦除,二者本质上都是Obejct类型。方法是一样的,所以编译器会报错。
- 对静态方法的限制:静态方法无法访问类上定义的泛型,所以必须要将泛型定义在方法上;
- 对泛型数组的限制:不能实例化元素类型为类型参数的数组,但是可以将数组指向类型兼容的数组的引用:
public class User<T> {
private T[] values;
public User(T[] values) {
//错误,不能实例化元素类型为类型参数的数组
this.values = new T[5];
//正确,可以将values 指向类型兼容的数组的引用
this.values = values;
}
}
Java中的数组是协变的:数组的协变(covariance)是指可以向子类型的数组赋予基类型的数组引用。如果A是B的子类,那么一个A类型的数组可以被当作B类型的数组来使用。这种特性在Java的泛型中尤为重要,因为它允许我们在不丧失类型安全性的情况下,更加灵活地操作数组。
例如,如果我们有一个Integer类型的数组,我们可以将它赋值给一个Object类型的数组引用,因为Integer是Object的子类。这就是数组的协变特性。然而,需要注意的是,虽然这种赋值在编译时可以通过,但在运行时可能会引发错误,因为如果我们试图通过这个Object类型的数组引用向数组中存储一个非Integer类型的对象(如String),就会在运行时抛出异常。
数组是固定长度的,并且它们的元素类型在创建时就已确定。如果你有一个特定类型的数组(比如 Integer[]),那么你不能向这个数组中添加不是该类型或其子类型的元素。但如果你有一个 Object[] 类型的数组,由于Object是Java中所有类的超类,所以你可以向这个数组中添加任何类型的对象;
然而,如果你有一个 Integer[] 类型的数组,并将其赋值给一个 Object[] 类型的变量,虽然可以通过这个 Object[] 类型的变量来访问数组中的元素,但你仍然不能通过这个变量向数组中存储非Integer类型的对象。如果你尝试这样做,编译器会抛出一个 ArrayStoreException。
还有一些问题目前李家将还没解决,期待看客老爷们评论解惑!
Q:协变与多态的关系?
Q:泛型类中不能用下限限定??
参考:
致谢&部分资源出处:百战程序员 、B站@程序员c兄 (视频讲解得真的好深刻啊!