Blog

Java 泛型:类型擦除、上下界与通配符

泛型让编译器检查放进列表、盒子或方法里的是什么,然后在程序运行前把这些信息擦除。通过运行小程序,学会上下界、通配符、PECS,以及怎样读懂 JDK 的方法签名。

有了泛型,你可以写 List<String>,而不是一个什么都能装的列表。这样编译器会检查放进去的东西,取出来时你也不用再强制类型转换。泛型也是一些看似完全合理的代码编译不过的原因:你不能写 new T(),而 List<Integer> 也不是 List<Number>。只要看清编译器在检查完类型之后做了什么,这两条规则就说得通了。

本文讲泛型修复的那个 bug,泛型类、泛型 record 和泛型方法,有界类型,类型擦除,通配符和 PECS 规则,泛型接口,基本类型,以及怎样读懂 Collections.max 这样的签名。下面每个程序都在 Java 25 上跑过,输出直接从运行结果粘贴而来。想自己运行,就把代码存成 Main.java,再执行 java Main.java

为什么需要泛型:Object 列表出错太晚

Java 5 之前,List 里装的是 Object,所以你什么都能放进去,取出来的每样东西都得强制类型转换。下面就是这种写法,它今天依然合法:

@SuppressWarnings({"rawtypes", "unchecked"})
void main() {
    List names = new ArrayList();
    names.add("Ada");
    names.add(42);
    for (Object o : names) {
        String name = (String) o;
        IO.println(name.toUpperCase());
    }
}

输出后停止:

ADA
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')

bug 出在 names.add(42),程序却在两行之后的强制转换处崩溃。在真实的代码库里,add 和强制转换可能在不同的文件里,由不同的人写,前后相隔几个月。异常告诉你错误的值是在哪里被发现的,而不是在哪里被放进去的。

没有类型实参的 List 叫作原始类型(raw type)。本系列的每个程序都用 javac -Xlint:all -Werror 构建,这就是那行 @SuppressWarnings 存在的原因。去掉它,构建就会停下:

void main() {
    List names = new ArrayList();
    names.add("Ada");
    names.add(42);
    IO.println(names.size());
}

构建失败,报错:

Main.java:2: warning: [rawtypes] found raw type: List
Main.java:2: warning: [rawtypes] found raw type: ArrayList
Main.java:3: warning: [unchecked] unchecked call to add(E) as a member of the raw type List
Main.java:4: warning: [unchecked] unchecked call to add(E) as a member of the raw type List
error: warnings found and -Werror specified

没有 -Werror 时,这些只是警告,java Main.java 照样会运行代码。现在给列表加上类型实参:

void main() {
    List<String> names = new ArrayList<>();
    names.add("Ada");
    names.add(42);
    for (String name : names) {
        IO.println(name.toUpperCase());
    }
}

构建失败,报错:

Main.java:4: error: incompatible types: int cannot be converted to String
    names.add(42);
              ^

现在错误指向真正出错的那一行,而且什么都不会运行。循环里不需要强制转换,因为编译器已经知道每个元素都是 Stringnew ArrayList<>() 里那对空的 <> 叫菱形语法:编译器会从左边复制类型实参。

泛型类和泛型 record

泛型类在尖括号里声明一个类型参数,每次使用这个类时再把它填上。T 是类型的占位符,就像方法参数是值的占位符:

class Box<T> {
    private T value;

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

