qinghuan
← Java SE / 全部笔记

单元 102 / Java SE

P2 · 按需深入L3 · 深入

泛型方法:写清类型关系,减少强制转换

让泛型方法写清输入输出的关系,再理解类型上界、运行时信息和未检查转换为什么可能留下错误。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能设计有明确上界的泛型方法,并审查擦除、可变参数和未检查转换风险。

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

为什么学它

当需要设计通用库与复杂类型 API 时,深入类型关系可以消除不安全强转。

先读这些内容

知识点清单

  • 方法类型参数
  • Comparable 递归上界
  • 类型参数与通配符联系
  • 擦除后重载冲突
  • 可具体化类型限制
  • 堆污染来源
  • unchecked 警告
  • SafeVarargs 与运行时元素校验

运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。

01先弄懂它是什么

写一个“选出最大值”的通用方法时,希望传进什么类型,返回的就仍是这个类型。泛型能把这种关系交给编译器检查;若全部写成 Object,调用者只能自己强制转换,错误可能到运行时才出现。类型参数用来关联输入和输出,通配符用来表示不必知道的具体类型。先画清数据从哪里来、交给谁,再设计方法声明。

方法要调用某种能力,就用上界说明要求。例如 T extends Comparable<? super T> 允许 T 按自身或父类型定义的比较规则参与比较。多个要求可以用 & 连接;若包含类上界,类写在前面。Java 不允许直接写 new T(),类型参数的上界也不保证具体类型提供某个构造器;需要创建对象时,可传入 Supplier<T> 这样的创建方法。

泛型声明不等于运行时逐个检查了元素。List<String> 与 List<Integer> 可以由同一种列表类实现,不能靠 instanceof List<String> 就证明每项都是字符串。

反射可以读到某些声明上的泛型签名,但普通对象不会因此自带一份完整的元素类型证明。如果变量声明为某种泛型,实际引用的对象却不符合这个类型,就叫堆污染。例如绕过检查,把装有其他类型的列表当作 List<String> 传下去,错误可能等到取出元素时才出现。原始类型、未经检查的转换和泄漏出去的可变参数数组,都需要重点检查。

文中这几个词,先认一认

上界
要求类型参数必须具备某个父类或接口的能力,让方法可以安全调用相应操作。
可具体化类型
指运行时仍保留了完整类型信息的类型,例如 String 和 List<?>;后者只说“某种元素的列表”,没有承诺元素是 String。List<String> 中的 String 元素约束不会完整保留,所以认出一个 List 并不能证明里面每项都是字符串。
类型令牌
把 Class<T> 等类型说明作为参数传入,让程序在运行时也能按它进行检查。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

List<? extends Number> 里的元素可以按 Number 读取,却不能随便加入一个 Number:实际列表可能只允许 Integer。List<? super Integer> 在类型上允许写入 Integer,但读出来通常只能按 Object 使用。

如果返回值的类型要随着参数一起变化,就要用命名的 T 表达这个关系;全部换成问号会丢掉这种联系。List<Integer> 不能直接当作 List<Number> 用。数组虽然允许某些类似的赋值,但会在运行时检查写入的元素,不能把数组的规则套到 List 上。

两个同名方法如果分别只接收 List<String> 和 List<Integer>,不能仅靠这两个参数形成重载:泛型擦除后,它们的参数类型都只剩 List,编译器无法这样区分。编译器还可能补出桥接方法,让擦除后的调用仍能正确找到被覆盖的方法。反射读到 bridge 或 synthetic 标记时,先弄清它是不是为这种兼容需要生成的,再决定是否过滤,不要直接认定业务方法写重复了。

可变参数实际通过数组传入,而带泛型的元素类型未必能在运行时完整检查。@SafeVarargs 是作者承诺:这段可变参数代码不会把不符合声明类型的对象混进去,也就是不会造成相关的堆污染。

它不会额外添加类型检查;Java 17 允许它用于构造器及 static、final、private 方法。如果把数组交给其他代码,或经 Object[] 写入其他类型,仍可能破坏这个承诺。

