12.2 (中):不变性、通配符与 PECS
先完成类型参数与泛型方法,再用约 35 分钟跑完本篇 Java 17 示例。目标是能解释泛型不变性,并根据参数究竟生产还是消费数据,选择
extends、super或明确的T。
两份领料单不能直接叠在一起
工具箱已经会记住物品类型。阿花却在合并领料单时遇到新问题:Integer 明明是 Number 的子类,List<Integer> 为什么不能交给接收 List<Number> 的方法?
答案藏在“接收之后还能做什么”。若赋值成立,后者就可以放入 Double,原本只装整数的列表会被污染。泛型在这里选择不变性,把错误拦在写入发生之前。
1. 元素有继承关系,List 不会跟着协变
下面的赋值不能通过编译:
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<?>:类型未知,但前后一致
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。
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 契约。列表本身也可能支持 clear、remove 或位置交换。准确说法是:不能通过它安全地加入一个具体的 T。
4. ? super T:向参数写入 T
接着她要把整数数量抄进一册可以容纳 Integer、Number 或 Object 的总账。此时关心的是“我能安全写进去什么”,方向掉了个头。
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 列表,可以这样写:
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.copy、Collections.max 等方法值得对照阅读:边界服务于实际的数据流,而不是追求签名看起来复杂。
6. 通配符捕获给未知类型起一个临时名字
交换 List<?> 中的两个位置时,两个元素属于同一个未知类型,但公开方法没法直接写回。私有 helper 可以捕获它:
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#1 或 capture of ? 时,先检查是否需要这种捕获,而不是立刻写 unchecked cast。
捕获解决的是类型表达问题,不会改变集合的可修改性。把 List.of("锤子", "锯子") 传给 swap 能通过编译,却会在 set 时抛 UnsupportedOperationException。
7. 回到仓库跑通复制与捕获
把领料数量、工具顺序和未知类型列表放进同一个 Java 17 程序。这里的验收不靠“看起来能编译”:成功路径要有断言,不可变列表的失败路径也要被明确捕获。
先准备临时目录:
chapter12_pecs_root=$(mktemp -d)
readonly chapter12_pecs_root
mkdir -p "$chapter12_pecs_root/out"
cd "$chapter12_pecs_root"保存为 WildcardsPecsDemo.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);
}
}
}javac --release 17 -Xlint:all -d out WildcardsPecsDemo.java
java -cp out WildcardsPecsDemo预期输出:
复制数量:[2, 3, 5, 2.5]
交换工具:[Plane, Saw, Hammer]
通配符与 PECS 检查通过保存记录后清理:
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 的正式规则
- JLS 4.5:参数化类型。
- JLS 4.5.1:类型实参与包含关系。
- JLS 4.10:子类型。
Collections:对照标准库中copy、max等方法的边界设计。
下一步:擦除后的运行时
源码里的类型关系已经清楚,下一篇继续追问:编译器擦除了什么,为什么会生成桥接方法,以及 Class<T> 能恢复到哪一层运行时检查。