Java泛型和类型擦除

11 阅读9分钟

一、为什么需要使用泛型

使用泛型的主要目的是为了增强编译期强制检查,提前发现错误,防止数据污染。

例:我需要使用ArrayList,用来存储每个人的姓名,在没有泛型的情况下会发生什么

ArrayList names = new ArrayList();
// 当没有泛型的时候,由于底层使用的是Object[],我们可以存入任何类型的数据
names.add("zhang san");
names.add("li si");
names.add(123);
// 由于我们本意是想让这个数组存放人命,但不知道是出于什么情况,错误的向其中添加了Integer,这时编译仍然是没有任何问题的,但当我们使用它时,就会发生错误
String name = (String) names.get(2);  // 获取123
// 虽然编译期通过了,但运行到这里时会发生ClassCastException
// 这种在运行时才发现的问题是我们不想看到的
// 所以我们可以使用泛型,他会在编译期帮助我们提早发现错误
List<String> names = new ArrayList<>();
names.add(123); // 这里会报错,无法通过编译

二、泛型的定义

  1. 类泛型定义

    public class Box<T> { // 这里的T就是我们定义的泛型参数Type Parameter
      // 我们可以在类中几乎任意地方使用泛型(静态的成员不可以直接使用类定义泛型)
      // 在字段中使用泛型类型
      private T value;
      // 在构造器中使用泛型参数
      public Box(T value) {
        this.value = value;
      }
      // 在方法中使用泛型参数
      public void setValue(T value) {
        this.value = value;
      }
    }
    
  2. 方法泛型定义

    // 对于方法定义的泛型,必须要在方法返回值之前完成定义,因为方法返回值也有可能使用到此泛型,在使用之前必须完成定义
    public <T> T getValue(T v1, T v2) {
      return v1 + v2;
    }
    
  3. 关于静态成员的泛型问题

    /*
    java中的静态成员为什么不可以直接使用类定义的泛型
    1、生命周期不同,静态成员在类加载时就已经初始化,而类定义泛型参数需要创建对象才能使用
    2、类成员在类加载时创建,并且在类中之存在一份,如果允许类成员使用类定义泛型,那么,我们每新创建一个对象,都可能会提供一个新的类型参数Type arguments,这时无法确定类成员到底是使用哪个类型参数
    ​
    类成员如果想要使用泛型,可以自定义泛型
    */
    public class Box {
      static <T> T value;
      
      public static <T> T getValue() {
        return value;
      }
    }
    
  4. Type Parameter 和 Type Argument的区别

    1. 本质区别:

      • Type Parameter(类型形参)是泛型定义时的占位符,如 class Box 中的 T
      • Type Argument(类型实参)是泛型使用时的具体类型,如 Box 中的 String
    2. 类比理解: 就像方法的形参和实参:

      • void method(String param) ← param 是形参
      • method("actual") ← "actual" 是实参
    3. 特殊形式: Type Argument 可以是具体类、接口,也可以是通配符(?, ? extends T, ? super T)

    4. 运行时差异: Type Parameter 会被类型擦除,Type Argument 在编译期用于类型检查

三、泛型的通配符

泛型通配符 ? 主要是用于在编译时,我们无法确定这里需要什么类型,来使用代替泛型参数的,主要是通过上界和下界对参数类型进行限制,达到程序正确运行的目的。

public static void print(List<? extends Number> list) {
        for (Number n : list)
            System.out.print(n + " ");
        System.out.println();
    }

四、上界下界

这里用货车装载货物来举例

情况1、

假设我们在某地订购了一车水果,当货车来送货时,我们需要将水果从车上搬运下来并切检查货物,如果货物不是水果,说明出现了错误

情况2、

假设我们向某地发送一车水果,货主来取货时,我们发现它的车无法装下这些水果,那也是一种错误。

针对情况1和情况2

我们需要保证情况1 从车上搬下来的所有货物都是水果 <? extends Fruit>