可以启用 javac -Xlint:unchecked -Xlint:varargs,逐条检查警告;确实需要抑制警告时,只放在已经确认安全的最小范围。

正在加载流程图…

编译期约束与运行时入口检查互补;批量发布另有自己的状态一致性要求。
查看流程图源码(Mermaid)
flowchart TD
 A["类型关联由泛型签名约束"] --> B["外部对象进入运行时边界"]
 B --> C["Class 令牌校验"]
 C --> D["整批暂存验证"]
 D --> E["全部合法后发布新映射"]

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

  1. 写出参数和返回值之间的类型关系。
  2. 优先用上界和通配符表达能力,避免原始类型。
  3. 外部 Object 通过 Class.cast 检查具体类。
  4. 暂存整批结果,全部合法后才替换可见状态。

03对照实现代码

Class.cast:显式令牌连接运行时检查 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/lang/Class.java · java.lang.Class#cast(Object)

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

核心机制代码 · java
public final class SourceModel {
  static <T> T checked(Class<T> type, Object value) {
    // 原创入口策略:本模型不接受基本类型令牌。
    if (type == null || type.isPrimitive()) throw new IllegalArgumentException("type");
    // isInstance 检查实际对象;null 在引用类型转换中合法。
    if (value != null && !type.isInstance(value)) throw new ClassCastException(type.getName());
    // 调用公开 API 完成带泛型返回值的受检转换,不绕过类型检查。
    return type.cast(value);
  }
}

本机 Class.cast 的实现先检查对象非空且不满足 isInstance 的情况,失败抛 ClassCastException,最后返回转换结果。它没有遍历集合内容,因此只能证明对象符合令牌代表的运行时类,无法证明一个 List 内每个元素都是 String。源码中的受控转换有前置检查作为依据,不应成为业务代码随处压制警告的理由。

Class<T> 与返回 T 的对应关系让调用者无需再写强转,但原始类型仍可能让调用者绕过编译期检查,因此入口进行 cast 仍有价值。基本类型令牌也存在,例如 int.class,却不能以 isInstance 接受 Integer;教学模型明确拒绝这类令牌,让注册表只处理引用类型。

源码允许 cast(null) 返回 null。本文注册表另外规定值非空,使 Optional.empty 只表示没有登记该类型。这是业务容器契约,不是 Class.cast 本身的规则。阅读源码时要把底层机制和调用方增设的约束分开,否则容易以为所有使用 Class 的 API 都不能保存空值。

若要验证 List<String>,需额外类型描述和元素遍历,必要时复制为受控集合;单凭一个 Class<List> 令牌不够。反射读取 Type 可以提供参数声明信息,却不会自动验证任意输入对象。算法因此选择异构单值注册表,直接展示 Class 令牌确实能够保证的范围。

代码先调用谁,再调用谁

  1. putBatch 接收类型与对象
  2. 检查令牌和值
  3. Class.cast 验证运行时类型
  4. 在副本中登记
  5. 发布新映射并按令牌读取

04动手写一个例子

先验证后发布的类型注册表

批量登记 Class 与对象,禁止空值、基本类型令牌及重复键;任一输入非法时保留原表。保存 Main.java,使用 javac --release 17 Main.java 与 java Main 运行固定自测。

输入 1
String.class -> hello,Integer.class -> 3
输出 1
按对应令牌取得 hello 和 3
输入 2
一条合法、一条类型错误
输出 2
整批失败,原表不变

直接在原表循环 put 会出现半批成功:第一条写入后第二条才发现错误,调用者得到异常却已经改变状态。先复制原映射,在副本上完成类型检查和重复判断,只有循环结束才替换字段,就能在单线程模型中实现验证失败不发布的语义。

每个键使用 Class 对象,值使用 Object 存储;读取时用同一个令牌 cast 恢复对应的 T。登记时每项同样经过 cast,建立键值匹配的不变量。重复键包括已有记录与本批前面出现的记录,两者都拒绝,避免依赖输入顺序悄悄覆盖。

