1
0
0

Java 泛型笔记:类型参数、通配符与类型擦除

2026-10-23

📝 笔记摘要:Java 泛型,编译期就把类型错误拦住

泛型解决的是一个具体问题:List 里塞进去什么类型,取出来的时候编译器不知道,只能靠强转硬扛,转错了要等到运行时才炸。泛型把这个「不知道」写进了类型里,编译期就能查。

这篇分五节:泛型是什么、泛型类和接口、泛型方法、通配符、类型擦除。类型擦除是最后一节,也是最容易漏的一节——它决定了为什么泛型类里不能 new T[],也决定了反射读泛型为什么要绕一圈。基础语法在 Java 基础笔记 里铺过,集合里 List<E> 怎么用,Java 集合笔记 那篇拆得细。

上篇 类型系统
  1. 什么是泛型 2. 泛型类与泛型接口
  3. 泛型方法

下篇 通配符与擦除
  4. 泛型通配符 5. 类型擦除

1. 什么是泛型

泛型是 JDK 5 引入的特性,做的事只有一件:把类型当成参数用。写 List<String> 的时候,String 不是随手写的备注,它是一个类型参数,编译器拿它来检查这个容器里到底允许放什么。

没有泛型的时候,编译器只知道容器里装的是 Object:往里塞什么都行,取出来只能强转,转错了要等运行到那一行才抛 ClassCastException。泛型把这道防线从运行时提到了编译期。

// 不带泛型:放进去没人拦,取出来要自己赌
List list = new ArrayList();
list.add("abc");
String s = (String) list.get(0);   // 编译通过,运行到这行才炸 ClassCastException

// 带泛型:类型写在声明里,编译器替你盯着
List<String> names = new ArrayList<>();
names.add("abc");
names.add(123);                     // 编译期就报错,压根跑不起来

泛型参数只能是引用类型
  List<int> 编译不过,要写 List<Integer>
  八个基本类型对应八个包装类,泛型系统里只认包装类
  自动装箱只在赋值和传参时发生,容器里实际存的是包装类对象
泛型是编译期的,运行时什么都不剩
  反射拿到的 List 里全是 Object,元素类型在字节码里被抹掉了
  这条代价单独占一节,见第 5 节

2. 泛型类与泛型接口

泛型类就是把类型参数写在类名后面,类里的字段、方法、构造器都能用这个类型参数。类型参数的名字只是约定,写 T 还是 E 还是 K 都不影响编译,但团队里最好统一:T 给类型、E 给元素、K/V 给键值对。

// 类名后面加尖括号,里面是类型参数列表
public class Box<T> {
    private T value;                       // 字段用 T

    public Box(T value) {
        this.value = value;
    }

    public T get() {                       // 返回值用 T
        return value;
    }

    public void set(T value) {
        this.value = value;
    }
}

接口写法完全一样,只是关键字换成 interface:

public interface Provider<T> {
    T get();
}

public class StringProvider implements Provider<String> {
    @Override
    public String get() {
        return "hello";
    }
}

泛型类之间继承时有个硬规则:子类必须把父类的类型参数接住。要么原样传下去,要么在父类那里就定死成一个具体类型。

// 父类的 T 由子类接住,子类可以再加自己的类型参数
public class ParentBox<T> {
}

public class ChildBox<T, E> extends ParentBox<T> {
}

// 子类不需要泛型,就要在父类那里写死
public class FixedBox extends ParentBox<String> {
}

泛型类里不能有「用类类型参数」的静态成员
  static T value; 和 static T get() { } 都编译不过
  静态成员属于类,不属于某个实例,而 T 是每个实例自己的类型
  绕法是让静态方法自己声明类型参数:public static <T> T of(T v)
泛型方法可以加 static
  方法和类一样是静态的,跟实例没关系,不冲突
菱形语法 new Box<>() 只在 new 的时候用
  声明变量那侧要写全:Box<String> b = new Box<>();