保证情况2 这辆车可以装载水果 <? super Fruit>

特性上界通配符 (? extends T)下界通配符 (? super T)
语法<? extends Fruit><? super Apple>
逻辑含义“货物”最高级别不能超过 Fruit“车厢”最低规格必须能装下 Apple
主要角色生产者 (Producer)消费者 (Consumer)
读取 (Get)安全:拿出来的全是 T 或其父类不安全:拿出来的全是 Object
写入 (Add)禁止:除了 null 什么都不能传安全:可以传 T 及其子类

上界 extends

顾名思义,上界就是划定了一个天花板,上面的例子中天花板就是Fruit,编译器知道所有的元素都必须在这个天花板之下,这样可以保证读取出来的每一个数据都至少是一个Fruit,可以保证读取安全。

但是对于写入来说,我们不知道它写入的具体是什么类型,所以除了null以外不能写入任何值

下界 super

下界就是地板,所有的元素都在地板之上,这样可以保证我们的空间至少是大于等于我们要写入的元素的,可以保证安全写入。

但是对于读取来说,因为每一个类都最终继承自Object,所以读出来会按Object读取,那么我们使用时就可能会需要强制转换,所以读取是不安全的。

PECS定律

Producer Extends Consumer Super

生产者读取使用extends,消费者写入用super

小思考:当我们有一个集合,里面的数据既需要读有需要写时该如何定义泛型?

答案是:需要使用具体类型 T

这里需要补充声明:

通配符可以支持上界和下界,但不支持多上界

具体泛型可以定义多个上界,但不支持下界

<T extend A & B & C>

如果定义的上界中包含类和接口,必须将类定义为第一个上界,(并且只能有一个类,其余只能是接口)

在类型擦除时,会被擦除为第一个上界

<? extends Number><T extends Number> 有什么区别?

  1. 本质区别:

    • 是通配符上界,用于"使用泛型"时,表示某个未知的 Number 子类
    • 是类型参数上界,用于"定义泛型"时,声明 T 必须是 Number 子类
  2. 核心差异:

    • ? 是匿名的,无法在代码中引用;T 是有名的,可以在方法体内使用
    • ? 只能用于方法参数或变量类型,不能用于创建实例;T 可以创建 List 实例
    • 返回类型上, 可以返回精确的 List,而 ? 只能返回 List<? extends Number>
  3. 使用场景:

    • 只读操作(Producer)用 ? extends T
    • 需要返回精确类型、或需要创建泛型实例时,必须用
  4. 记忆口诀(PECS): Producer Extends, Consumer Super 既读又写用 Type Parameter()

五、类型擦除

  1. 当类型没有边界时 如T 或?,类型会被替换为Object

  2. 当类型有上界时,类型会被替换为第一个上界,如 <T extends A & B> => A

  3. 当类型有下界时,类型会被替换为Object

  4. 桥接方法

    public interface BoxInterface<T> {
        T getT();
        void setT(T t);
    }
    ​
    public class BoxImpl implements BoxInterface<Integer> {
    ​
        @Override
        public Integer getT() {
            return 0;
        }
    ​
        @Override
        public void setT(Integer integer) {
            System.out.println("this is setT" + integer);
        }
    }
    // 当子类实现或继承一个接口或类时,如果父类中有泛型,并且在子类中指定了具体类型,就会出现下面这种情况,这时BoxImpl类的字节码
    ​
    ​
    public class generic.BoxImpl implements generic.BoxInterface<java.lang.Integer> {
      public thrid_week.generic.BoxImpl();
        Code:
           0: aload_0
           1: invokespecial #1                  // Method java/lang/Object."<init>":()V
           4: return
    ​
      public java.lang.Integer getT();
        Code:
           0: iconst_0
           1: invokestatic  #7                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
           4: areturn
    ​
      public void setT(java.lang.Integer);
        Code:
           0: getstatic     #13                 // Field java/lang/System.out:Ljava/io/PrintStream;
           3: aload_1
           4: invokedynamic #19,  0             // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/Integer;)Ljava/lang/String;
           9: invokevirtual #23                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
          12: return
    ​
      public void setT(java.lang.Object);
        Code:
           0: aload_0
           1: aload_1
           2: checkcast     #8                  // class java/lang/Integer
           5: invokevirtual #29                 // Method setT:(Ljava/lang/Integer;)V
           8: return
    ​
      public java.lang.Object getT();
        Code:
           0: aload_0
           1: invokevirtual #35                 // Method getT:()Ljava/lang/Integer;
           4: areturn
    }
    ​
    // 我们发现在泛型消除后,父类中的方法泛型应该被擦除并替换为Object,但是子类中实现的是Integer类型的方法,这样就无法构成重写关系,于是编译期帮助我们在子类中生成了Object类型的方法,在此方法中调用我们自己实现的方法,这样就可以在保证类关系的同时可以实现我们自己的需求
    