这种复制发布只保障方法规定的验证异常,不等于数据库事务,也不提供跨线程的读写原子性。本文表封闭在一个对象内且没有暴露 Map,自测用相互独立的输入验证正常路径、空批、错误类型、重复键、基本类型与空值;强制构造错误 Object 能测试编译期之外的防线。

为什么这样写是对的已发布表中每个非空值都能通过其键 Class.cast;验证失败不会把副本发布。

时间 已有 n 项、输入 m 项,预期 O(n+m)。额外空间 O(n+m) 额外映射空间。

复制已有映射处理 n 项,逐项查重和写入在散列分布正常时预期常数成本;类继承检查成本视为固定类型层级下的常数。

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

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

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

Main.java · 完整可运行程序 · Java
import java.util.*;
public class Main {
  record Binding(Class<?> type, Object value) {}
  static final class Registry {
    private Map<Class<?>, Object> values = new HashMap<>();
    void putBatch(List<Binding> batch) {
      Objects.requireNonNull(batch);
      Map<Class<?>, Object> next = new HashMap<>(values); // 先复制,失败不触碰已发布状态。
      for (Binding b : batch) {
        Objects.requireNonNull(b);
        if (b.type() == null || b.type().isPrimitive()) throw new IllegalArgumentException("type");
        Object value = b.type().cast(Objects.requireNonNull(b.value())); // 运行时验证类型令牌。
        if (next.containsKey(b.type())) throw new IllegalArgumentException("duplicate");
        next.put(b.type(), value);
      }
      values = next; // 单线程模型中,仅在整批验证结束后发布。
    }
    <T> Optional<T> get(Class<T> type) { return Optional.ofNullable(type.cast(values.get(type))); }
    int size() { return values.size(); }
  }
  static void eq(Object a,Object b) { if(!Objects.equals(a,b)) throw new AssertionError(a+" != "+b); }
  static void fails(Class<? extends Throwable> type,Runnable r) {
    try { r.run(); } catch(Throwable e) { if(type.isInstance(e)) return; throw new AssertionError(e); }
    throw new AssertionError("expected "+type);
  }
  public static void main(String[] args) {
    Registry r=new Registry();
    r.putBatch(List.of(new Binding(String.class,"hello"),new Binding(Integer.class,3)));
    eq(r.get(String.class).orElseThrow(),"hello"); eq(r.get(Integer.class).orElseThrow(),3);
    r.putBatch(List.of()); eq(r.size(),2); // 空批不会清空旧表。
    eq(r.get(Long.class),Optional.empty());
    fails(ClassCastException.class,()->r.putBatch(List.of(new Binding(Long.class,4L),new Binding(Double.class,"bad"))));
    eq(r.get(Long.class),Optional.empty()); eq(r.size(),2); // 核对合法前缀也没有泄漏。
    fails(IllegalArgumentException.class,()->r.putBatch(List.of(new Binding(String.class,"again"))));
    fails(IllegalArgumentException.class,()->r.putBatch(List.of(new Binding(int.class,1))));
    fails(NullPointerException.class,()->r.putBatch(List.of(new Binding(Float.class,null))));
    System.out.println("PASS 7");
  }
}

这些特殊情况也要试一试

  • 空批保留原表。
  • 重复键、基本类型、错误类型明确失败。
  • 容器只验证对象类型,不验证参数化集合内部元素。
进阶选读 · 支持参数化类型的受控注册

场景需要保存 List<String> 等结构。

做法使用显式类型描述递归验证,并复制可变嵌套容器;定义循环对象、空值与共享引用策略。

复杂度至少与被检对象图规模成正比;需访问集合防止循环。

好处与代价表达力提高,但描述类型不等于运行时证明,验证后还要控制后续修改。

05项目里怎么用

公共库应尽量让调用者不必使用编译器无法确认安全的类型转换;确实无法避免的转换,集中放在少量容易检查的位置。写 max 一类方法时,可用递归上界,也就是在上界里再次用到这个类型参数,说明元素必须怎样参与比较;写 copy 时,分清从哪个参数读取元素、向哪个参数写入,再选择 extends 或 super。

除成功样例外,还可以保留本应编译失败的错误用法,确认修改接口后它们仍被编译器拒绝,避免放过不安全的调用。

