从一个批量处理 API 的设计问题出发,说明 Java 泛型为何不支持集合协变,如何正确使用 extends 与 super,以及输入集合、返回集合的可变性、快照和常见陷阱。

问题背景:为什么 List<Dog> 不能传给 List<Animal>

在业务代码里,批量接口经常从最简单的形式开始:

void process(List<Animal> animals) {
    // 批量处理动物
}

当调用方手里有 List<Dog> 时,很多人会直觉地认为:Dog 是 Animal 的子类,所以 List 也应该是 List。但 Java 会拒绝这样的调用。

这并不是泛型设计得不够灵活,而是为了避免类型污染。假设 Java 允许下面的赋值:

List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs;
animals.add(new Cat());
Dog dog = dogs.get(0); // 实际对象可能是 Cat

如果集合支持这种协变,编译器就无法保证 dogs 中始终只有 Dog。因此,Java 的泛型集合默认是不变的:List<Dog> 与 List<Animal> 是两种完全不同的类型。

工程中的真正问题是:批量接口通常并不需要把任意 Animal 写入调用方的集合,它可能只是读取集合中的元素,或者把元素交给一个消费者处理。此时,直接使用 List<T> 往往限制过强。

用 extends 表达“我只读取这批数据”

当一个方法只需要从集合中读取 T 类型的元素时,可以使用 Collection<? extends T>:

public void process(Collection<? extends Animal> animals) {
    for (Animal animal : animals) {
        System.out.println(animal.name());
    }
}

这里的意思不是“集合中的元素一定是 Animal”,而是“集合中的元素类型是 Animal 的某个子类型”。因此,下面几种调用都成立:

List<Dog> dogs = new ArrayList<>();
List<Cat> cats = new ArrayList<>();
List<Animal> animals = new ArrayList<>();

process(dogs);
process(cats);
process(animals);

从 Collection<? extends Animal> 中取出的元素,可以安全地当作 Animal 使用,因为无论实际类型是什么,它都一定是 Animal 的子类型。

但这个集合不能被安全地写入 Animal:

void process(Collection<? extends Animal> animals) {
    // animals.add(new Dog()); // 编译错误
}

因为调用方可能传入的是 Collection<Cat>,向其中添加 Dog 会破坏类型安全。唯一可以安全加入的值是 null,但业务代码通常不应该依赖这一点。

用 super 表达“我可以接收这种类型”

如果一个参数的职责是消费 T,而不是读取一个具体的 T 集合,通常应使用 ? super T。

例如,Consumer<Animal> 可以消费 Dog,因为处理 Animal 的逻辑自然也能处理 Dog:

Consumer<Animal> animalConsumer = animal ->
        System.out.println("处理:" + animal.name());

Consumer<? super Dog> dogConsumer = animalConsumer;

反过来,Consumer<Dog> 不能被当作 Consumer<Animal> 使用,因为它无法处理 Cat。

这就是常说的 PECS:Producer Extends,Consumer Super。数据生产者使用 extends,数据消费者使用 super。它不是要求所有泛型参数都套通配符,而是要根据参数实际承担的职责判断边界。

一个接近真实业务的批量 API

下面实现一个批量处理器,要求具备几个特征:

  • 可以接收 Dog、Cat 等 Animal 子类型的集合;
  • 通过消费者处理每个元素;
  • 返回结果时不暴露内部可变集合;
  • 对输入和元素中的 null 有明确约束。
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;

public class GenericCollectionDemo {

    public static void main(String[] args) {
        List<Dog> dogs = List.of(
                new Dog("旺财"),
                new Dog("小黑")
        );

        BatchProcessor<Animal> processor = new BatchProcessor<>(
                animal -> System.out.println("记录动物:" + animal.name())
        );

        processor.process(dogs);

        List<Animal> snapshot = processor.snapshot(dogs);
        System.out.println(snapshot.size());

        // snapshot.add(new Dog("新的狗"));
        // 抛出 UnsupportedOperationException,调用方不能修改返回结果
    }

    interface Animal {
        String name();
    }

    static final class Dog implements Animal {
        private final String name;

        Dog(String name) {
            this.name = Objects.requireNonNull(name, "name");
        }

        @Override
        public String name() {
            return name;
        }
    }

    static final class BatchProcessor<T> {
        private final Consumer<? super T> consumer;

        BatchProcessor(Consumer<? super T> consumer) {
            this.consumer = Objects.requireNonNull(consumer, "consumer");
        }

        void process(Collection<? extends T> source) {
            Objects.requireNonNull(source, "source");

            for (T item : source) {
                if (item == null) {
                    throw new IllegalArgumentException("source cannot contain null");
                }
                consumer.accept(item);
            }
        }

