《Java 编程入门》11.2 通配符与 PECS

从 List<Book> 不能传给 List<Object> 的编译错误出发,解释泛型不变性与数组协变的区别,拆开 ? extends、? super 与 ? 的读写能力,再用 PECS 规则改造 library 的 maxBy、copyInto 和查询接口,最后给出通配符捕获与常见误用的真实报错。

本节目标:看懂 ? extends T、? super T 和 ? 各自允许读什么、写什么,能按 PECS 规则设计方法签名,并说清 List<Book> 为什么不能当 List<Object> 用。
适用版本:JDK 25 LTS(Temurin 25.0.5);通配符语法在 JDK 21 上没有差异。

上一节留下的问题

Repository<T, ID> 解决了仓储重复的问题,但马上会遇到新的编译错误。假设我们想写一个方法,把一组书复制到另一个容器里:

static void copyAll(List<Object> target, List<Book> source) {
    target.addAll(source);
}

调用方手里只有一个书架 List<Book> shelf,想把它自己的书复制到另一个书架:

List<Book> shelf = new ArrayList<>();
copyAll(shelf, shelf);
错误: 不兼容的类型: List<Book>无法转换为List<Object>
        copyAll(shelf, shelf);
                ^

List<Book> 不能传给 List<Object> 参数,哪怕 Book 是 Object 的子类型。如果强行赋值,同样编译失败:

List<Object> objects = new ArrayList<Book>();
错误: 不兼容的类型: ArrayList<Book>无法转换为List<Object>

这不是编译器故意刁难,而是泛型为了保证类型安全做的取舍。理解这个取舍,才能理解通配符为什么存在。

数组协变留下的坑

在泛型之前,Java 的数组是协变的:String[] 是 Object[] 的子类型。

Object[] objects = new String[1];
objects[0] = 42;

这段代码能编译,但运行时报错:

Exception in thread "main" java.lang.ArrayStoreException: java.lang.Integer
	at ArrayCovProbe.main(ArrayCovProbe.java:4)

数组在运行时知道自己装的是 String,所以拒绝存入 Integer。问题是错误发生在运行时,而且写代码的人可能离赋值语句很远。

泛型选择了相反的策略:不型变(invariant)。List<Book> 和 List<Object> 没有子类型关系,把 List<Book> 赋给 List<Object> 直接编译失败。代价是写法更啰嗦,收益是类型错误在编译期就被拦住——如果 List<Book> 能当 List<Object> 用,你就能往里塞一个 Member,然后所有读 Book 的地方都会在运行时炸掉。

三个通配符,三种读写能力

泛型不型变,但业务代码确实需要「接受 Book 及其子类的列表」或「接受能装 Book 的容器」。通配符就是为这种需求开的口子。

写法含义能读成什么能写入什么
List<? extends Book>Book 或它的子类型Book除 null 外不能写
List<? super Book>Book 或它的父类型ObjectBook 及其子类型
List<?>未知类型Object除 null 外不能写

? extends T:上界,适合读

List<? extends Number> source = List.of(1, 2, 3);

Number first = source.get(0);
// source.add(4); // 编译错误

source 的实际类型可能是 List<Integer>,也可能是 List<Double>。编译器只知道「元素至少是 Number」,所以读出来的东西一定能赋给 Number;但写入时无法确定这个列表到底允许 Double 还是只允许 Integer,因此一律拒绝。

? super T:下界,适合写

List<? super Book> target = new ArrayList<Object>();
target.add(new Book("978-7-111-54742-6", "Effective Java", "Joshua Bloch", 2018));

Object value = target.get(0); // 只能读成 Object
// Book book = target.get(0); // 编译错误

target 的实际类型可能是 List<Book> 或 List<Object>。编译器知道「它至少能装 Book」,所以写入 Book 是安全的;但读出来时无法确定具体是哪个父类型,只能当成 Object。

?:完全未知

List<?> 等价于 List<? extends Object>,但语义更弱:你连「元素是 Object 的子类型」之外的任何信息都没有。能调用的方法只有不依赖元素类型的那几个,比如 size()、isEmpty()、clear()。它适合写「只关心集合大小或遍历打印」的方法。

PECS:Producer Extends, Consumer Super

把上面的规律压缩成一句口诀,就是 PECS:

  • 如果参数是生产者(方法从里面读 T),用 ? extends T。
  • 如果参数是消费者(方法往里面写 T),用 ? super T。
  • 如果既要读又要写,或者类型要出现在返回值里,就用具体的 T。

JDK 里到处都是这条规则:

API签名片段角色
Collection.addAllCollection<? extends E>生产者
Collections.copyList<? super T> dest, List<? extends T> src消费者 + 生产者
Iterable.forEachConsumer<? super T>消费者
List.sortComparator<? super E>消费者
Stream.maxComparator<? super T>消费者

一个最小的复制方法可以写成:

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

src 只负责产出 T,所以用 ? extends T;dest 只负责接收 T,所以用 ? super T。调用时 T 可以由两个参数共同推断,既能把 List<Book> 复制到 List<Object>,也能把 List<Book> 复制到 List<Book>。

注意方法上的 <T> 不能省。T 出现在两个参数之间,负责建立「源元素能写进目标」这层关系;如果只写 List<? super Object> 和 List<? extends Object>,就丢掉了这层关系。

在 library 里落地

library 的查询工具类正好需要这三种形状。下面三个方法分别处理「找最大」「复制」「遍历」:

public final class LibraryQueries {
    private LibraryQueries() {
    }