面对反序列化、插件和反射等外部对象,不能只相信泛型声明。可以采用 Class.cast、显式校验器及不可变结果共同维护边界。类加载器不同也可能产生同名但不兼容的类,因此错误信息应保留类型和加载来源,同时避免打印敏感对象完整内容。插件卸载时还应注意强引用 Class 的缓存生命周期。

批量复制适合较小、更新较少的配置表,频繁大量复制会增加内存分配与 GC。并发更新时,可以在锁内检查并发布结果,或构造内容固定、不再被修改的映射,再用 CAS 替换并在失败后重试;不能只包一层不可修改视图,却让底层数据继续变化。重试时还要重新确认去重判断所依据的数据没有过时。容器里类型放对了,也不表示金额正负或版本搭配正确,这些仍要单独校验。

容易踩的坑

  • 用原始类型消除编译错误,却将异常推迟到读取位置。
  • 把 @SafeVarargs 当作自动检查器。
  • 认为 Class<List> 能验证字符串元素。
  • 批处理中先改原表,再在后项失败时忘记恢复。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
具体类型 API简单清楚,错误提示直接可复用范围较窄领域对象固定的业务层
泛型加 Class 令牌连接静态关联与实际类检查无法表达所有参数化内容插件单值目录
完整类型描述与验证器支持复杂嵌套结构实现和维护成本较高通用序列化边界

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01为什么 List<Integer> 不能传给 List<Number>?

展开参考答案与推导

若允许,接收方可以添加 Double,原调用方再以 Integer 读取就会失败。使用 extends 通配符表达只读生产者,才能维持元素类型约束。

02@SafeVarargs 会替调用者检查每个元素吗?

展开参考答案与推导

不会。它是作者承担安全责任的声明,用来控制相关警告;需要审查数组写入与逃逸。即使加上注解,危险实现仍可能造成堆污染。

03擦除是否意味着反射完全读不到泛型?

展开参考答案与推导

不是。类和成员的声明签名可保留泛型描述,但普通集合实例通常不能证明实际元素类型。声明信息与对象内容验证是不同层次。

换个情况,还会处理吗

01异常为何出现在读取而不是错误写入处?

展开参考答案与推导

原始类型写入可能只按 Object 处理而没有检查;编译器在参数化读取处插入转换时才暴露错误。应追溯首次不受控写入,而非仅在读取处吞掉异常。

02批量登记失败后表多了一条,如何修复?

展开参考答案与推导

把所有校验与更新放到私有副本,在全部合法后发布,或采用能保证回滚的明确机制。测试应同时断言异常类型和表状态,不只验证抛错。

03第三方库返回原始 List 怎么接入?

展开参考答案与推导

在边界逐个检查元素并复制成自己的类型化结果,对非法内容给出明确错误;仅给整个 List 加强转无法建立元素安全性。

写代码试一试

01写一个保留返回类型的最大值签名。

输入 / 条件
List<Child>,Child 继承父类比较能力
输出 / 预期
Child

提示 比较器允许父类型。

展开参考答案与推导

可用 <T extends Comparable<? super T>> T max(List<? extends T> xs),再定义空集合策略。不能只写 Comparable<T> 就假定适合所有继承场景。

02登记 String 和错误 Integer 值后表多大?

输入 / 条件
初始空表;String->ok,Integer->bad
输出 / 预期
0

提示 先验证再发布。

展开参考答案与推导

第二项 cast 失败,局部副本丢弃,原表仍为空。这是算法应维护的失败状态,不应返回第一项已提交的结果。

03如何验证 List<String> 的外部值?

输入 / 条件
Object 指向 [a,1]
输出 / 预期
拒绝

提示 不要仅检查 List.class。

展开参考答案与推导

先检查是 List<?>,再逐项确认非空元素是否为 String,按空值策略决定允许与否,并复制输出;检查发现整数时拒绝整个结果。

读完之后,独立完成

课后作业:自己做一遍

本篇共 8 道作业,先完成 6 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能设计有明确上界的泛型方法,并审查擦除、可变参数和未检查转换风险。