六、泛型擦除带来的一些问题

  1. 由于类型擦除机制,泛型无法使用 instanceof

    // 编译错误!无法对 Non-Reifiable 类型使用 instanceof
    if (list instanceof List<String>) {  // 非法!
        // ...
    }
    
  2. 无法创建泛型数组

    // ❌ 编译错误!
    T[] genericArray = new T[10];  // 非法!
    
  3. 无法创建泛型示例

    public <T> T createInstance() {
        // ❌ 编译错误!无法实例化类型参数
        return new T();  // 非法!
        
        // ❌ 编译错误!无法创建泛型数组
        T[] array = new T[10];  // 非法!
    }
    
  4. Varargs 警告可能会产生堆污染

    public static <T> void addAll(List<T> list, T... elements) {
        for (T element : elements) {
            list.add(element);
        }
    }
    // 想要保证不产生堆污染需要满足两个条件
    // 1、方法内部对 elements 只读不写
    // 2、不会发生逃逸,也就是不让elements离开此方法作用域
    // 满足这两种条件可以对方法添加@SafeVarargs注解
    
  5. 不能使用泛型作为异常处理

    // 1. 不能 catch 泛型异常(因为擦除后可能相同)
    try {
        // ...
    } catch (T e) {  // ❌ 编译错误!不能catch类型参数
    }
    
    // 2. 泛型类不能继承 Throwable
    class MyException<T> extends Exception {  // ❌ 编译错误!
    }
    
    // 3. 但可以在 throws 声明中使用类型参数(有限制)
    interface Task<T extends Throwable> {
        void run() throws T;  // ✅ 可以
    }
    

补充内容:

  1. 数组协变与泛型不变性

    // 数组协变
    String[] strs = new String[10];
    Object[] ojbs = strs;
    
    // 泛型的不变性是指:对于任何两种不同的类型 A 和 B,无论 A 和 B 之间是否存在继承关系,List<A> 与 List<B> 之间都没有任何继承关系。
    List<String> lists = new ArrayList<>();
    List<Object> listObjs = lists;
    // 错误
    
  2. 类型擦除绕过(也并非是真正绕过,只是通过一些办法来记录范型)

    import java.lang.reflect.ParameterizedType;
    import java.lang.reflect.Type;
    
    // 定义一个抽象类来捕获 T
    public abstract class TypeReference<T> {
        private final Type type;
    
        protected TypeReference() {
            // 获取带参数的父类类型:TypeReference<List<String>>
            Type superClass = getClass().getGenericSuperclass();
            // 提取真正的泛型参数:List<String>
            type = ((ParameterizedType) superClass).getActualTypeArguments()[0];
        }
    
        public Type getType() { return type; }
    }
    
    // 使用方式:创建一个匿名内部类
    TypeReference<List<String>> token = new TypeReference<List<String>>() {};
    System.out.println(token.getType()); // 输出:java.util.List<java.lang.String>