2.10 Java帝国之泛型
从集合依赖 Object 和强制转换走到类型参数,把不匹配提前到编译期,并沿擦除、方法推断与继承边界验证 Java 泛型。
学习目标
- 能沿声明类型参数、编译期检查、类型擦除、运行时转换追踪一个参数化类型
- 能解释泛型方法的类型推断、上界/下界与泛型和继承之间的可替换边界
- 能在正常、边界和故障场景中回答:为什么不能把一组 Integer 当成一组 Number 写入 Double
2.10 Java帝国之泛型
本页依据刘欣《码农翻身》(2018 年第 1 版)及出版社公开书目信息,独立重构 2.10 Java帝国之泛型。正文、代码、图示、实验和练习都是本课程重新设计的教学材料,不复制原书正文、插图、练习答案或代码。
先想象仓库有两种标签:一种只写“箱子”,取货时才打开确认;另一种在入库时就写明“只能装整数”。前者看起来灵活,却把错误留到发货时;后者要求入口更清楚,但让后面的搬运可以少做猜测。
本章解决的是“容器和函数如何提前声明它们接受什么”。如果没有这层声明,错误会穿过编译阶段,直到运行时转换才爆出来;如果声明过度,又会把本来安全的读取或复用拒绝掉。泛型的验收因此必须同时看编译期边界、运行时擦除和继承可替换性。
三个会让泛型失真的陷阱
六个目录节点到类型证据
2.10 Java帝国之泛型
总合同是:在编译期拒绝不满足的类型关系,在运行时只依赖擦除后仍然存在的边界,并让读取/写入的位置决定可接受的泛化程度。最小证据要包含源代码、编译结果、擦除后的签名和重置后的同一输入。
新王登基
↡用类型参数给类、接口或方法声明一个可复用的类型位置,让调用者在使用处提供具体类型。描述的是从“所有东西先当作 Object”到“容器先声明元素合同”的变化。类型参数不是装饰语法:它让集合的加入、取出和组合在编译阶段拥有同一份约束。
C 使者
↡把类型参数、边界和调用处实参带到编译器面前,让编译器检查类型是否匹配的角色。代表编译期检查者。若把 Double 放入只接受 Integer 的容器,C 使者应在构建阶段拒绝;若编译器放行了原始类型转换,风险就转移到运行时,应在证据中标出这条逃逸路径。
泛型实现
↡泛型类或接口的声明、编译后擦除和必要桥接方法共同形成的实现边界。需要区分源代码视角和字节码视角。源代码用参数化类型获得检查,编译后类型参数通常擦除到上界;重写方法的返回类型变化可能由编译器生成桥接方法维持多态调用。
泛型方法
↡在方法自身声明类型变量,并依据参数、返回目标和上下界推断本次调用的具体类型。把同一个算法复用于不同类型,但推断不是魔法。先列出每个参数对类型变量施加的约束,再看返回目标是否收紧结果;空值或通配符不足以提供可靠约束时,要显式传入类型信息。
泛型和继承
↡类型实参的父子关系、参数化类型的可替换性以及通配符读写规则共同构成的继承边界。要避免一个危险直觉:Integer 是 Number 的子类型,不代表“Integer 的列表”也是“Number 的列表”。允许任意写入会破坏容器原有合同;上界适合读取,下界适合写入,具体类型适合同时读写。
最小泛型合同
static <T> T first(List<T> values) {
if (values.isEmpty()) throw new IllegalArgumentException("empty");
return values.get(0);
}
List<Integer> numbers = List.of(1, 2, 3);
Integer value = first(numbers);合同的关键不是 T 这个字母,而是同一次调用中输入元素类型、返回类型和空集合策略彼此一致。若把参数化信息擦掉再强制转换,必须在测试中标记这种不安全入口,而不能把它当成泛型已经保证安全。
五步复核一条泛型链
1. 声明类型参数并固定输入
先写出类/接口/方法的类型变量、实参和空值策略。基线使用一组 Integer;只改变一个条件,例如将写入值改成 Double,预期是编译期拒绝,而不是运行时才报错。
Lab
泛型读写与擦除实验
只改变参数化类型、读写方向或入口安全性,观察错误是在编译期还是运行时出现。
一组 Integer 只读写 Integer
List<Integer> → add(Integer) → get() = Integer → compile ok
判定
accept:输入、返回和副作用使用同一类型合同
当前样本:精确参数;保存类型参数、编译输出、警告、擦除边界和转换位置。
正常、边界与故障证据矩阵
| 样本 | 只改变的变量 | 预期判定 | 必存证据 |
|---|---|---|---|
| 正常 | 精确参数化类型、匹配实参 | 编译通过,读取结果保持类型 | 源码、编译输出、返回类型 |
| 边界 | 上界/下界或空值约束 | 只允许合同支持的读写,空值显式处理 | 约束列表、错误位置、调用签名 |
| 故障 | 原始类型或未经检查转换 | 警告/拒绝,运行时转换点可定位 | 警告、字节码签名、异常位置 |
故障诊断:先问错误发生在编译前还是擦除后
- 声明侧:核对类型变量、精确实参和空值策略;容器的元素合同不能靠注释补回。
- 推断侧:列出参数位置、目标类型、上界和下界;冲突约束应在编译期暴露。
- 继承侧:区分类型实参继承与参数化类型可替换性;先判断操作是读、写还是读写。
- 运行侧:查擦除后的边界、桥接方法和检查转换;原始类型造成的警告要追到第一次不安全转换。
如果一段代码编译失败,先读错误位置和推断约束;如果编译通过却运行时失败,优先查原始类型、未经检查转换和桥接方法;如果 API 过于保守,回到读写位置选择合适的上界或下界,而不是把所有参数改成 Object。
术语表
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 新王登基
用类型参数替代所有元素都先当作 Object 的宽泛容器约定。
- C 使者
在编译阶段检查类型参数、实参和边界是否匹配的检查者。
- 泛型实现
源代码参数化、编译后擦除以及桥接方法组成的实现边界。
- 泛型方法
方法自己声明类型变量,并从调用参数和返回目标推断具体类型的方法。
- 泛型和继承
类型实参的继承关系与参数化类型读写可替换性之间的边界。
练习
练习
问题 1: 为什么一组 Integer 不能直接当作一组 Number 传给允许写入 Number 的方法?应该怎样设计只读方法?
问题 2: 一个泛型方法在传入两个不同类型参数时推断失败。你会按什么顺序排查?
问题 3: 修改本页“泛型读写与擦除实验”的故障场景,让它显示一次原始类型造成的运行时转换失败,并说明重置后应恢复哪些证据。
本页小结
- 类型参数把容器和方法的错误提前到编译期。
- 推断要看参数位置、目标类型和上下界,不是猜变量名。
- 类型实参继承不自动带来参数化类型协变。
- 擦除与桥接解释了运行时仍会出现的转换边界。
读完后的自测问题是:面对“Integer 是 Number 的子类型”这句话,你能否说明为什么它不足以证明一组 Integer 可以写入一组 Number,并指出安全的读写 API 设计?