【Java】泛型
文章目录
- 【Java】泛型
- 泛型概述
- 什么是泛型?
- 为什么需要泛型?
- 泛型的使用
- 泛型类
- 泛型方法
- 泛型接口
- 泛型通配符
- 什么是泛型通配符?
- `<?>` —— 通配任何类型
- `<? extends T>` —— 上界通配符(子类)
- `<? super T>` —— 下界通配符(父类)
- T 和 ? 的区别
- 协变和逆变
- 为什么数组支持协变而泛型集合不支持?
- 类型擦除
- 什么是类型擦除?
- 核心概念
- 类型擦除的局限性
- 无法创建泛型类型数组
- 无法使用 `instanceof` 检查泛型类型
- 无法创建类型参数的实例
- 无法创建类型参数的数组
- 静态上下文(静态变量、静态方法)不能引用类型参数
- 方法重载冲突
- 异常处理
- 类型擦除的必要性:兼容性
- 总结
【Java】泛型
泛型概述
什么是泛型?
泛型,也就是 “参数化类型”。
在编程中,我们常常希望编写通用的代码,比如容器类(如 ArrayList
)可以存放任意类型的对象,而不需要为每种类型分别写一个类。泛型就提供了一种方式,让你在定义类、接口或方法时将“类型”也当作参数传入。
为什么需要泛型?
想象一下,在泛型出现前(Java 5之前),我们操作集合时常这样写:
List list = new ArrayList();
list.add("Hello");
list.add(100); // 不小心混入整数
String str = (String) list.get(1); // 运行时抛出ClassCastException!
问题显而易见:
- 类型不安全:任何类型都能放入集合,错误在运行时才暴露。
- 繁琐的强制类型转换:每次取出元素都需要显式转换。
- 代码可读性差:无法直接看出集合存储的类型。
泛型正是为解决这些问题而生!
泛型的使用
泛型有三种使用方式,分别为:泛型类、泛型接口、泛型方法。
泛型类
类型参数用于类的定义中,则该类被称为泛型类。通过泛型可以完成对一组类的操作对外开放相同的接口。最典型的就是各种容器类,如:List、Set、Map等。
基本语法:
class 类名称 <泛型标识> {...
}
-
尖括号 <> 中的 泛型标识被称作是类型参数,用于指代任何数据类型。
-
泛型标识是任意设置的(如果你想可以设置为 Hello都行),Java 常见的泛型标识以及其代表含义如下:
T Type 类型(代表一般的任何类) E Element 元素(常用于集合) K Key 键 V Value 值 ? 通配符
示例:
public class Box<T> {private T content;public void setContent(T content) {this.content = content;}public T getContent() {return content;}
}Box<String> stringBox = new Box<>();
stringBox.setContent("Java");
String value = stringBox.getContent();
泛型方法
当在一个方法签名中的返回值前面声明了一个 < T > 时,该方法就被声明为一个泛型方法。< T >表明该方法声明了一个类型参数 T,并且这个类型参数 T 只能在该方法中使用。当然,泛型方法中也可以使用泛型类中定义的泛型参数。
基本语法:
public <类型参数> 返回类型 方法名(类型参数 变量名) {...
}
- 只有在方法签名中声明了< T >的方法才是泛型方法,仅使用了泛型类定义的类型参数的方法并不是泛型方法。
- 方法声明中定义的形参只能在该方法里使用,而接口、类声明中定义的类型形参则可以在整个接口、类中使用。
示例:
public <T> void printArray(T[] array) {for (T element : array) {System.out.println(element);}
}// 调用时编译器自动推断类型
printArray(new String[]{"A", "B"});
泛型接口
泛型接口和泛型类的定义差不多,基本语法如下:
public interface 接口名<类型参数> {...
}
- 泛型接口中的类型参数,在该接口被继承或者被实现时确定。
示例:
public interface Repository<T> {T findById(int id);void save(T entity);
}public class UserRepository implements Repository<E> {// 实现中T被替换为E
}
泛型通配符
什么是泛型通配符?
在现实编码中,确实有这样的需求,希望泛型能够处理某一类型范围内
的类型参数,比如某个泛型类和它的子类,为此 Java 引入了泛型通配符
这个概念。
泛型通配符有三种形式:
<?>
:表示未知类型(无界通配符)<? extends T>
:表示某个 T 的子类型(上界通配符)<? super T>
:表示某个 T 的父类型(下界通配符)
<?>
—— 通配任何类型
<?>
表示未知类型,可以接收任何类型的泛型实例。
示例代码:
public void printList(List<?> list) {for (Object obj : list) {System.out.println(obj);}
}
使用场景:
只读取集合中的数据,不进行写入(除了添加 null)。
注意:
- 不能添加除
null
以外的任何元素。 - 通常用于只读操作。
<? extends T>
—— 上界通配符(子类)
<? extends T>
表示某种类型是 T
或 T
的子类。
示例代码:
public void printNumbers(List<? extends Number> list) {for (Number num : list) {System.out.println(num);}
}
可以传入 List<Integer>
、List<Double>
、List<Number>
。
使用场景:
- 从集合中读取元素,适用于生产者 Producer。
- 不能添加任何非
null
值。
你不知道传入的是 List<Integer>
还是 List<Double>
,如果你尝试加入 Double
到 List<Integer>
中会类型错误。
“extends 只能读,不能写”
<? super T>
—— 下界通配符(父类)
<? super T>
表示某种类型是 T
或 T
的父类。
示例代码:
public void addNumbers(List<? super Integer> list) {list.add(1);list.add(2);
}
可以传入 List<Integer>
、List<Number>
、List<Object>
。
使用场景:
- 向集合中添加元素,适用于消费者 Consumer。
- 取出元素时只能当作
Object
类型。
“super 只能写,不能读(读出来是 Object)”
T 和 ? 的区别
两者的核心角色不同:
T
是类型参数:在声明泛型类、接口或方法时使用,代表一个具体的类型占位符(如class Box<T>
),可在代码中作为实际类型操作变量。?
是通配符:在使用泛型时(如方法参数)表示未知类型(如List<?>
),主要用于放宽类型限制,但无法直接引用该类型。
具体区别有三点:
- 可操作性
T
可定义变量、返回值、参数(T item;
)?
仅用于类型约束,不能声明变量(? item;
❌)
- 类型关联性
T
可关联多个位置(如<T> void add(T a, List<T> b)
要求a和b同类型)?
每个通配符独立(List<?> a, List<?> b
可接受不同类型集合)
- 写入能力
T
允许写入对象(list.add(item)
✅)?
禁止写入(除null
外),如List<?> list.add("A")
❌”
T
的定位:精确控制类型,实现类型安全的操作(如 Collections.sort(List<T>)
)。
?
的定位:解决泛型的协变/逆变问题(通过 ? extends
/? super
),遵循 PECS 原则
例如 Collections.copy(List<? super T> dest, List<? extends T> src)
中:
src
用extends
保证安全读取dest
用super
保证安全写入
代码示例:
// T 的典型用例:类型安全的容器
public class Container<T> {private T value;public T getValue() { return value; } // T作为返回类型
}// ? 的典型用例:灵活处理未知集合
public void printSize(List<?> list) {System.out.println(list.size()); // 不依赖具体类型
}
补充:
T
只能定义上界(<T extends Number>
),而?
支持上下界(<? extends Number>
或<? super Integer>
),这是实现API灵活性的关键。- 编译后两者都会被擦除,但
T
擦除到边界类型(如Number
),? extends T
擦除到T
,而?
直接擦除到Object
。
协变和逆变
协变和逆变是泛型中处理类型继承关系的两种方式:
- 协变 用
<? extends T>
实现:允许将子类型的集合视为父类型的集合(例如List<Integer>
当作List<? extends Number>
),但只能读不能写。 - 逆变 用
<? super T>
实现:允许将父类型的集合视为子类型的集合(例如List<Number>
当作List<? super Integer>
),但只能写不能安全读。
- 数据流向:
- 协变支持数据产出(如从集合读取元素)
- 逆变支持数据消费(如向集合添加元素)
- 类型安全:
- 协变读取时编译器保证返回
T
类型 - 逆变写入时编译器检查元素必须是
T
的子类
- 协变读取时编译器保证返回
- 设计原则:
二者共同遵循 PECS( Producer(生产者)使用extends
、 Consumer(消费者)使用super
),例如:
public static <T> void copy(List<? super T> dest, List<? extends T> src) {// src 是生产者(协变) → 读取安全// dest 是消费者(逆变) → 写入安全for (int i = 0; i < src.size(); i++) {dest.set(i, src.get(i)); // 安全读 + 安全写}
}
- 协变 是子类型替换的延伸(里氏替换原则),但 Java 泛型默认是不变的,因此需显式声明
extends
实现协变。 - 逆变 是父类型能力覆盖的体现(如回调函数
Consumer<? super T>
可处理所有T
的子类)。 - 类型擦除后,JVM 无法在运行时检查泛型类型,因此编译器需在编译期通过规则保证安全。
为什么数组支持协变而泛型集合不支持?
// 数组协变(运行时可能失败)
Number[] nums = new Integer[10];
nums[0] = 1.5; // 运行时抛出 ArrayStoreException// 泛型集合(编译器阻止错误)
List<Number> numList = new ArrayList<Integer>(); // 编译错误!
数组在运行时保留类型信息,泛型通过编译器类型擦除实现。Java 用 ? extends
提供安全的编译期协变,避免运行时错误。
类型擦除
什么是类型擦除?
Java 的类型擦除是泛型实现的核心机制。它的主要目的是在编译时提供更强的类型检查,同时保持与旧版本Java字节码的兼容性(主要是Java 5之前的非泛型代码)
核心概念
- 编译时检查,运行时擦除:
- 编译时: 编译器会严格检查泛型类型的使用是否安全(例如,确保你向
List<String>
里添加的都是String
对象)。 - 运行时: JVM 在运行时并不知道泛型类型参数的具体信息。编译器在生成字节码时,会将泛型类型参数移除或替换掉,这个过程就是“擦除”。
- 编译时: 编译器会严格检查泛型类型的使用是否安全(例如,确保你向
- 擦除规则:
- 无界类型参数 (
<T>
): 被擦除为Object
。class Box<T> { T item; }
-> 擦除后class Box { Object item; }
- 有界类型参数 (
<T extends SomeClass & SomeInterface>
): 被擦除为边界中的第一个类型(类或接口)。class Box<T extends Number & Comparable> { T item; }
-> 擦除后class Box { Number item; }
(使用第一个边界Number
)
- 泛型方法中的类型参数: 同样遵循上述规则进行擦除。
- 类型转换: 编译器在必要的地方插入类型转换指令(称为
checkcast
),以保证类型安全。这些转换在字节码中是显式的。- 当你从
List<String> list
中获取元素String s = list.get(0);
时,编译器生成的字节码相当于String s = (String) list.get(0);
。
- 当你从
- 桥接方法: 为了保证多态性在类型擦除后仍然正确工作,编译器会在子类中生成合成的“桥接方法”。这是类型擦除中最复杂且对开发者透明的一部分。
- 无界类型参数 (
类型擦除的局限性
无法创建泛型类型数组
// 编译错误!
List<String>[] arrayOfLists = new List<String>[10];
原因: 数组在创建时需要在运行时确切知道其元素类型(new List<String>[10]
需要知道 List<String>
类型)。但类型擦除后,List<String>
和 List<Integer>
在运行时都是 List
(原始类型),JVM 无法区分它们。如果允许创建,可能会导致将错误的类型存入数组(破坏类型安全)。
无法使用 instanceof
检查泛型类型
List<String> list = new ArrayList<>();
// 编译错误!
if (list instanceof List<String>) { ... }
// 只能检查原始类型
if (list instanceof List) { ... } // 可以,但意义不大
原因: instanceof
是一个运行时操作符,需要检查对象的实际类型信息。但类型擦除后,List<String>
和 List<Integer>
在运行时都是 List
,无法区分。
无法创建类型参数的实例
class Box<T> {T createInstance() {// 编译错误!return new T();}
}
原因: new T()
需要在运行时知道 T
的具体类型来调用其构造函数。但类型擦除后,T
被替换为 Object
或边界类型,编译器无法确定调用哪个构造函数,甚至 T
是否有无参构造函数都不确定。
无法创建类型参数的数组
class Box<T> {// 编译错误!T[] createArray(int size) {return new T[size];}
}
原因: 与创建泛型类型数组类似。数组创建 new T[size]
需要在运行时知道 T
的具体类型,但类型擦除后无法得知。
静态上下文(静态变量、静态方法)不能引用类型参数
class Box<T> {// 编译错误!每个Box实例的T可能不同private static T staticField;// 编译错误!public static void staticMethod(T param) { ... }
}
原因: 静态成员属于类本身,而不是类的某个特定实例。类型参数 T
是在创建类的实例时才被具体化的(即使是在编译时具体化)。在静态上下文中,没有具体的 T
类型信息可用。擦除后,静态字段或方法的签名中根本不会有 T
的信息。
方法重载冲突
// 编译错误!方法签名冲突 (Erasure of method print(List<String>) is the same as print(List<Integer>))
public void print(List<String> list) { ... }
public void print(List<Integer> list) { ... }
原因: 类型擦除后,两个方法的签名都变成了 print(List list)
,它们在字节码层面是完全相同的,违反了方法重载要求签名不同的规则。
异常处理
- 不能在
catch
子句中使用类型参数(catch (T e)
是非法的)。 - 在方法声明中,
throws T
是允许的,但受限于类型擦除和异常处理的规则。
类型擦除的必要性:兼容性
Java 在 1.5 版本才引入泛型。类型擦除的设计是向后兼容的关键:
- 新代码使用泛型: 新编写的泛型代码(如
List<String>
)在编译后,其字节码与非泛型的旧代码(如List
)在表示上是一致的(原始类型List
+Object
转换)。 - 旧代码使用泛型集合: 老版本(Java 1.4 及之前)编译的字节码可以直接在支持泛型的 JVM (Java 5+) 上运行。这些旧代码使用的是原始类型(如
List
),而 JVM 看到的正是擦除后的原始类型。 - 新代码使用旧库: 使用泛型的新代码可以无缝调用和使用那些没有泛型的旧库(传递
List
给期望List<String>
的方法,编译器会警告但允许,需要@SuppressWarnings("unchecked")
)。
如果没有类型擦除,JVM 就需要修改以支持新的泛型类型表示,这将破坏“一次编写,到处运行”的承诺,导致旧版本 JVM 无法运行新编译的泛型代码。
总结
- 类型擦除是Java泛型实现的基石,核心是编译时检查类型安全,运行时移除类型参数信息。
- 擦除规则:无界->
Object
,有界->第一个边界类型,编译器插入强制转换。 - 主要限制:无法创建泛型数组/实例/参数数组,无法
instanceof
检查泛型,静态成员不能使用类型参数,特定方法重载冲突,异常处理限制。 - 根本原因:确保Java 5+的泛型代码与Java 5之前的非泛型代码之间的二进制兼容性。