读完这篇,你能做什么
能为复制与聚合方法选择合适通配符,并解释泛型擦除限制。
学习理由、准备要求与知识点
为什么学它
泛型把容器和接口约束留在编译期,减少运行时强转与类型错误。
先读这些内容
知识点清单
- 泛型类型参数
- 包装类型与基本类型
- 泛型不变性
- extends 读取上界
- super 写入下界
- 泛型方法类型关联
- 类型擦除
- 原始类型风险
运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。
01先弄懂它是什么
泛型:先约定这里装什么
List<String> 表示按字符串使用的列表。取出元素时,不必先把它当成 Object,再到处强制转换;不少“放错类型”的问题也能提前在编译时发现。
泛型检查类型,不检查业务含义。 字符串即使类型正确,也不一定是合法邮箱,业务规则仍要另外检查。类型参数不能直接写 int,可以使用 Integer。
元素有父子关系,列表不一定有
Integer 是 Number 的子类,但 List<Integer> 不能直接当成 List<Number>。否则,别人就可能通过后一个入口放入 Double,破坏“这个列表只装整数”的约定。这就是这里所说的不变性。
需要更灵活的接口时,可以用通配符表示“具体类型还不知道,但范围已经约定好了”。
| 写法 | 可以怎样使用 | 需要记住的限制 |
|---|---|---|
List<? extends Number> | 把读出的元素当作 Number | 具体元素类型未知,不能随意放入某个数字 |
List<? super Integer> | 放入 Integer | 取出时只能保证是 Object,列表里也可能有其他对象 |
List<T> | 用同一个类型变量关联读入、写出等位置 | 当接口既读又写同一精确类型时,这样表达可能更清楚 |
下面只是声明片段,用于对照写法,不是完整可运行程序:
List<String> names;
List<? extends Number> numbersToRead;
List<? super Integer> integersToWrite;
先画出数据往哪里走,再选写法
泛型方法可以用 T 这样的类型参数,把多个位置关联起来。例如,把一个“产生 T”的来源复制到一个“能够接收 T”的目标。
- 数据从哪里读出?
- 数据准备写到哪里?
- 这些位置是否必须保持同一个精确类型?
不要只背
extends和super的口诀。 先说清数据流,再选择接口写法。T这个名字本身没有特别的约束;真正起作用的是它在参数、返回值和边界之间建立的关系。
文中这几个词,先认一认
- 类型参数
- 用 T 这样的名字暂时代替某个类型,让参数和返回值之间的类型关系写进接口。
- 不变性
- 装
Integer的List不能直接当成装任意Number的List,否则别人就可能从那个入口放入 Double,破坏原列表的类型要求。Integer是Number的子类,并不会让这两个列表类型也成为同样的父子关系。 - 上界通配符
- 如 ? extends
Number:具体类型未知,但可安全地把读出的元素当作Number。 - 下界通配符
- 如 ? super
Integer:具体类型未知,但可以安全地向其中放入Integer。
02看看它怎么工作
编译器会根据声明和传入的参数,检查这次调用的类型是否允许。从 extends 来源读取时,你不知道具体类型是不是 Integer,但知道它符合 Number 的要求,所以可以按 Number 使用。
super 目标的实际元素类型可能是 Integer、Number 或 Object,因此放入 Integer 在类型上是允许的;这并不保证容器本身支持修改。null 能赋给这些引用类型是特殊规则,不能据此认为其他任意值也能放进去。
编译时能区分 List<String> 和 List<Integer>,运行时却通常不能只看列表对象,就证明里面每项都是字符串或整数。为兼容旧代码,编译后的大多数类型参数会用上界或 Object 表示,这叫类型擦除;编译器还会补上需要的类型转换,并在必要时生成桥接方法,维持方法重写关系。
面对外部数据,可以先确认它是 List<?>,再逐项检查内容。需要创建 T 对象时,可以显式传入创建方法或 Class<T> 这类类型信息;不能直接写 new T,也不能据此认为所有泛型信息都恢复了。
原始类型和未经检查转换会绕开编译期保护,使错误延迟到后续读取时才暴露。警告的位置与最终 ClassCastException 位置可能不同,所以不应只在抛异常处继续增加强转。图将数据从来源流向目标,强调接口类型约束保护整条传递路径,反序列化等外部边界还需要运行时内容验证。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart LR
A["来源 List extends T"] -->|"安全读取 T"| B["泛型算法"]
B -->|"安全写入 T"| C["目标 List super T"]
D["编译器推导与检查"] --> B
E["外部未验证数据"] --> F["边界内容校验"]
F --> A按顺序看,每一步做了什么
- 确定方法实际读取和写入哪些值。
- 用类型变量连接相关输入输出。
- 为生产来源选合适上界。
- 为消费目标选合适下界。
- 保留编译警告并审查外部数据边界。
03对照实现代码
Microsoft OpenJDK 17.0.20.1;java.base/java/util/Collections.java · java.util.Collections#copy(List,List)
以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。
import java.util.List;
import java.util.ListIterator;
public final class SourceModel {
public static <T> void overwrite(List<? super T> target, List<? extends T> source) {
if (target.size() < source.size()) throw new IndexOutOfBoundsException();
ListIterator<? super T> writer = target.listIterator();
for (T value : source) {
writer.next(); // copy 替换已有槽位,不负责追加扩容。
writer.set(value);
}
}
}真实 Collections.copy 把目标声明为 List<? super T>,来源声明为 List<? extends T>,正好对应写入与读取。方法先比较两边 size,目标元素数量不足就失败。这里的目标容量不是关键,new ArrayList<>(100) 仍然 size 为零,不能因此作为已有一百个槽位的目标使用。
真实实现依据规模与 RandomAccess 标记选择按下标或迭代器复制。模型统一使用迭代器,保留替换现有元素的语义,省略阈值优化与分支。它与“把来源追加到目标末尾”的 copyInto 教学方法不同,两个方法即使通配符形状相似,也不能混淆行为契约。
目标列表的具体元素类型虽然未知,但至少能接收 T,所以 writer.set(value) 安全;来源给出的值能按 T 使用,所以可以写 T value。目标读出时只能保证是 Object,这不代表它只能保存 Object,而是调用者没有足够信息认定更具体类型。
泛型签名无法保证目标列表支持 set,也无法证明并发修改不会发生。不可修改列表仍可能在运行时拒绝替换,类型系统只处理它表达的类型关系。理解这一边界有助于避免把通过编译当成所有调用前提都成立;可修改性、尺寸与线程规则仍需 API 文档和测试。
代码先调用谁,再调用谁
- 编译器验证来源与目标的类型关系
Collections.copy检查目标 size- 根据随机访问能力选择遍历方式
- 用 set 替换已有位置而不是 add
04动手写一个例子
泛型稳定归并两个已排序列表
给定两个按同一 Comparator 排序的来源列表,返回合并后的新列表,比较相等时先保留左侧元素。拒绝 null 参数、null 元素和无序输入,不修改来源。
- 输入 1
左[1,3],右[2,3]- 输出 1
[1,2,3,3]
- 输入 2
左[],右[2]- 输出 2
[2]
- 输入 3
左[2,1],右[]- 输出 3
拒绝无序输入
归并不需要知道元素具体类,只需要一个能够比较 T 的比较器。来源只生产值,所以声明为 List<? extends T>;Comparator 消费两个 T,所以使用 Comparator<? super T>。结果由方法创建为 List<T>,把同一个类型变量贯穿读取、比较与返回,便能让编译器检查调用关系。
先验证两侧各自有序,再分别保存两个迭代器的当前元素。比较后把较小的一项追加到结果,并只推进对应一侧;相等时推进左侧,保证来自两侧的相等元素维持确定的稳定顺序。使用迭代器避免对 LinkedList 反复按下标读取而引入额外平方级成本。
当一侧耗尽,另一侧剩余部分已经有序,可以直接追加。null 元素在前面的验证阶段被拒绝,因此内部可以用 null 表示该侧已耗尽,不会与合法数据混淆。若将来允许 null 元素,这个状态表示必须改为额外布尔值或专门包装,不能只删除校验。
为什么这样写是对的输出始终有序,并恰好包含两侧已消费前缀;两个当前元素分别是尚未消费部分的最小候选。
校验各扫描一次,归并每个元素只消费一次;通用情形还要乘入或累计比较器真实成本,不能忽略复杂字段比较。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
public class Main {
static <T> void validate(List<? extends T> list, Comparator<? super T> cmp) {
T previous = null;
for (T value : list) {
if (value == null) throw new IllegalArgumentException("null element");
if (previous != null && cmp.compare(previous,value) > 0)
throw new IllegalArgumentException("unsorted");
previous = value;
}
}
static <T> List<T> merge(List<? extends T> left, List<? extends T> right,
Comparator<? super T> cmp) {
if (left == null || right == null || cmp == null) throw new IllegalArgumentException();
validate(left,cmp); validate(right,cmp);
Iterator<? extends T> li = left.iterator(), ri = right.iterator();
T a = li.hasNext() ? li.next() : null;
T b = ri.hasNext() ? ri.next() : null;
List<T> result = new ArrayList<>();
while (a != null || b != null) {
// null 只表示耗尽,合法元素已在前置校验中保证非空。
if (b == null || (a != null && cmp.compare(a,b) <= 0)) {
result.add(a); // 相等时优先左侧,保留稳定顺序。
a = li.hasNext() ? li.next() : null;
} else {
result.add(b); b = ri.hasNext() ? ri.next() : null;
}
}
return result;
}
static void require(boolean condition) {
if (!condition) throw new AssertionError();
}
public static void main(String[] args) {
Comparator<Integer> order = Integer::compare;
// 普通、单侧为空、双空与比较相等的稳定性。
require(merge(List.of(1,3),List.of(2,3),order).equals(List.of(1,2,3,3)));
require(merge(List.of(),List.of(2),order).equals(List.of(2)));
require(merge(List.of(),List.of(),order).isEmpty());
record Item(int rank, String name) {}
Item a = new Item(1,"left"), b = new Item(1,"right");
require(merge(List.of(a),List.of(b),Comparator.comparingInt(Item::rank))
.equals(List.of(a,b)));
boolean rejected = false;
try { merge(List.of(2,1),List.of(),order); }
catch (IllegalArgumentException e) { rejected = true; }
require(rejected);
System.out.println("PASS 5");
}
}这些特殊情况也要试一试
- 空来源合法,两个空来源返回空结果。
- 比较相等时左侧优先。
- 无序输入显式拒绝。
null元素不合法,因此可用null表示内部耗尽。
进阶选读 · 可选深入:泛型边界与工厂
场景算法需要创建结果元素,或者比较类型实现了自比较接口。
做法通过 Supplier 或 Function 显式传入构造行为,必要时使用 T extends Comparable<? super T> 表达自比较关系;避免原始类型和未经检查的数组创建。
复杂度类型约束本身不改变算法阶数,工厂与比较器成本需要纳入总成本。
好处与代价过度复杂边界降低可读性,应优先让调用者理解数据流与失败前提。
05项目里怎么用
公开泛型 API 应尽量使用能满足职责的最宽参数约束,让 Integer 来源可以安全流向 Number 结果等合理组合;返回类型则应尽量易用,避免把不必要的通配符复杂性推给调用者。类型变量只使用一次且不关联其它位置时,可能需要重新检查是否真有必要。
从 JSON、反射或数据库拿到外部数据时,写了 List<User> 不等于每个元素都已被检查。反序列化要提供正确类型信息,原始 Map 转成对象时也要逐项核对字段。未经检查的强转常让错误到很远的地方才出现,应先在接收位置验证,再交给内部算法使用。
泛型主要保护类型关系,不保护集合可修改性、排序前提、单位或事务一致性。一个 Comparator 也可能读取可变字段,导致同一对对象先后比较结果不同,破坏归并前提。应让比较规则稳定且无副作用,输入在计算期间保持不变,再讨论算法结果与性能。
容易踩的坑
- 把
List<Integer>直接当作List<Number>。 - 为让代码编译而使用原始类型并忽略警告。
- 认为 new
ArrayList<>(n) 已有 n 个元素可供 set。 - 把泛型检查当作数据内容、顺序和可修改性检查。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
精确 List<T> | 同类型读写直接 | 对子类型来源灵活性较低 | 需要精确读写关系 |
| extends 与 super | 按数据流接受更广安全组合 | 签名需要解释未知类型 | 通用复制、转换与比较算法 |
| 运行时类型校验 | 适合外部未可信数据 | 有额外检查和错误处理成本 | 反序列化、反射和动态边界 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01为什么 extends 列表不能随意添加 Number?
展开参考答案与推导
它可能实际是 Integer 列表,加入 Double 会破坏真实元素类型。上界只保证读取时至少能当成 Number,不赋予写入所有 Number 子类的能力。
02为什么比较器使用 super T?
展开参考答案与推导
比较器接收 T 作为输入,一个能比较 T 父类型的比较器也能够消费 T。下界表达这个安全方向,使通用比较规则可以用于更具体元素,而无需不安全转换。
03擦除是否表示运行时完全没有任何泛型信息?
展开参考答案与推导
不能这样绝对说。多数类型参数不作为对象的可直接检查运行时类型保留,但声明签名等元数据仍可能供反射读取。必须区分声明信息与某个容器实际元素的逐项类型事实。
换个情况,还会处理吗
01Collections.copy 对容量一百的空 ArrayList 报错,为什么?
展开参考答案与推导
容量只是底层预留槽位,size 仍为零;copy 替换现有元素而非追加,需要目标实际元素数量足够。若需求是追加,应选择 addAll 或具有追加契约的方法。
02泛型代码取元素时 ClassCastException,先查哪里?
展开参考答案与推导
追查是否经过原始类型、未经检查转换、反射或不准确反序列化边界。错误可能在更早的写入处被引入,继续在读取位置加转换不能恢复被破坏的类型约束。
03归并 ArrayList 很快,换 LinkedList 后变慢,可能原因?
展开参考答案与推导
检查实现是否反复按下标 get,链表每次定位可能再次遍历。使用迭代器顺序推进能保持线性访问;接口相同不代表所有访问模式在不同实现上成本相同。
写代码试一试
01增加降序归并
- 输入 / 条件
左[5,2],右[4,1]- 输出 / 预期
[5,4,2,1]
提示 两侧排序和比较器方向一致。
展开参考答案与推导
传入 Comparator.reverseOrder,并以同样比较器校验输入。归并规则仍选择比较意义上的较小项,不应另外硬编码数值大小,否则泛型比较接口被破坏。
02允许 null 元素
- 输入 / 条件
[null,1] 与 [2]- 输出 / 预期
按指定 null 顺序合并
提示 不能再用 null 表示耗尽。
展开参考答案与推导
使用独立 hasA、hasB 状态标记是否有当前元素,再传入支持 null 的比较器。同时修改输入校验和稳定性测试,避免把合法 null 错当作结束信号。
03把来源追加到父类型目标
- 输入 / 条件
List<Integer> 到 List<Number>- 输出 / 预期
目标包含原整数
提示 来源 extends,目标 super。
展开参考答案与推导
声明 <T> void append(List<? extends T> source,List<? super T> target),逐项 target.add。它与 Collections.copy 的 set 契约不同,还需说明目标是否可追加及共享列表时的行为。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能为复制与聚合方法选择合适通配符,并解释泛型擦除限制。
本篇重点加练
重点训练读取上界与泛型方法的类型关联,使接口约束能由编译器验证。
- extends 读取上界
- 泛型方法类型关联
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
为仅保存一种类型的
Box<T>写使用样例,分别讨论整数、字符串和可空值;指出哪些写法需要包装类型,并区分类型参数与普通形参的职责。- 提交内容
- 类型使用表与可编译示例。
- 怎样算完成
- 类型与值两个层次清晰
- 解释基本类型相关限制
练到的知识点泛型类型参数 · 包装类型与基本类型
- 作业 02代码推演
针对
List<Integer>、List<Number>、List<? extends Number>列出赋值、读取及写入操作,逐条预测是否可编译;把每个不能编译的样例放进独立文件验证。- 提交内容
- 编译矩阵、编译器消息与原因。
- 怎样算完成
- 不混淆容器子类型和元素子类型
- 每个结论有独立样例
练到的知识点泛型不变性 · extends 读取上界
- 作业 03代码推演
比较
List<? extends Number>的元素读取、移除和 clear 操作,分别预测编译与运行层面的要求;明确实验使用的是可修改还是不可修改列表。- 提交内容
- 逐操作编译/运行矩阵。
- 怎样算完成
- 类型许可与容器能力分别核验
- 每个样例独立复现
练到的知识点extends 读取上界
综合应用
- 作业 04编程实践
定义 transfer 方法,把只读来源中的元素追加到目标容器;要求来源
Integer、目标Number或Object的调用均能表达,来源和目标不可互相污染类型合同。- 提交内容
- 泛型方法、正反编译用例、自写空集合/别名边界测试及运行证据。
- 怎样算完成
- 签名表达来源与目标的关联
- 错误调用由类型系统拒绝
- 说明同一容器作为两参数的政策
练到的知识点super 写入下界 · 泛型方法类型关联
- 作业 05排错实验
创建一个隔离实验:泛型列表经过原始类型引用写入不匹配元素,再由另一方法读取。记录编译警告和运行失败位置,并追溯最早违反类型合同的步骤。
- 提交内容
- 最小代码、警告记录、调用链与修复后测试。
- 怎样算完成
- 区分污染发生点和暴露点
- 修复后无相关 unchecked 警告
练到的知识点类型擦除 · 原始类型风险
- 作业 06面试场景
某公共 API 同时服务“读取数字并求和”和“向容器写整数”两种客户。评审一个统一使用
List<Number>的签名,并分别设计兼容性测试,不要求强行合并接口。- 提交内容
- 两项签名及调用方编译测试。
- 怎样算完成
- 读写需求分别论证
- 不使用强制类型转换掩盖设计问题
练到的知识点泛型不变性 · extends 读取上界 · super 写入下界 · 泛型方法类型关联
- 作业 07编程实践
实现从只读来源筛出不大于给定阈值元素的泛型方法,大小由本篇已用过的 Comparator 接口提供;支持
Integer来源与可比较Number的对象,明确null与空来源政策。- 提交内容
- Java 实现、自写运行边界测试及正反编译案例。
- 怎样算完成
- 类型关系由签名表达
- 结果与来源容器隔离
练到的知识点extends 读取上界 · 泛型方法类型关联
- 作业 08设计评审
一个 choose 方法从两个参数中选择一个返回,另一个方法允许两参数类型完全无关。为两种真实需求分别设计签名及调用方赋值测试。
- 提交内容
- 接口合同和编译测试文件。
- 怎样算完成
- 返回类型信息满足使用方需求
- 不同需求不被无依据合并
练到的知识点泛型方法类型关联
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
学完反射后:设计插件注册接口,使注册时的类型与获取结果关联;限定只支持非参数化 Class 类型令牌。列出该限制之外的类型需求与可能失效方式。
- 提交内容
- 接口草案、反例和边界文档。
- 怎样算完成
- 不夸大运行时类型恢复能力
- 约束能够在注册边界检验
练到的知识点泛型方法类型关联 · 类型擦除 · 原始类型风险
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 反例构造
有人声称“泛型只要把所有参数改为 ? extends
Object就最通用”。构造一个实际调用任务检验该说法,并在保持任务不变时比较至少两种签名。- 提交内容
- 可编译调用、失败调用和适用边界说明。
- 怎样算完成
- 反例关联需要保留的类型信息
- 没有借助强制转换跳过检验
练到的知识点extends 读取上界 · 泛型方法类型关联
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读教程英文
Dev.java:泛型 (新标签页)↗
从泛型类型与方法开始,继续阅读通配符、擦除和不可具体化类型。
- 实践规范英文
Java 17 语言规范:类型、值与变量 (新标签页)↗
核对基本类型范围、引用类型、类型变量与可具体化类型,理解编译期约束。
- 深入规范英文
Java 17 语言规范:转换与上下文 (新标签页)↗
查阅数值提升、强制转换、装箱拆箱以及方法调用时的转换规则。
- 深入参考文档英文
OpenJDK 17u java.util.Collections (新标签页)↗
定位
Collections.copy的上下界、size 前提及随机访问与迭代器两种路径。 - 深入参考文档英文
Oracle 通配符指南 (新标签页)↗
旧版教程用于稳定泛型规则,重点阅读输入输出方向与返回值建议。
- 深入参考文档英文
Oracle 泛型擦除 (新标签页)↗
了解上界替换、插入转换和桥接方法的目的。
- 深入参考文档英文
Java17 Collections API (新标签页)↗
核对 copy 的目标大小和可修改要求。