    T get() {
        return value;
    }

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

record Pair<A, B>(A first, B second) {}

void main() {
    Box<String> label = new Box<>("fragile");
    label.set("this side up");
    IO.println(label.get().length());

    var entry = new Pair<>("Ada", 36);
    String name = entry.first();
    int age = entry.second();
    IO.println(name + " is " + age);
    IO.println(entry);
}

输出:

12
Ada is 36
Pair[first=Ada, second=36]

label.get() 返回 String,所以不用强制转换就能调用 .length()Pair<A, B> 有两个参数,record 根据构造器实参把两个都填上:Pair<String, Integer>。按照惯例,类型参数用单个大写字母:T 表示类型,E 表示元素,KV 表示键和值。

泛型方法与类型推断

方法可以有自己的类型参数,声明在返回类型前面。每次调用时,编译器根据实参推算出它们是什么。这叫作类型推断

static <T> T firstOrDefault(List<T> items, T fallback) {
    return items.isEmpty() ? fallback : items.getFirst();
}

void main() {
    String city = firstOrDefault(List.of("Lisbon", "Pune"), "nowhere");
    Integer score = firstOrDefault(List.of(), 0);
    IO.println(city);
    IO.println(score);

    var mixed = List.<Number>of(1, 2.5);
    mixed.forEach(n -> IO.println(n.doubleValue()));
}

输出:

Lisbon
0
1.0
2.5

第一次调用里,TString。第二次调用里,List.of() 是空的,所以编译器从默认值 0 得出 T,把它定为 Integer

List.<Number>of(1, 2.5)显式类型见证(explicit type witness):由你自己指定 T,而不是让编译器推断。你很少需要它。我们在自己的方法上也试了同样的写法,Main.<String>firstOrDefault(...),javac 却对 Main 报了 cannot find symbol。紧凑源文件里的类没有能在代码中写出来的名字,所以你没法给它的静态方法加类型见证。

有界类型:对 T 提更多要求

普通的 T 可以是任何类型,所以你能在它上面调用的只有 Object 的方法。想调用 compareTo,就得告诉编译器 T 是可比较的。不告诉它会怎样:

static <T> T max(List<T> items) {
    T best = items.getFirst();
    for (T item : items) {
        if (item.compareTo(best) > 0) {
            best = item;
        }
    }
    return best;
}

void main() {
    IO.println(max(List.of(3, 9, 4)));
}

构建失败,报错:

Main.java:4: error: cannot find symbol
        if (item.compareTo(best) > 0) {
                ^

加一个界限(bound)就能解决。<T extends Comparable<T>> 的意思是“任何 T,只要一个 T 能和另一个 T 比较”。这里即使是接口,也用 extends

static <T extends Comparable<T>> T max(List<T> items) {
    T best = items.getFirst();
    for (T item : items) {
        if (item.compareTo(best) > 0) {
            best = item;
        }
    }
    return best;
}

void main() {
    IO.println(max(List.of(3, 9, 4)));
    IO.println(max(List.of("pear", "apple", "plum")));
}

输出:

9
plum

一个类型参数可以有多个界限,用 & 连接。如果其中有类,类要放在最前面。<T extends Number & Comparable<T>> 让同一个方法既能用 NumberdoubleValue(),又能用 ComparablecompareTo

static <T extends Number & Comparable<T>> String summary(List<T> items) {
    T best = items.getFirst();
    double total = 0;
    for (T item : items) {
        total += item.doubleValue();
        if (item.compareTo(best) > 0) {
            best = item;
        }
    }
    return "max " + best + ", total " + total;
}

void main() {
    IO.println(summary(List.of(3, 9, 4)));
    IO.println(summary(List.of(1.5, 0.25)));
}

输出:

max 9, total 16.0
max 1.5, total 1.75

记住这一节。我们的 max 有个缺口,本文结尾会碰到它;Collections.max 的签名之所以更古怪,正是因为它没有这个缺口。

类型擦除:先检查类型,再把它去掉

Java 在编译时检查泛型类型,然后把它们去掉。到了运行时,List<String>List<Integer> 都只是 ArrayList

record Box<T>(T value) {}

record Ranked<T extends Comparable<T>>(T value) {}

void main() {
    List<String> words = new ArrayList<>();
    List<Integer> numbers = new ArrayList<>();
    IO.println(words.getClass() == numbers.getClass());
    IO.println(words.getClass().getName());

    IO.println(Box.class.getRecordComponents()[0].getType());
    IO.println(Ranked.class.getRecordComponents()[0].getType());
}

输出:

true
java.util.ArrayList
class java.lang.Object
interface java.lang.Comparable

所有列表共用一个类对象。运行时没有任何地方记录一个列表原本是用来装字符串还是数字的。最后两行显示了编译后的 record 里 T 还剩下什么:普通的 T 变成 Object,有界的 T 变成它的第一个界限 Comparable。这种替换叫作类型擦除

用十岁孩子能懂的话说

泛型盒子就像一个饭盒,盖子上贴着标签:“只装三明治”。装饭盒的时候,大人会看一眼标签。你想塞一只袜子进去,他们当场就会拦住你。

饭盒装好以后,放进书包之前,标签就被撕掉了。到了午饭时间,没人检查标签,因为标签已经没了。其实也不需要检查。之前已经查过,所以里面装的就是三明治。

所以书包里的每个饭盒看起来都一样。你没法问书包里的饭盒“你以前是装三明治的吗?”,因为答案已经跟着标签进了垃圾桶。

准确的说法

javac 会根据类型实参检查泛型类型的每一次使用。然后它编译代码,把每个类型参数替换成它的擦除类型,也就是它的第一个界限;没有界限时就是 Object。如果你的代码读取一个 T 并把它当作 String 使用,javac 会在那里插入一个隐藏的强制转换。ArrayList 的字节码只有一个类,由每一个 ArrayList<Whatever> 共用。

Java 5 选择擦除,是为了让泛型代码和旧的非泛型代码能用同样的 class 文件一起运行。代价是类型实参在运行时不存在,所以任何需要在运行时知道它的操作都写不出来。

这个比喻的局限:标签并不总是完全消失。字段、方法参数和父类的声明类型会在 class 文件里保留泛型签名,反射可以读到。丢掉的是每个对象的类型实参:某个具体的 ArrayList 不知道自己是作为 ArrayList<String> 创建的。而且大人也可能被骗:原始类型或非受检的强制转换会跳过检查,这时袜子真的可能进到饭盒里。

擦除导致的限制

你不能创建 T,因为运行时没有 T 可供调用构造器:

class Factory<T> {
    T make() {
        return new T();
    }
}

void main() {
    IO.println(new Factory<String>());
}

构建失败,报错:

Main.java:3: error: unexpected type
        return new T();
                   ^

你也不能创建 T[]。数组会在运行时保存它的元素类型,而擦除意味着没有类型可存:

<T> T[] makeArray(int size) {
    return new T[size];
}

void main() {
    IO.println(makeArray(3).length);
}

构建失败,报错:

Main.java:2: error: generic array creation
    return new T[size];
           ^

绕开这两个限制的常见做法,是传进一个能负责创建的东西,比如 Class<T>Supplier<T>

你也不能问一个对象它是不是 List<String>,因为对象自己并不知道:

void main() {
    Object thing = new ArrayList<String>();
    IO.println(thing instanceof List<String>);
}

构建失败,报错:

Main.java:3: error: Object cannot be safely cast to List<String>
    IO.println(thing instanceof List<String>);
               ^

这一点出乎我们的意料。这个错误并不是说 instanceof 里禁止用泛型,而是关于安全。如果变量是 Collection<String>,那么 thing instanceof List<String> 能编译也能运行,因为凡是通过检查的,确实就是字符串列表。这是 Java 16 加入的:javac --release 15 会拒绝它。如果从 Object 出发,就改用 instanceof List<?>

最后,只有类型实参不同的两个重载方法不能放在同一个类里:

void print(List<String> items) {
    IO.println("strings");
}

void print(List<Integer> items) {
    IO.println("integers");
}

void main() {
    print(List.of("a"));
}

构建失败,报错:

Main.java:5: error: name clash: print(List<Integer>) and print(List<String>) have the same erasure
void print(List<Integer> items) {
     ^

擦除之后,两个方法都是 print(List),而一个 class 文件里不能有名字和参数都相同的两个方法。给它们起不同的名字。

如果原始类型或非受检的强制转换让一个 List<String> 变量指向了装着 Integer 的列表,这就叫作堆污染(heap pollution)。之后 ClassCastException 会在离肇事代码很远的地方冒出来,就像第一个程序那样。

通配符:List<Integer> 不是 List<Number>

IntegerNumber,所以看起来 List<Integer> 也应该是 List<Number>。编译器说不行:

void main() {
    List<Integer> ints = new ArrayList<>(List.of(1, 2));
    List<Number> nums = ints;
    nums.add(2.5);
    IO.println(ints);
}

构建失败,报错:

Main.java:3: error: incompatible types: List<Integer> cannot be converted to List<Number>
    List<Number> nums = ints;
                        ^

原因在第 4 行。如果允许第 3 行,nums.add(2.5) 就会把一个 Double 放进列表,而 ints 仍然承诺这个列表里只有整数。泛型类型是不变的(invariant):除非 AB 是同一个类型,否则 List<A>List<B> 毫无关系。

数组做了相反的选择,代价在运行时付出:

void main() {
    Integer[] ints = {1, 2};
    Number[] nums = ints;
    IO.println(nums[0]);
    nums[1] = 2.5;
}

输出后停止:

1
Exception in thread "main" java.lang.ArrayStoreException: java.lang.Double

数组是协变的(covariant):Integer[]Number[],所以赋值能编译通过。同样错误的写入到运行时才失败。这之所以行得通,只是因为数组记得自己的元素类型,而这恰恰是被擦除的泛型做不到的。所以泛型改为在编译时抓住这个错误。

? extends T:只从中读取的列表

不变性会让一个简单的方法变得没用。sum(List<Number>) 接收不了 List<Integer>通配符能解决这个问题。List<? extends Number> 的意思是“某种类型的列表,这个类型是 Number 或它的子类型”:

double sum(List<? extends Number> items) {
    double total = 0;
    for (Number n : items) {
        total += n.doubleValue();
    }
    return total;
}

void main() {
    IO.println(sum(List.of(1, 2, 3)));
    IO.println(sum(List.of(1.5, 2.5)));
}

输出:

6.0
4.0

读取是安全的。不管列表实际装的是什么,每个元素都是某种 Number。写入就不安全了:

void main() {
    List<Integer> ints = new ArrayList<>();
    List<? extends Number> nums = ints;
    nums.add(2.5);
}

构建失败,报错:

Main.java:4: error: incompatible types: double cannot be converted to CAP#1
    nums.add(2.5);
             ^

CAP#1 是 javac 给“这个 ? 背后的未知类型”起的名字。它可能是 Integer,也可能是 Double,编译器无法证明 2.5 放得进去,所以拒绝。除了 null,你什么都不能往 List<? extends Number> 里加。

? super T:只往里写入的列表

List<? super Integer> 的意思是“Integer 或它某个父类型的列表”。它可能是 List<Integer>List<Number>List<Object>。这些列表都能装 Integer,所以往里加一个是安全的。读取时只能拿到 Object,因为你不知道手上是哪一种列表。

两种通配符在复制方法里碰面,这个方法从一个列表读取,再写进另一个列表:

static <T> void copy(List<? super T> dst, List<? extends T> src) {
    for (T item : src) {
        dst.add(item);
    }
}

void main() {
    List<Integer> ints = List.of(1, 2);
    List<Double> doubles = List.of(2.5);
    List<Number> numbers = new ArrayList<>();
    List<Object> anything = new ArrayList<>(List.of("start"));

    copy(numbers, ints);
    copy(numbers, doubles);
    copy(anything, ints);
    IO.println(numbers);
    IO.println(anything);
}

输出:

[1, 2, 2.5]
[start, 1, 2]

同一个方法把整数复制进 List<Number>List<Object>,又把浮点数复制进同一个 List<Number>。如果写成 copy(List<T> dst, List<T> src),只有第一次调用能编译,而且前提是两个列表的 T 都是 Integer,可 numbers 并不是。JDK 自己的 Collections.copy 就是这个签名。

经验法则是 PECS:producer extends, consumer super(生产者用 extends,消费者用 super)。如果一个参数产出值给你的方法读取,用 extends。如果它消费你的方法写入的值,用 super。两者都有,就用普通的 T

生产者 extends:值流出,你来读 src: List<? extends T> for (T item : src) copy(dst, src) 消费者 super:值流入,你来写 copy(dst, src) dst.add(item) dst: List<? super T> 往 src 添加:编译不过。从 dst 读取:只能拿到 Object。

源列表产出 T 值,所以它的参数用 extends,方法只从中读取。目标列表消费 T 值,所以它的参数用 super,方法只往里写入。

单独的 ?:任何列表都行

无界通配符 List<?> 的意思是“某种类型的列表,我不关心是哪种”。如果方法只需要对所有列表都成立的操作,比如 size() 或打印,就用它:

String describe(List<?> items) {
    return items.size() + " items, first is " + items.getFirst();
}

void main() {
    IO.println(describe(List.of("a", "b")));
    IO.println(describe(List.of(3.5, 7.0, 1.0)));
}

输出:

2 items, first is a
3 items, first is 3.5

List<?> 和原始的 List 不一样。原始类型关掉了检查,所以你什么都能加。List<?> 保留检查,所以除了 null 你什么都不能加,javac 也不会对它发出警告。

泛型接口:Comparable<T> 和仓储

接口也能带类型参数,JDK 里用得最多的就是 Comparable<T>。实现 Comparable<Version> 意味着一个 Version 能和另一个 Version 比较,这样我们的有界 max 就能接受它:

record Version(int major, int minor) implements Comparable<Version> {
    @Override
    public int compareTo(Version other) {
        if (major != other.major) {
            return Integer.compare(major, other.major);
        }
        return Integer.compare(minor, other.minor);
    }
}

static <T extends Comparable<T>> T max(List<T> items) {
    T best = items.getFirst();
    for (T item : items) {
        if (item.compareTo(best) > 0) {
            best = item;
        }
    }
    return best;
}

void main() {
    var versions = List.of(new Version(21, 0), new Version(25, 1), new Version(25, 0));
    IO.println(max(versions));
}

输出:

Version[major=25, minor=1]

compareTo 接收的是 Version 而不是 Object,所以里面既不需要强制转换,也不需要 instanceof

你自己的接口也可以用同样的方式泛型化。业务代码里常见的一个例子是仓储(repository),它按 ID 存储和查找实体。Repository<T, ID> 适用于任何实体类型和任何 ID 类型:

interface Repository<T, ID> {
    void save(ID id, T item);

    Optional<T> findById(ID id);

    List<T> findAll();
}

record User(String name) {}

class InMemoryRepository<T, ID> implements Repository<T, ID> {
    private final Map<ID, T> items = new LinkedHashMap<>();

    @Override
    public void save(ID id, T item) {
        items.put(id, item);
    }

    @Override
    public Optional<T> findById(ID id) {
        return Optional.ofNullable(items.get(id));
    }

    @Override
    public List<T> findAll() {
        return List.copyOf(items.values());
    }
}

void main() {
    Repository<User, Integer> users = new InMemoryRepository<>();
    users.save(1, new User("Ada"));
    users.save(2, new User("Linus"));
    IO.println(users.findById(2));
    IO.println(users.findById(7));
    IO.println(users.findAll());
}

输出:

Optional[User[name=Linus]]
Optional.empty
[User[name=Ada], User[name=Linus]]

users.save("one", ...) 编译不过,因为这个仓储的 IDInteger。Spring Data 这类框架的仓储也是基于同样的思路构建的。讲 Optional 的那一篇会介绍 findById 返回的是什么。

基本类型与泛型:没有 List<int>

类型实参必须是引用类型,所以不允许用 int 这样的基本类型:

void main() {
    List<int> scores = new ArrayList<>();
    IO.println(scores);
}

构建失败,报错:

Main.java:2: error: unexpected type
    List<int> scores = new ArrayList<>();
         ^

原因还是擦除。类型参数会变成 Object 或某个界限,而 int 不是对象。你要写 List<Integer>,Java 在放进去时把每个 int 装箱成 Integer,取出来时再拆箱。这很方便,但有一个陷阱,每个人都会栽一次:

void main() {
    List<Integer> scores = new ArrayList<>(List.of(1, 2, 3));
    scores.remove(1);
    IO.println(scores);
    scores.remove(Integer.valueOf(1));
    IO.println(scores);
}

输出:

[1, 3]
[3]

List 有两个 remove 方法:remove(int index)remove(Object o)scores.remove(1) 选中了 int 那个,因为它不需要装箱,所以删掉的是下标 1 处的元素,也就是 2。如果你想删的是值 1,就传一个 Integer,像第二次调用那样。

装箱还要占内存:List<Integer> 为每个数字保存一个指向独立对象的引用。Valhalla 项目正在开发值类(value class),能缩小这个差距,但它们不在 Java 25 里。

读懂 JDK 里的泛型签名

JDK 里的泛型签名看上去很密,但每一部分只回答一个问题,而这些部分你现在都见过了。下面是 Collections.max 的声明:

public static <T extends Object & Comparable<? super T>> T max(Collection<? extends T> coll)

一次读一部分:

  1. <T ...>max 是一个泛型方法,有一个类型参数 T
  2. T extends ... Comparable<? super T>T 必须能和 T T 的某个父类型比较。这又是 PECS。比较操作消费一个 T,所以用 super
  3. Collection<? extends T> coll:实参产出 T 值,所以用 extends。只要元素是 T 或其子类型,任何集合都行,不只是 List
  4. max 前面的 T:方法返回一个 T
  5. Object &:第一个界限决定擦除结果。有了它,擦除后的方法返回 Object,和 Java 5 之前的 max 完全一样,所以调用它的旧编译代码仍然能链接。

第 2 步正是我们自己的 max 的缺口。LocalDate 并没有实现 Comparable<LocalDate>,它实现的是 Comparable<ChronoLocalDate>,一个位于它上层的接口。我们的 <T extends Comparable<T>> 接受不了它:

static <T extends Comparable<T>> T max(List<T> items) {
    T best = items.getFirst();
    for (T item : items) {
        if (item.compareTo(best) > 0) {
            best = item;
        }
    }
    return best;
}

void main() {
    var dates = List.of(LocalDate.of(2026, 3, 1), LocalDate.of(2026, 9, 14));
    IO.println(max(dates));
}

构建失败,报错:

Main.java:13: error: method max in class Main cannot be applied to given types;
  reason: inference variable T has incompatible equality constraints ChronoLocalDate,LocalDate

Collections.max 能接受同一个列表,反射也证实了第 5 步:

void main() throws NoSuchMethodException {
    var dates = List.of(LocalDate.of(2026, 3, 1), LocalDate.of(2026, 9, 14));
    IO.println(Collections.max(dates));

    var erased = Collections.class.getMethod("max", Collection.class);
    IO.println(erased.getReturnType());
}

输出:

2026-09-14
class java.lang.Object

Comparable<? super T>T 可以是 LocalDate,而比较操作定义在 ChronoLocalDate 上。如果你写的库方法要比较东西,照搬这个形式。getMethod("max", Collection.class) 按擦除后的参数类型找到方法,因为运行时只剩下这个类型。

要点

  • 泛型把类型错误从运行时提前到编译时。原始的 List 会把迟到的 ClassCastException 带回来,-Xlint:all 会对它发出警告。
  • 类型参数写在类名后面(Box<T>),或方法返回类型前面(<T> T first(...))。编译器通常能推断出它们。
  • 界限 <T extends Comparable<T>> 让你能在 T 上调用界限类型的方法。多个界限用 & 连接。
  • 类型实参在检查之后就被擦除。所以 new T()new T[]、从 Object 出发的 instanceof List<String>,以及只有类型实参不同的重载,都编译不过。
  • List<Integer> 不是 List<Number>。数组是协变的,出错时改在运行时抛出 ArrayStoreException
  • PECS:只读的参数用 ? extends T,只写的参数用 ? super T,类型无关紧要时用 ?
  • 类型实参必须是引用类型,所以 int 会被装箱成 Integer。小心 remove(int)remove(Object) 的区别。

编译器检查完类型实参就把它扔掉,所以泛型的每条规则,说的都是它在扔掉之前能证明什么。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。