跳到内容

12.2 (中):不变性、通配符与 PECS

先完成类型参数与泛型方法,再用约 35 分钟跑完本篇 Java 17 示例。目标是能解释泛型不变性,并根据参数究竟生产还是消费数据,选择 extendssuper 或明确的 T

两份领料单不能直接叠在一起

工具箱已经会记住物品类型。阿花却在合并领料单时遇到新问题:Integer 明明是 Number 的子类,List<Integer> 为什么不能交给接收 List<Number> 的方法?

答案藏在“接收之后还能做什么”。若赋值成立,后者就可以放入 Double,原本只装整数的列表会被污染。泛型在这里选择不变性,把错误拦在写入发生之前。

1. 元素有继承关系,List 不会跟着协变

下面的赋值不能通过编译:

java
List<Integer> quantities = new ArrayList<>(List.of(2, 3, 5));
// List<Number> numbers = quantities;

假如第二行合法,调用方随后就能执行 numbers.add(3.14)。同一个对象会同时被视为“只含 Integer”和“可以写入任意 Number”,两个承诺互相冲突。

List<Number> 也不等于 List<Object>。它只接受 Number 及其子类对象;List<Object> 还能放入字符串等其他引用。需要表达“某种具体但未知的 List”时,才轮到通配符。

2. List<?>:类型未知,但前后一致

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

List<?> 可以引用 List<String>List<Integer> 或其他元素类型的列表。问号不是说每个元素各有一种类型,而是说这里存在一个确定类型,只是当前方法不知道它的名字。

因此读取结果只能先当作 Object。除 null 外,不能安全写入任何具体值:编译器无法证明这个值符合被隐藏的元素类型。若方法既不关心元素类型,也不需要写入,List<?> 比 raw List 安全得多。

3. ? extends T:从参数读取 T

阿花先拿到一摞只供查看的领料单。她不知道每张单的具体数字类型,却知道它们至少都是 Number。这种“只需从里面读”的方向,正适合 ? extends T

java
List<? extends Number> source = List.of(2, 3, 5);
Number first = source.get(0);
// source.add(4); // 编译失败

source 的真实类型可能是 List<Integer>,也可能是 List<Double>。无论哪一种,读出的元素至少是 Number;但写入 Integer 只对第一种安全。

技术上可以向多数这类列表写入 null,所以“extends 只读”是一条实用近似,不是完整的 API 契约。列表本身也可能支持 clearremove 或位置交换。准确说法是:不能通过它安全地加入一个具体的 T。

4. ? super T:向参数写入 T

接着她要把整数数量抄进一册可以容纳 IntegerNumberObject 的总账。此时关心的是“我能安全写进去什么”,方向掉了个头。

java
List<? super Integer> target = new ArrayList<Number>();
target.add(8);
Object first = target.get(0);

target 的真实元素类型可能是 Integer、Number 或 Object。写入 Integer 对三者都安全;读取时却只能确定得到 Object。

如果调用方需要把读出结果直接当成 Number,? super Integer 并没有提供这个保证。通配符描述的是允许传入哪些容器,以及方法能对它做哪些安全操作,不是给转换开后门。

5. PECS 要看参数在方法里的角色

两摞领料单终于可以合并了:source 把数据交出来,target 把数据收进去。PECS 只是这条流向的速记,不能脱离方法行为单独背。

把整数领料量复制进 Number 列表,可以这样写:

java
static <T> void copyAll(
        List<? extends T> source,
        List<? super T> target) {
    for (T value : source) {
        target.add(value);
    }
}

source 生产 T,使用 extends;target 消费 T,使用 super。这就是 Producer Extends, Consumer Super。

PECS 不是必须套在所有泛型声明上的口令。参数若既读取 T 又写入 T,直接使用 List<T> 往往更清楚。返回 List<? extends Item> 还会把未知类型的负担推给调用者;除非 API 有意隐藏具体子类型,优先返回明确的参数化类型。

JDK 的 Collections.copyCollections.max 等方法值得对照阅读:边界服务于实际的数据流,而不是追求签名看起来复杂。

6. 通配符捕获给未知类型起一个临时名字

交换 List<?> 中的两个位置时,两个元素属于同一个未知类型,但公开方法没法直接写回。私有 helper 可以捕获它:

java
static void swap(List<?> values, int left, int right) {
    swapCaptured(values, left, right);
}