    public static <T> T maxBy(Collection<? extends T> items,
                              Comparator<? super T> order) {
        Iterator<? extends T> it = items.iterator();
        if (!it.hasNext()) {
            throw new IllegalArgumentException("empty collection");
        }
        T best = it.next();
        while (it.hasNext()) {
            T candidate = it.next();
            if (order.compare(candidate, best) > 0) {
                best = candidate;
            }
        }
        return best;
    }

    public static <T> void copyInto(Collection<? super T> target,
                                    Collection<? extends T> source) {
        for (T item : source) {
            target.add(item);
        }
    }

    public static void eachBook(Collection<? extends Book> books,
                                Consumer<? super Book> action) {
        for (Book book : books) {
            action.accept(book);
        }
    }
}

逐个看签名:

  • maxBy 返回 T,所以 T 必须是方法级类型参数;items 只读,用 ? extends T;order 只消费 T,用 Comparator<? super T>。这样 Comparator<Object> 也能传给 maxBy,因为它能比较任何对象,当然也能比较 Book。
  • copyInto 的 target 是消费者,用 Collection<? super T>;source 是生产者,用 Collection<? extends T>。调用方可以把 List<Book> 复制进 List<Object>。
  • eachBook 只读图书,所以 Collection<? extends Book>;回调只消费图书,所以 Consumer<? super Book>。传一个 Consumer<Object> 也能工作。

在服务层可以再包一层,把仓储里的书收集到调用方提供的容器:

public void collectAllBooks(Collection<? super Book> target) {
    LibraryQueries.copyInto(target, books.findAll());
}

这个方法不关心 target 到底是 List<Book>、List<Object> 还是 Set<Object>,只要它能接收 Book 即可。

运行一个完整例子:

Book latest = LibraryQueries.maxBy(DemoData.BOOKS,
        Comparator.comparingInt(Book::year));
System.out.println("latest = " + latest.title() + " (" + latest.year() + ")");

List<Object> sink = new ArrayList<>();
LibraryQueries.copyInto(sink, DemoData.BOOKS);
System.out.println("copied = " + sink.size());

Consumer<Object> printer = item -> System.out.println("item: " + item);
LibraryQueries.eachBook(DemoData.BOOKS.subList(0, 2), printer);

Comparator<Object> byToString = Comparator.comparing(Object::toString);
Book last = LibraryQueries.maxBy(DemoData.BOOKS, byToString);
System.out.println("toString max = " + last.title());

本机 JDK 25 输出:

latest = 深入理解 Java 虚拟机 (2019)
copied = 6
item: Book[isbn=978-7-111-54742-6, title=Effective Java, author=Joshua Bloch, year=2018]
item: Book[isbn=978-7-111-11091-5, title=Java Puzzlers, author=Joshua Bloch, year=2005]
toString max = 深入理解 Java 虚拟机

Comparator<Object> 能传给 maxBy,是 ? super T 最直观的价值:比较器只需要能比较 Book,而一个能比较所有 Object 的比较器当然满足这个条件。

常见错误与边界

错误一:往 ? extends 里写

static void addAll(List<? extends String> target) {
    target.add("x");
}
错误: 不兼容的类型: String无法转换为CAP#1
        target.add("x");
                   ^
  其中, CAP#1是新类型变量:
    CAP#1从? extends String的捕获扩展String

CAP#1 是编译器为通配符生成的捕获类型。它可能是 String 的任意子类型,编译器不能假设 String 能放进去。需要写入时,参数应该声明成 List<? super String> 或 List<String>。

错误二:从 ? super 里读成具体类型

static Book first(List<? super Book> books) {
    return books.get(0);
}
错误: 不兼容的类型: CAP#1无法转换为Book
        return books.get(0);
                        ^
  其中, CAP#1是新类型变量:
    CAP#1从? super Book的捕获扩展Object 超 Book

? super Book 只保证「能装 Book」,不保证「装的都是 Book」。实际列表可能是 List<Object>,里面完全可能有一个 Member。读出来只能当 Object。

错误三:把通配符写进返回值

public List<? extends Book> findAll() {
    return List.copyOf(store.values());
}

这个签名能编译,但调用方拿到 List<? extends Book> 后既不能 add,也不能把它传给需要 List<Book> 的方法。除非你确实想表达「只读视图」,否则返回值用具体的 List<Book>。通配符主要用在参数上,不滥用返回值。

边界:? 和 Object 不是一回事

List<?> 和 List<Object> 看起来都「什么都能装」,实际能力完全不同:

List<?> unknown = List.of("a", "b");
// unknown.add("c"); // 编译错误
List<Object> objects = new ArrayList<>();
objects.add("c");     // 可以

List<Object> 明确表示「元素类型是 Object」,所以什么都能放;List<?> 表示「元素类型未知」,所以除了 null 什么都不能放。

小结

  • 泛型是不型变的,List<Book> 不是 List<Object>;这换来了编译期的类型安全,避免数组协变那种运行时 ArrayStoreException。
  • ? extends T 是上界,适合读,读出来是 T,写入一律拒绝。
  • ? super T 是下界,适合写,能写入 T,读出来只能当 Object。
  • PECS 的口诀是「生产者用 extends,消费者用 super」;类型出现在返回值或多个参数之间时,用方法级 <T>。
  • maxBy、copyInto、eachBook 分别覆盖了返回值、消费者、回调三种场景,签名可以直接套到别的领域类型上。
  • List<?> 不是 List<Object>:前者只能读,后者能写。

通配符解决的是「怎么安全地读写」,但还有一个问题没回答:这些 <T> 在运行时到底去哪了?为什么不能 new T(),也不能 instanceof List<String>?下一节拆类型擦除。

阅读导航:上一节:11.1 泛型类与方法 · 下一节:11.3 类型擦除与运行时真相 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发