本篇重点加练

重点核验运行时可检查的类型范围与安全声明,防止泛型 API 承诺超出实际能力。

  • 可具体化类型限制
  • SafeVarargs 与运行时元素校验

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

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

基础必做

  1. 作业 01原理问答

    设计从一组可比较对象中选择最小项的方法,给出方法类型参数与 Comparable 约束;用拥有可比较父类型的子类检验签名适用范围。

    提交内容
    签名、类型关系图及正反编译案例。
    怎样算完成
    • 边界表达真实可比较关系
    • 方法类型参数作用域明确

    练到的知识点方法类型参数 · Comparable 递归上界

  2. 作业 02代码推演

    设计两个接收 List<String> 与 List<Integer> 的同名方法,并记录编译结果;另用通配符和显式类型参数表达两输入间的联系,比较两种写法能表达哪些要求。

    提交内容
    独立编译样例与签名比较。
    怎样算完成
    • 说明擦除影响的位置
    • 关联类型信息未无故丢失

    练到的知识点类型参数与通配符联系 · 擦除后重载冲突

综合应用

  1. 作业 03排错实验

    为泛型数组、原始类型和泛型可变参数各建立一个小型实验,启用编译器 unchecked 警告;记录哪些写法被拒绝、哪些仅警告,以及运行失败是否出现。

    提交内容
    隔离源码、完整编译选项与结果记录。
    怎样算完成
    • 警告和错误分类准确
    • 污染来源与读取失败分开

    练到的知识点可具体化类型限制 · 堆污染来源 · unchecked 警告

  2. 作业 04设计评审

    库方法接收泛型 varargs,团队拟添加 @SafeVarargs 消除警告。定义审查方法体与数组逃逸的检查项目,并提出外部调用者需要的运行时验证。

    提交内容
    注解适用性审查和反例测试计划。
    怎样算完成
    • 注解声明有实现依据
    • 不将消除警告等同安全证明

    练到的知识点SafeVarargs 与运行时元素校验

  3. 作业 05编程实践

    实现 checkedCopy(Class<T>, List<?>),逐项验证元素后返回类型化副本,明确 null 元素政策;输入包含合法子类型、错误类型及空列表。

    提交内容
    Java 实现、自写位置/类型/边界测试与运行证据。
    怎样算完成
    • 结果没有相关 unchecked 强转
    • 失败位置可定位
    • 副本与输入容器隔离

    练到的知识点方法类型参数 · 类型参数与通配符联系 · 堆污染来源 · unchecked 警告 · SafeVarargs 与运行时元素校验

  4. 作业 06编程实践

    实现只接受可具体化类型令牌的插件结果验证器,输入既可能是普通对象也可能是数组;记录元素检查的范围与不支持的参数化类型需求,不以 unchecked 强转消除问题。

    提交内容
    Java 实现、自写类型/数组/空值测试与运行证据。
    怎样算完成
    • 验证范围能从接口获知
    • 拒绝和接受条件有边界样本

    练到的知识点可具体化类型限制 · SafeVarargs 与运行时元素校验

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

  1. 作业 07设计评审

    完成反射 Type 基础后:接口要求校验 List<List<String>>。说明现有 Class<T> 令牌合同不能表达的部分,制定扩展后的验证范围和嵌套规模限制。

    提交内容
    能力边界文档、扩展类型模型与验收样本。
    怎样算完成
    • 不把外层检查当作完整验证
    • 嵌套深度和元素数量有预算

    练到的知识点Comparable 递归上界 · 类型参数与通配符联系 · 可具体化类型限制

思维拓展

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

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

    审查“给 varargs 方法加安全注解就可以把收到的数组保存给任意调用方”的主张。构造会暴露或支持该承诺的类型与别名关系,并注明编译器可检查和不可检查的部分。

    提交内容
    最小类型草图、别名图和证据边界。
    怎样算完成
    • 论证关注数组生命周期与元素类型
    • 没有把注解本身当作实现证明

    练到的知识点可具体化类型限制 · SafeVarargs 与运行时元素校验

完成后,再检验一次

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

继续阅读

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

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