3. 泛型方法

泛型方法是方法自己带类型参数,写在修饰符后面、返回值类型前面。它跟类的泛型是两套独立的东西:类上的 T 在 new 的时候就定死了,方法上的 <T> 是每次调用时按实参推断出来的。

public class Utils {

    // 泛型方法:<T> 写在 public 后面、返回值前面
    public static <T> T first(List<T> list) {
        return list.get(0);
    }

    // 调用时不用写 <T>,编译器从实参推断
    List<String> names = Arrays.asList("a", "b");
    String first = Utils.first(names);        // T 推断成 String

    List<Integer> nums = Arrays.asList(1, 2);
    Integer n = Utils.first(nums);            // T 推断成 Integer
}

同一个类里,类级泛型和方法级泛型可以并存,名字甚至可以都叫 T,它们互不干扰。

public class Holder<T> {

    private T field;

    // 类里的普通方法用的是类的 T
    public T getField() {
        return field;
    }

    // 静态方法只能用自己声明的 T
    public static <E> E pick(E a, E b) {
        return a;
    }
}

泛型方法的位置记牢
  public static <T> T method(...),尖括号夹在修饰符和返回值之间
  写成 public T method(...) 的,那是类级泛型,不是泛型方法
类型推断能推出来就别手写
  Utils.<String>first(names) 是合法但多余的写法
  只有实参推不出来时才需要显式指定,比如传的是 null
返回值是泛型时,调用方不需要强转
  方法签名已经把类型说清楚了,String first = Utils.first(names); 直接赋值

4. 泛型通配符

通配符是给「不关心具体类型」的场合用的。尖括号里写死 String,这个容器就只能是 String 的容器;写 ?,就表示「里面是什么都行,我只负责传」。

三种写法,区别在能不能读、能不能写:

三种泛型通配符的读写权限对照:问号只能读成 Object,extends 上界只读不写,super 下界只写不读

? 任意类型
  只能读出 Object,不能写(写什么都可能类型不匹配)
? extends T 上界通配符
  只能是 T 或者 T 的子类,只读不写
? super T 下界通配符
  可以是 T 或者 T 的父类,只写不读
一句话记法:Producer Extends,Consumer Super
  生产者往外掏东西用 extends,消费者往里塞东西用 super

public class WildcardDemo {

    static double sum(List<? extends Number> list) {
        double total = 0;
        for (Number n : list) {          // 读:Number 及其子类都能当 Number 用
            total += n.doubleValue();
        }
        return total;
    }

    static void fill(List<? super Integer> list) {
        list.add(1);                     // 写:Integer 一定在 ? super Integer 的范围内
        list.add(2);
    }

    public static void main(String[] args) {
        List<Integer> ints = new ArrayList<>(List.of(1, 2, 3));
        List<Double> doubles = new ArrayList<>(List.of(1.5, 2.5));

        System.out.println(sum(ints));     // 6.0
        System.out.println(sum(doubles));  // 4.0

        List<Object> objects = new ArrayList<>();
        fill(objects);
        System.out.println(objects);       // [1, 2]
    }
}

这个「只读不写」「只写不读」不是编译器刁难,是类型系统逼出来的:List<? extends Number> 里实际装的可能是 List<Integer>,往里塞一个 Double 就把人家的类型承诺毁了。

上界通配符取出来只能当父类用
  List<? extends Number> 里取出的元素,类型只能是 Number
  想拿到 Integer 就得自己强转,编译器不给你这个承诺
下界通配符取出来只能是 Object
  List<? super Integer> 可能是 List<Object>,取出时只能是 Object
直接写具体类型最省事就别用通配符
  只有「方法要同时兼容多种类型」时才需要 ?
  一个泛型方法想同时接收 List<Integer> 和 List<Double>,就写 <? extends Number>

5. 类型擦除