        List<T> snapshot(Collection<? extends T> source) {
            Objects.requireNonNull(source, "source");

            List<T> copy = new ArrayList<>(source.size());
            for (T item : source) {
                if (item == null) {
                    throw new IllegalArgumentException("source cannot contain null");
                }
                copy.add(item);
            }

            return Collections.unmodifiableList(copy);
        }
    }
}

BatchProcessor<Animal> 之所以能够处理 List<Dog>,是因为 process 的参数是 Collection<? extends T>。而构造方法使用 Consumer<? super T>,意味着它可以接受 Consumer<Animal>、Consumer<Object> 等能够处理 Animal 的消费者。

这里的 snapshot 有两个关键点。第一,它把输入复制到新的 ArrayList 中,避免返回结果与调用方持有的原集合共享结构。第二,它使用 Collections.unmodifiableList 禁止通过返回值修改这份结果。需要注意,不可修改只限制集合结构,不会自动让集合中的对象变成不可变对象。如果元素本身是可变对象,调用方仍可能修改元素内部状态。

返回值为什么通常不需要 ? extends

下面这种返回值看起来很灵活,但通常没有必要:

List<? extends Animal> findAnimals() {
    // ...
    return List.of();
}

调用方拿到这种结果后,只能把元素当作 Animal 读取,却很难继续组合、复制或传递给需要 Collection<Animal> 的代码。对外返回 List<Animal> 往往更容易使用,也更符合 API 的表达习惯。

泛型通配符主要用来放宽“输入边界”。返回值如果代表接口承诺的数据类型,通常应返回明确的 List<T>、Set<T> 或其他具体泛型类型。除非确实需要隐藏返回集合的具体元素类型,否则不要为了“看起来通用”而在返回值上大量使用通配符。

集合可变性:类型安全不等于不会被修改

泛型只解决元素类型问题,不解决集合是否可变的问题。以下代码经常产生误解:

List<String> source = new ArrayList<>();
List<String> view = Collections.unmodifiableList(source);

source.add("later");
System.out.println(view); // view 仍然会看到 source 的变化

unmodifiableList 是只读视图,不是独立副本。如果希望结果不受原集合后续变化影响,应先复制:

List<String> snapshot = Collections.unmodifiableList(
        new ArrayList<>(source)
);

相反,Arrays.asList 返回的是固定大小的列表,不能执行 add 和 remove,但可以执行 set;subList 返回的是原列表的视图,修改一方可能影响另一方。把这些集合直接作为公共 API 的返回值,容易让调用者误判其行为,应在边界处明确复制或包装。

常见坑与工程建议

1. 为了省事使用原始类型

List values = new ArrayList();
values.add("text");
values.add(1);

原始类型会关闭大量编译期检查,错误可能被推迟到运行时。公共方法、成员变量和局部变量都应尽量保留完整泛型信息。确实无法表达类型时,也应优先考虑 List<?>,而不是 List。

2. 把 List<?> 当成可以读取任意具体类型

List<?> 表示“某种未知类型的 List”。可以安全读取为 Object,但不能直接读取为 String 或调用方指定的类型:

void print(List<?> values) {
    for (Object value : values) {
        System.out.println(value);
    }
}

如果方法需要读取 Animal,就使用 List<? extends Animal>;如果需要加入 Animal,就使用 List<? super Animal>。不要用 List<?> 逃避设计边界。

3. 用强制类型转换弥补错误的 API

看到 List<Dog> 不能传给 List<Animal> 时,强制转换并不能解决问题,反而会隐藏真实的类型约束。应该回到方法职责,判断它是生产数据、消费数据,还是同时读写。如果一个方法既要读取又要写入,通常应使用明确的 List<T>,或者拆成两个职责更清晰的方法。

4. 忽略 null 策略

Collection<? extends T> 只约束了元素类型,并不排除 null。业务接口应明确:是拒绝 null、跳过 null,还是把 null 当作一种有意义的状态。建议在边界处统一校验,而不是让 null 在集合内部传播,最后在无关位置触发异常。

总结

泛型集合的工程化设计,重点不在于记住几个通配符写法,而在于准确表达数据流向:输入集合只读时使用 ? extends T,消费者参数使用 ? super T,需要同时读写时使用明确的 T。返回集合则应优先考虑稳定、易用的具体类型,并根据业务要求决定返回视图还是独立快照。

当类型边界和可变性边界都被明确表达后,调用方不需要依赖注释猜测 API 行为,编译器也能在更早阶段发现错误。一个好的集合接口,不只是“能传进去、能取出来”,还应让合法用法自然,让危险操作尽早变得不可编译。

最后修改:2026 年 10 月 04 日
如果觉得我的文章对你有用,请随意赞赏