qinghuan
← Java SE / 全部笔记

单元 21 / Java SE

P0 · 必会主线L1 · 基础

泛型:把类型约束写进接口

用 List<String> 理解泛型如何提前发现类型错误,再从读入和写出的方向看懂 extends 与 super。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能为复制与聚合方法选择合适通配符,并解释泛型擦除限制。

学习理由、准备要求与知识点

为什么学它

泛型把容器和接口约束留在编译期,减少运行时强转与类型错误。

先读这些内容

知识点清单

  • 泛型类型参数
  • 包装类型与基本类型
  • 泛型不变性
  • 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>用同一个类型变量关联读入、写出等位置当接口既读又写同一精确类型时,这样表达可能更清楚

下面只是声明片段,用于对照写法,不是完整可运行程序:

文中示例 · java
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

按顺序看,每一步做了什么

  1. 确定方法实际读取和写入哪些值。
  2. 用类型变量连接相关输入输出。
  3. 为生产来源选合适上界。
  4. 为消费目标选合适下界。
  5. 保留编译警告并审查外部数据边界。

03对照实现代码

Collections.copy 的上下界为何朝不同方向 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/util/Collections.java · java.util.Collections#copy(List,List)

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
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 文档和测试。

代码先调用谁,再调用谁

  1. 编译器验证来源与目标的类型关系
  2. Collections.copy 检查目标 size
  3. 根据随机访问能力选择遍历方式
  4. 用 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 元素,这个状态表示必须改为额外布尔值或专门包装,不能只删除校验。

为什么这样写是对的输出始终有序,并恰好包含两侧已消费前缀;两个当前元素分别是尚未消费部分的最小候选。

时间 O(n+m),以比较器单次 O(1) 为前提。额外空间 O(n+m) 输出,辅助游标 O(1)。

校验各扫描一次,归并每个元素只消费一次;通用情形还要乘入或累计比较器真实成本,不能忽略复杂字段比较。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 53 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
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 读取上界
  • 泛型方法类型关联

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    为仅保存一种类型的 Box<T> 写使用样例,分别讨论整数、字符串和可空值;指出哪些写法需要包装类型,并区分类型参数与普通形参的职责。

    提交内容
    类型使用表与可编译示例。
    怎样算完成
    • 类型与值两个层次清晰
    • 解释基本类型相关限制

    练到的知识点泛型类型参数 · 包装类型与基本类型

  2. 作业 02代码推演

    针对 List<Integer>、List<Number>、List<? extends Number> 列出赋值、读取及写入操作,逐条预测是否可编译;把每个不能编译的样例放进独立文件验证。

    提交内容
    编译矩阵、编译器消息与原因。
    怎样算完成
    • 不混淆容器子类型和元素子类型
    • 每个结论有独立样例

    练到的知识点泛型不变性 · extends 读取上界

  3. 作业 03代码推演

    比较 List<? extends Number> 的元素读取、移除和 clear 操作,分别预测编译与运行层面的要求;明确实验使用的是可修改还是不可修改列表。

    提交内容
    逐操作编译/运行矩阵。
    怎样算完成
    • 类型许可与容器能力分别核验
    • 每个样例独立复现

    练到的知识点extends 读取上界

综合应用

  1. 作业 04编程实践

    定义 transfer 方法,把只读来源中的元素追加到目标容器;要求来源 Integer、目标 Number 或 Object 的调用均能表达,来源和目标不可互相污染类型合同。

    提交内容
    泛型方法、正反编译用例、自写空集合/别名边界测试及运行证据。
    怎样算完成
    • 签名表达来源与目标的关联
    • 错误调用由类型系统拒绝
    • 说明同一容器作为两参数的政策

    练到的知识点super 写入下界 · 泛型方法类型关联

  2. 作业 05排错实验

    创建一个隔离实验:泛型列表经过原始类型引用写入不匹配元素,再由另一方法读取。记录编译警告和运行失败位置,并追溯最早违反类型合同的步骤。

    提交内容
    最小代码、警告记录、调用链与修复后测试。
    怎样算完成
    • 区分污染发生点和暴露点
    • 修复后无相关 unchecked 警告

    练到的知识点类型擦除 · 原始类型风险

  3. 作业 06面试场景

    某公共 API 同时服务“读取数字并求和”和“向容器写整数”两种客户。评审一个统一使用 List<Number> 的签名,并分别设计兼容性测试,不要求强行合并接口。

    提交内容
    两项签名及调用方编译测试。
    怎样算完成
    • 读写需求分别论证
    • 不使用强制类型转换掩盖设计问题

    练到的知识点泛型不变性 · extends 读取上界 · super 写入下界 · 泛型方法类型关联

  4. 作业 07编程实践

    实现从只读来源筛出不大于给定阈值元素的泛型方法,大小由本篇已用过的 Comparator 接口提供;支持 Integer 来源与可比较 Number 的对象,明确 null 与空来源政策。

    提交内容
    Java 实现、自写运行边界测试及正反编译案例。
    怎样算完成
    • 类型关系由签名表达
    • 结果与来源容器隔离

    练到的知识点extends 读取上界 · 泛型方法类型关联

  5. 作业 08设计评审

    一个 choose 方法从两个参数中选择一个返回,另一个方法允许两参数类型完全无关。为两种真实需求分别设计签名及调用方赋值测试。

    提交内容
    接口合同和编译测试文件。
    怎样算完成
    • 返回类型信息满足使用方需求
    • 不同需求不被无依据合并

    练到的知识点泛型方法类型关联

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    学完反射后:设计插件注册接口,使注册时的类型与获取结果关联;限定只支持非参数化 Class 类型令牌。列出该限制之外的类型需求与可能失效方式。

    提交内容
    接口草案、反例和边界文档。
    怎样算完成
    • 不夸大运行时类型恢复能力
    • 约束能够在注册边界检验

    练到的知识点泛型方法类型关联 · 类型擦除 · 原始类型风险

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 反例构造

    有人声称“泛型只要把所有参数改为 ? extends Object 就最通用”。构造一个实际调用任务检验该说法,并在保持任务不变时比较至少两种签名。

    提交内容
    可编译调用、失败调用和适用边界说明。
    怎样算完成
    • 反例关联需要保留的类型信息
    • 没有借助强制转换跳过检验

    练到的知识点extends 读取上界 · 泛型方法类型关联

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