private static <T> void swapCaptured(
        List<T> values, int left, int right) {
    T previous = values.set(left, values.get(right));
    values.set(right, previous);
}

调用者仍可传入任意元素类型的 List;helper 则让编译器在这一次调用中用 T 代表那个未知类型。错误信息里出现 CAP#1capture of ? 时,先检查是否需要这种捕获,而不是立刻写 unchecked cast。

捕获解决的是类型表达问题,不会改变集合的可修改性。把 List.of("锤子", "锯子") 传给 swap 能通过编译,却会在 set 时抛 UnsupportedOperationException

7. 回到仓库跑通复制与捕获

把领料数量、工具顺序和未知类型列表放进同一个 Java 17 程序。这里的验收不靠“看起来能编译”:成功路径要有断言,不可变列表的失败路径也要被明确捕获。

先准备临时目录:

bash
chapter12_pecs_root=$(mktemp -d)
readonly chapter12_pecs_root
mkdir -p "$chapter12_pecs_root/out"
cd "$chapter12_pecs_root"

保存为 WildcardsPecsDemo.java

java
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public class WildcardsPecsDemo {
    public static void main(String[] args) {
        List<Integer> quantities = List.of(2, 3, 5);
        List<Number> totals = new ArrayList<>();
        copyAll(quantities, totals);
        totals.add(2.5);
        checkEquals(List.of(2, 3, 5, 2.5), totals);

        List<String> tools = new ArrayList<>(
                List.of("Hammer", "Saw", "Plane"));
        swap(tools, 0, 2);
        checkEquals(List.of("Plane", "Saw", "Hammer"), tools);

        List<? extends Number> producer = quantities;
        checkEquals(10, sum(producer));

        List<? super Integer> consumer = new ArrayList<Object>();
        consumer.add(8);
        consumer.add(13);
        checkEquals(List.of(8, 13), consumer);

        expect(UnsupportedOperationException.class,
                () -> swap(List.of("fixed", "list"), 0, 1));

        System.out.println("复制数量:" + totals);
        System.out.println("交换工具:" + tools);
        System.out.println("通配符与 PECS 检查通过");
    }

    static <T> void copyAll(
            List<? extends T> source,
            List<? super T> target) {
        for (T value : source) target.add(value);
    }

    static int sum(List<? extends Number> values) {
        int result = 0;
        for (Number value : values) result += value.intValue();
        return result;
    }

    static void swap(List<?> values, int left, int right) {
        swapCaptured(values, left, right);
    }

    private static <T> void swapCaptured(
            List<T> values, int left, int right) {
        T previous = values.set(left, values.get(right));
        values.set(right, previous);
    }

    static <T extends Throwable> T expect(
            Class<T> type, Runnable action) {
        try {
            action.run();
        } catch (Throwable failure) {
            if (type.isInstance(failure)) return type.cast(failure);
            throw new AssertionError(
                    "expected=" + type + ", actual=" + failure, failure);
        }
        throw new AssertionError("expected exception=" + type.getName());
    }

    static void checkEquals(Object expected, Object actual) {
        if (!Objects.equals(expected, actual)) {
            throw new AssertionError(
                    "expected=" + expected + ", actual=" + actual);
        }
    }
}
bash
javac --release 17 -Xlint:all -d out WildcardsPecsDemo.java
java -cp out WildcardsPecsDemo

预期输出:

text
复制数量:[2, 3, 5, 2.5]
交换工具:[Plane, Saw, Hammer]
通配符与 PECS 检查通过

保存记录后清理:

bash
cd
ls -ld -- "$chapter12_pecs_root"
rm -r -- "$chapter12_pecs_root"

离开仓库前,读一遍方法签名

看到 List<? extends Number>,说明你至少能读出 Number,却不能安全加入一个具体 Number;看到 List<? super Integer>,说明你能写入 Integer,读出时只能先当 Object。若一个列表既要读又要写同一种 T,就别为了套 PECS 而隐藏它。

再故意编译三段错误代码:把 List<Integer> 赋给 List<Number>、向 producer 加入整数、把 consumer 读出的值直接赋给 Integer。编译器给出的三次拒绝,正好画出了通配符的边界。

查阅 Java 17 的正式规则

下一步:擦除后的运行时

源码里的类型关系已经清楚,下一篇继续追问:编译器擦除了什么,为什么会生成桥接方法,以及 Class<T> 能恢复到哪一层运行时检查。

继续学习类型擦除与运行时边界

Built with VitePress | Software Systems Atlas