泛型是 Java 的一大利器,它将类型检查从运行时提前到了编译时。这篇笔记不讲废话,只记录核心概念、易错点和个人的一些思考。
1. 为什么需要泛型?------ 类型安全
在没有泛型的时代,我们用 Object 来容纳任意类型的数据,但这带来了两个问题:
- 需要强制类型转换:取出来时,必须手动强转,容易出错。
- 类型不安全 :编译器无法检查你放的类型是否正确,只在运行时抛出
ClassCastException。
java
// 旧时代
List list = new ArrayList();
list.add("hello");
list.add(123); // 编译通过,但逻辑上可能出错
// 取出时必须强转,且容易出错
String str = (String) list.get(0); // 正常
Integer num = (Integer) list.get(1); // 正常
// String error = (String) list.get(1); // 运行时抛出 ClassCastException!
泛型的出现,就是为了解决这个问题。它像一个标签,告诉编译器这个容器里应该放什么类型的东西。
java
// 泛型时代
List<String> list = new ArrayList<>();
list.add("hello");
// list.add(123); // 编译时直接报错!完美!
String str = list.get(0); // 无需强转,安全、简洁
*由此泛型将类型安全的责任,转移给了编译器自动检查。
2. 泛型的基本使用
泛型可以用在类、接口和方法上。
-
泛型类/接口 :
<T>是一个类型占位符,可以是任意字母(T, E, K, V 等)。java// 泛型类 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("Hello World"); -
泛型方法 :方法拥有自己的类型参数,独立于类。
javapublic class Util { // <T> 是声明这是一个泛型方法,T 是方法的类型参数 public static <T> T getValue(T[] array, int index) { return array[index]; } } // 使用,编译器会自动推断 T 的类型 String value = Util.getValue(new String[]{"a", "b", "c"}, 1); Integer num = Util.getValue(new Integer[]{1, 2, 3}, 2);
思考:泛型方法什么时候用?当一个方法需要处理的逻辑是通用的,但操作的类型不确定时,泛型方法就非常合适。它比泛型类更灵活,因为它的类型作用域仅限于方法内部。
3. 进阶理解:通配符与 PECS 原则
这是泛型最容易混淆的地方,但也是精髓所在。
假设我们有这样的继承关系:Number 是 Integer 的父类。
java
List<Integer> integerList = new ArrayList<>();
// List<Number> numberList = integerList; // 编译报错!
报错是因为只是容器类的元素有继承关系而他们整体 List<Integer> 和 List<Number> 之间没有继承关系。List<Integer> 不是 List<Number> 的子类型。如果允许这样赋值,就会破坏类型安全:
java
// 假设上面那行代码能编译通过
List<Number> numberList = integerList; // integerList 里只能放 Integer
numberList.add(3.14); // 如果能通过,这里就放入了 Double
Integer i = integerList.get(0); // 取出时就会得到 Double,强转失败!
为了解决这种"泛型继承"的问题,Java 引入了通配符 ?。
3.1 上界通配符 ? extends T (Producer Extends)
List<? extends Number> 表示一个可以存放 Number 或其子类型(Integer, Double...)的列表,但我们不知道具体是哪种。
- 特点 :只能读取 ,不能写入 (除了
null)。 - 适用场景 :作为数据生产者,向外提供数据。
java
public void sum(List<? extends Number> list) {
double sum = 0.0;
for (Number n : list) {
sum += n.doubleValue(); // 安全,因为 list 里的元素肯定是 Number 或其子类
}
System.out.println("Sum: " + sum);
// list.add(1); // 编译错误!编译器不知道 list 的具体类型,可能是 List<Double>,不能放 Integer
// list.add(1.0); // 编译错误!同理,可能是 List<Integer>
}
3.2 下界通配符 ? super T (Consumer Super)
List<? super Integer> 表示一个可以存放 Integer 或其父类型(Number, Object...)的列表。
- 特点 :只能写入
T及其子类型,不能精确读取 (只能读出Object)。 - 适用场景 :作为数据消费者,接收数据。
java
public void addNumbers(List<? super Integer> list) {
list.add(1); // 安全,Integer 是 Integer 的子类
list.add(2); // 安全
// Number num = list.get(0); // 编译错误!list 可能是 List<Object>,取出来不一定是 Number
Object obj = list.get(0); // 只能确定是 Object
}
3.3 PECS 原则:Producer Extends, Consumer Super
这是 Joshua Bloch 在《Effective Java》中提出的著名原则,是判断使用 extends 还是 super 的黄金法则。
- 如果你只需要从集合中读取数据(生产者),就用
? extends T。 - 如果你只需要向集合中写入数据(消费者),就用
? super T。 - 如果既要读又要写,那就不要用通配符,用具体的泛型类型
<T>。
4. 底层机制:类型擦除
泛型是 Java 的语法糖,它在编译期有效,但在运行期会被"擦除"。
- 擦除规则 :
- 所有泛型类型参数都会被替换为它们的边界 (
<T extends Number>擦除后是Number)。 - 如果没有边界,则被替换为
Object。 - 因此,在运行时,
List<String>和List<Integer>其实都是List。
- 所有泛型类型参数都会被替换为它们的边界 (
java
// 编译前
List<String> stringList = new ArrayList<>();
stringList.add("abc");
// 编译后(虚拟机看到的代码类似这样)
List stringList = new ArrayList();
stringList.add("abc");
// 在 get() 时,编译器会自动插入一个强转 (String)
String s = (String) stringList.get(0);
类型擦除带来的限制与思考:
-
不能创建泛型数组 :
T[] array = new T[10];是非法的。- 原因 :类型擦除后,
new T[]会变成new Object[]。而Object[]不能被强转为String[]等,会抛出ArrayStoreException。这会破坏数组原本的类型安全机制。 - 解决方案 :
- 使用
ArrayList<T>代替数组,这是首选。 - 使用反射
Array.newInstance(Class<?> componentType, int length),并传入Class对象。
- 使用
- 原因 :类型擦除后,
-
instanceof操作符不能用于泛型类型 :obj instanceof List<String>是非法的。- 原因 :运行时
List<String>就是List,无法区分。 - 解决方案 :使用通配符
obj instanceof List<?>。
- 原因 :运行时
-
静态方法/变量不能使用类的泛型参数 :
- 原因 :泛型是实例级别的,每个实例的
T可能不同。而静态成员是类级别的,属于所有实例共享。编译器无法确定静态成员应该使用哪个T。 - 解决方案 :如果静态方法需要泛型,必须声明为独立的泛型方法 ,即自己拥有
<T>。
javapublic class MyClass<T> { // private static T data; // 编译错误 public static <U> U staticGenericMethod(U input) { // <U> 声明这是个泛型方法 return input; } } - 原因 :泛型是实例级别的,每个实例的
5. 实践技巧:使用 Class<T> 传递类型信息
由于类型擦除,我们在运行时无法获取 T 的具体类型。一个常见的变通方法是传入 Class<T> 对象。
java
public class JsonParser {
// Gson/Jackson 等库都大量使用这种模式
public static <T> T fromJson(String json, Class<T> clazz) {
// ... 解析逻辑
// 通过 clazz.newInstance() 或其他方式创建实例
// 通过反射获取字段类型
return null; // 示例
}
}
// 使用
User user = JsonParser.fromJson("{\"name\":\"test\"}", User.class);
思考 :Class<T> 就像是运行时泛型 。它把编译时的类型信息延续到了运行时,弥补了类型擦除的缺陷。
总结
- 核心价值 :泛型是编译时的类型安全工具。
- 关键语法:掌握泛型类、泛型方法的定义。
- 精髓 :理解 PECS 原则,这是灵活使用通配符的关键。
- 底层认知 :理解类型擦除,能解释很多"为什么不行"的问题,并找到变通方案。
- 实践 :遇到类型擦除的坑时,考虑使用
Class<T>对象作为类型令牌。