泛型只活在编译期。编译完之后,Box<String> 和 Box<Integer> 在字节码里是同一个类 Box,类型参数被抹成了它的上界(没写上界就是 Object)。这个机制叫类型擦除。

泛型类型擦除前后对照:源码里的 List<String> 编译后变成 List,类型参数被抹掉并由编译器插入强转

所以运行时拿到的 Class 对象,看不出元素类型;编译器自己解决类型问题靠的是在调用点插入强转和桥接方法。

// 源码里写的是这个
Box<String> box = new Box<>("hi");
String v = box.get();

// 编译后大致是这个(编译器插入的强转)
Box box = new Box("hi");
String v = (String) box.get();   // 这里就是运行期 ClassCastException 的现场

类型擦除带来三条硬限制,写代码时会直接撞上:

public class ErasureLimit {

    public static <T> T[] newArray(int size) {
        // 编译不过:不能 new T[],运行时没人知道 T 是多长
        // return new T[size];

        Object[] tmp = new Object[size];         // 折中办法
        return (T[]) tmp;                         // 这里同样编译不过,要加 @SuppressWarnings
    }

    public static void main(String[] args) {
        Object o = "hello";
        // if (o instanceof String) {}            // 编译不过:运行时 T 没了

        if (o instanceof String) {               // 只能写成具体类型
            System.out.println(((String) o).length());
        }
    }
}

反射想在运行时把泛型信息捞回来,得走另一套 API:getGenericParameterTypes() 和 getGenericReturnType() 拿到的不是 Class 而是 Type,得判断是不是 ParameterizedType 再取真实参数。

Method method = SomeClass.class.getMethod("find", Map.class);
Type type = method.getGenericParameterTypes()[0];   // java.util.Map<java.lang.String, X>

if (type instanceof ParameterizedType) {
    ParameterizedType p = (ParameterizedType) type;
    Type[] args = p.getActualTypeArguments();        // [String, X]
}

擦除的直接后果:instanceof 不能判断泛型
  list instanceof List<String> 编译不过
  运行时只分得出 List 和 ArrayList,分不出元素类型
擦除的第二条后果:new T[] 不能写
  想造泛型数组,只能走 (T[]) new Object[n] 并自己保证类型安全
擦除的第三条后果:单例泛型类的静态字段不能共用
  class Cache<T> { static T item; } 编译不过,理由见第 2 节
JSON 反序列化靠泛型信息定位字段类型
  擦除让框架读不到 List<User> 里的 User,Jackson 这类库要靠 TypeReference 把泛型类型显式留下来
  反射读泛型的完整写法见 Java 注解与反射

💡 补充几个容易忽略的:
泛型不支持基本类型,也不支持数组
  List<int[]> 是非法的,数组本身就是对象,List<String[]> 才是合法的
泛型方法的类型参数可以自己声明上界
  <T extends Comparable<T>> 这种写法在做排序工具类时很常见
桥接方法是编译器生成的,不是你写的
  子类重写泛型父类方法时,编译器会补一个桥接方法保证多态能正常走,看到它在栈里出现不用慌
通配符和泛型方法能解决的问题不同
  通配符解决「参数类型兼容」,泛型方法解决「返回值类型精确」,两者可以同时用
读泛型源码之前先把集合那篇啃下来
  Java 集合笔记里的 List<E>、Map<K, V> 全是泛型,不懂泛型就看不懂集合的签名

🔥 一句话总结:泛型是编译期的类型检查,容器写什么类型编译器就管什么类型;泛型类把类型参数写在类名后面,泛型方法把类型参数写在修饰符和返回值之间,两者互相独立;通配符三条规则——? 只能读成 Object,? extends T 只读不写,? super T 只写不读,记法是 Producer Extends、Consumer Super;类型擦除决定了泛型类里不能有 static T、不能 new T[]、不能 instanceof T,反射读泛型得靠 ParameterizedType。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!