从一个批量处理 API 的设计问题出发,说明 Java 泛型为何不支持集合协变,如何正确使用 extends 与 super,以及输入集合、返回集合的可变性、快照和常见陷阱。
问题背景:为什么 List<Dog> 不能传给 List<Animal>
在业务代码里,批量接口经常从最简单的形式开始:
void process(List<Animal> animals) {
// 批量处理动物
}当调用方手里有 List<Dog> 时,很多人会直觉地认为:Dog 是 Animal 的子类,所以 List
这并不是泛型设计得不够灵活,而是为了避免类型污染。假设 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 行为,编译器也能在更早阶段发现错误。一个好的集合接口,不只是“能传进去、能取出来”,还应让合法用法自然,让危险操作尽早变得不可编译。