泛型(generic)让类、接口和方法声明类型参数,从而在编译期表达输入、输出与容器元素之间的类型关系,而不必为每种具体类型复制实现。
List<Integer> 不是 List<Number> 的子类型;通配符也不是「关闭类型检查」的语法,? extends T 与 ? super T 允许的读写方向不同。
实现内部需要保持同一类型时声明 <T>;API 参数只生产值时使用 ? extends T,只消费值时使用 ? super T,并认真处理每个未检查警告。
是什么,为什么存在
Java 泛型(generic) 允许声明参数化类型和泛型方法。List<String> 中的 String 是类型实参,它把列表的元素类型固定为 String;class Box<T> 中的 T 是 类型参数(type parameter) ,由使用方在具体位置提供类型。
泛型解决的重点不是「少写一次强制转换」,而是保存类型之间的关系。static <T> T first(List<T> values) 表示返回值与列表元素是同一个 T。若改成接收 List<?> 并返回 Object,代码虽然能读元素,却丢掉了调用方需要的关系。
编译器会在调用点检查这些关系。把整数加入 List<String>、把 Optional<Integer> 当成 Optional<Number>,或者用不满足边界的类型实参,都会在编译期失败。泛型不能证明业务数据正确,但能排除一整类容器内容与 API 连接错误。
你会在集合、Optional、Stream、Comparator、异步 API 和框架扩展点中持续遇到泛型。自己设计 API 时,泛型适合表达「算法不关心具体类型,但若干位置必须一致」或「输入可以来自某个类型族」。若实现只对一种类型有意义,增加 <T> 只会把真实约束藏起来。
Java 泛型只接受引用类型作为类型实参,因此要写 List<Integer>,不能写 List<int>。自动装箱让多数调用看起来自然,但空值、对象分配和数值相等规则仍然属于包装类型的契约。
工作原理
类型参数写在声明名称或方法返回类型之前。类的 T 可用于实例字段、实例方法参数和返回值;静态成员不属于任何具体实例,不能直接使用类的 T。静态方法可以声明自己的 <T>,它与同名的类类型参数也是独立声明。
编译器通常从实参、目标类型和边界推断类型实参,所以 new ArrayList<String>() 常可写成 new ArrayList<>()。显式类型实参仍可写在方法名前,例如 Warehouse.<Number>first(values);只有在推断失败或需要说明意图时才值得这样做。
泛型类型具有不变性(invariance)。即使 Integer 是 Number 的子类型,List<Integer> 也不是 List<Number> 的子类型;否则,接收后者的方法就能加入 Double,破坏原列表的元素契约。数组采用协变且在运行时检查写入,因此不要把数组规则套到泛型上。
通配符(wildcard)表示某个受约束但未知的类型实参。它适合 API 使用点,不适合表示实现中需要反复命名的类型关系。下面的读写能力来自编译器对未知类型的保守判断:
| 形式 | 安全读取为 | 可安全写入 | 典型角色 |
|---|---|---|---|
List<T> | T | T | 同一确定类型 |
List<?> | Object | 只有 null | 任意列表,只观察 |
List<? extends T> | T | 只有 null | T 的生产者 |
List<? super T> | Object | T 及其子类型 | T 的消费者 |
PECS 是「Producer Extends, Consumer Super」的缩写。生产与消费描述的是某个参数相对于方法的作用,而不是容器类的永久身份。一个参数既要读出 T 又要写回 T 时,通常需要命名类型参数,而不是强行选择一个通配符。
上界限制可替换类型并开放边界成员,例如 <T extends Number> 允许实现调用 doubleValue()。多重边界中,类边界若存在必须放在最前,后面可以跟接口,例如 <T extends Number & Comparable<T>>。递归形式 <T extends Comparable<? super T>> 允许 T 与 T 的某个父类型定义自然顺序,通常比 Comparable<T> 更灵活。
类型参数与通配符解决不同问题。<T> 为未知类型命名,让多个位置保持关系;? 则明确调用方不需要知道该类型的名字。若一个类型只出现一次,额外声明 <T> 往往没有提供关系;若同一未知类型必须跨参数或返回值复用,通配符又可能过于模糊。
从签名推导能力
阅读复杂泛型签名时,不必先猜编译器的全部推断过程。先选一个调用点,把每个类型变量与通配符改写成约束,再检查方法体需要的操作是否由这些约束支持。
- 找出声明引入的类型参数,例如方法开头的
<T>。 - 标出同一个
T出现的所有参数、返回值和边界位置。 - 对每个
? extends参数只承诺读取上界,对每个? super参数只承诺写入下界。 - 检查调用点提供的类型是否同时满足所有约束,而不是逐个参数独立判断。
- 最后检查返回类型是否向调用方保留了实际有用的关系。
例如 static <T> T choose(T left, T right) 不要求两个实参的运行时类相同,而是要求编译器能推断出一个同时容纳两者的 T。若结果被赋给过宽的目标类型,调用仍能编译,但调用方可能主动丢失更精确的信息。
声明位置与使用位置
Java 的类和接口在声明位置定义类型参数及其上界,例如 class Box<T extends Item>。通配符出现在使用参数化类型的位置,例如 List<? extends Item>;它不会修改 List 本身的声明,而是限制当前引用能安全执行的操作。
| 设计需求 | 合适的签名形状 | 原因 |
|---|---|---|
| 输入与输出类型相同 | <T> T convert(T value) | 命名并保留关系 |
| 只遍历任意列表 | void inspect(List<?> values) | 不依赖元素类型 |
| 从子类型集合读取 | void read(List<? extends T> values) | 保留安全上界 |
| 向父类型集合写入 | void write(List<? super T> values) | 保留安全下界 |
| 对元素调用某个成员 | <T extends Bound> | 在实现中开放边界 API |
公开签名应以调用方真正需要的灵活性为准,而不是让每个位置都出现通配符。过窄的 List<T> 会拒绝安全调用,过宽的 List<?> 又会删除返回关系;最小但完整的约束通常最容易使用和测试。
示例
下面四个程序依次展示类型关系、PECS、边界与通配符捕获,以及擦除后的运行时视图。所有输出均由本地 OpenJDK 21.0.12 使用 javac --release 21 -Xlint:all 编译并执行得到;所用语法与 API 在目标 Java 25 中仍有效。
声明并推断类型参数
Bin<T> 把标签之外的载荷类型交给实例决定,first() 则为每次调用单独推断 T。返回值保持列表的元素类型,因此调用方无需强制转换。
import java.util.List;
public class Warehouse {
record Bin<T>(String label, T item) {}
static <T> T first(List<T> items) {
if (items.isEmpty()) {
throw new IllegalArgumentException("items must not be empty");
}
return items.getFirst();
}
public static void main(String[] args) {
var book = new Bin<>("A-12", "Effective Java");
var counts = List.of(3, 5, 8);
System.out.println(book.label() + ": " + book.item());
System.out.println(first(counts).getClass().getSimpleName()
+ ": " + first(counts));
}
}A-12: Effective Java
Integer: 3菱形语法 <> 让构造器从目标位置推断 Bin<String>,var 只省略局部变量上的重复拼写,并没有使变量变成动态类型。counts 的静态类型仍由初始化表达式确定。
空列表没有可返回的元素,因此方法显式拒绝它。用 null 作为哨兵会把本来清楚的返回类型关系变成额外的空值协议;如果空值有业务含义,可由调用方选择 Optional<T> 等更明确的接口。
用 PECS 连接不同的列表
源列表只向方法提供 T,所以它是生产者;目标列表只接收 T,所以它是消费者。同一个签名可以把 List<Integer> 追加到 List<Number>,同时保留编译期检查。
import java.util.ArrayList;
import java.util.List;
public class CopyOrders {
static <T> void appendAll(
List<? super T> destination,
List<? extends T> source) {
destination.addAll(source);
}
static double total(List<? extends Number> values) {
double result = 0.0;
for (Number value : values) {
result += value.doubleValue();
}
return result;
}
public static void main(String[] args) {
List<Integer> dailyOrders = List.of(3, 5, 2);
List<Number> report = new ArrayList<>();
appendAll(report, dailyOrders);
System.out.println(report);
System.out.println(total(report));
}
}[3, 5, 2]
10.0在 appendAll() 内,从 source 取得的值至少是 T,而 destination 保证能接收 T。反过来,从 destination 读取时只能依赖 Object,因为它的真实元素类型可能是 T、T 的父类或 Object。
total() 只读取数值,因此不需要把调用方限制为精确的 List<Number>。它不能向 values 加入 Integer 或 Double,因为实际列表可能是另一种 Number 子类型的列表。
组合边界与通配符捕获
max() 用边界保证元素可比较,swapFirstTwo() 则把未知类型交给私有辅助方法命名。这种通配符捕获让实现可以安全地取出并写回同一种未知元素。
import java.util.ArrayList;
import java.util.List;
public class BoundsAndCapture {
static <T extends Comparable<? super T>> T max(
List<? extends T> values) {
if (values.isEmpty()) {
throw new IllegalArgumentException("values must not be empty");
}
T result = values.getFirst();
for (T value : values) {
if (value.compareTo(result) > 0) {
result = value;
}
}
return result;
}
static void swapFirstTwo(List<?> values) {
swap(values, 0, 1);
}
private static <T> void swap(List<T> values, int left, int right) {
T saved = values.get(left);
values.set(left, values.get(right));
values.set(right, saved);
}
public static void main(String[] args) {
var queues = new ArrayList<>(List.of("fast", "bulk", "slow"));
swapFirstTwo(queues);
System.out.println(queues);
System.out.println(max(queues));
}
}[bulk, fast, slow]
slow公开方法不需要向调用方暴露辅助类型参数。编译器在调用 swap() 时为 List<?> 的未知元素类型创建一个捕获类型,并在一次调用内保持一致。
边界只保证 compareTo() 可用,不保证比较顺序适合业务。排序规则、空列表策略和 null 是否允许仍需由 API 契约说明;泛型不能替代这些领域决策。
观察擦除后的信息
两个不同类型实参的 ArrayList 共享同一个运行时类,但字段声明仍可携带泛型签名供反射读取。这两件事并不矛盾:对象通常不知道列表元素的具体类型,类文件中的声明位置却可以保留签名元数据。
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
public class ErasureView {
private List<String> names = List.of("Ada");
public static void main(String[] args) throws Exception {
List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();
Object candidate = strings;
System.out.println("same runtime class: "
+ (strings.getClass() == integers.getClass()));
Field field = ErasureView.class.getDeclaredField("names");
System.out.println("field class: " + field.getType());
System.out.println("field signature: " + field.getGenericType());
System.out.println("list<?> check: " + (candidate instanceof List<?>));
}
}same runtime class: true
field class: interface java.util.List
field signature: java.util.List<java.lang.String>
list<?> check: truefield.getType() 返回擦除后的 List 类,getGenericType() 读取声明签名。局部变量 strings 的类型实参不会变成每个列表对象上的运行时标签,因此不能写 candidate instanceof List<String>。
List<?> 是可具体化类型,可以用于 instanceof,因为检查只需确认对象是某种 List。检查成功后仍只知道元素来自某个未知类型,不能把它当成 List<String>。
陷阱
把子类型关系传给泛型容器
修复方法: 按参数的数据流选择 List<? extends Number> 或 List<? super Integer>。实现确实需要同一元素类型同时读写时,声明命名类型参数并让所有相关位置使用它。
用原始类型绕过编译错误
修复方法: 使用 javac -Xlint:all 检查原始类型与未检查转换,并逐个证明值的来源。抑制范围应尽可能小,注释要写出保持转换安全的不变量,而不是只写「编译器误报」。
把 extends 当成可写上界
修复方法: 需要写入 Integer 时使用 List<? super Integer>。若方法既读又写同一种元素,让 <T> 命名该类型;不要用非空试写来猜测通配符代表的真实类型。
误以为类型实参始终可在运行时查询
修复方法: 运行时确实需要类型时,显式传递 Class<T>、受控的类型令牌或解析器,并写清它能表示的类型范围。不要把字段签名中的反射信息误认为任意对象都保留同样信息。
把数组规则套到泛型上
修复方法: 大多数情况下改用 List<T>。必须与数组 API 交互时,要求调用方提供数组工厂或 Class<T>,把不可避免的转换封装在一个可验证边界,并测试错误组件类型。
擦除与运行时边界
Java 编译器会把类型参数擦除为其最左边界;没有显式边界时通常擦除为 Object。编译器还会在必要的位置插入强制转换,并可能生成桥接方法,以维持覆盖后的多态行为。擦除描述编译结果,不表示编译器在源代码检查阶段忽略泛型。
参数化类型的所有实例化通常共享一个类,而不是为 Box<String> 与 Box<Integer> 各生成一份类。静态字段也因此属于泛型类本身,不能按类型实参各保存一份值。这与 C++ 模板等按实例化生成代码的机制不同。
可具体化类型
可具体化类型(reifiable type)在运行时有足够完整的表示,可用于某些需要运行时检查的操作。基本类型、非泛型类、原始类型、所有实参都是无界通配符的参数化类型,以及部分数组类型属于这一类;List<String> 和类型参数 T 则不可具体化。
这一区别解释了为什么 candidate instanceof List<?> 合法,而 candidate instanceof List<String> 不合法。前者只检查原始列表身份,后者还要求运行时验证已被擦除的元素类型。模式匹配不会恢复缺失的类型实参。
new T() 与 T.class 也不可用,因为运行时没有一个由 T 自动提供的具体类对象。若构造是 API 契约的一部分,可接收 Supplier<? extends T>;若必须操作运行时类,则可接收 Class<T>,但它不能完整表示 List<String> 这样的嵌套参数化类型。
原始类型、未检查警告与堆污染
原始类型主要用于与泛型引入前的代码兼容。List 不等于 List<Object>:前者绕开部分泛型检查并产生未检查警告,后者明确只接收 Object 类型关系下允许的操作。新代码应把原始类型视为迁移边界,而不是简写。
堆污染(heap pollution)指参数化类型变量引用了不符合其声明类型的对象。原始类型赋值、未检查强制转换和某些泛型可变参数操作都可能造成这种状态;错误常在稍后的编译器插入转换处才表现为 ClassCastException。
泛型可变参数由数组承载,而参数化元素类型可能不可具体化,因此声明或调用会出现警告。@SafeVarargs 是程序员对方法实现的安全承诺,不会验证实现,也不会修复危险代码。只有在确认方法不向数组写入不兼容值、不暴露数组且调用方式安全后才应使用它。
通配符捕获
编译器会为每个通配符表达式引入新鲜的内部类型,这称为通配符捕获(wildcard capture)。因此,从 List<?> 读取后立即写回同一列表在某些直接表达式中仍可能无法通过类型检查;私有泛型辅助方法可以为一次操作命名捕获类型。
捕获只在相应表达式或调用的范围内保持身份。两个独立的 List<?> 参数不保证拥有同一种元素类型,即使运行时恰好都装着字符串。需要把一个列表的元素安全写入另一个列表时,应在公开签名中用 <T>、? extends T 与 ? super T 明确连接它们。
擦除、覆盖与重载
子类用具体类型覆盖泛型父类方法时,擦除可能让两个字节码签名不同。编译器可生成合成桥接方法,把擦除后的调用转发到具体覆盖方法;反射工具和堆栈信息因此可能看到源码中没有明确写出的桥接成员。
重载则必须在擦除后仍可区分。process(List<String>) 与 process(List<Integer>) 都擦除为接收 List,所以不能同时声明。改用不同方法名、不同原始参数类型,或把行为统一进一个泛型实现,不能依赖返回类型或类型实参区分重载。
类型推断与 API 演进
类型推断求解的是源码调用点上的约束,不是运行时检查。实参类型、目标类型、方法调用上下文和声明边界都可能参与求解;同一个泛型方法调用放在不同目标位置,推断结果可能不同。遇到难懂的错误时,先把嵌套调用拆成带显式静态类型的局部变量,通常比加入强制转换更能暴露冲突。
目标类型与捕获诊断
菱形语法、泛型方法和 lambda 都可能使用目标类型。把一个表达式从赋值位置移到无目标上下文的位置,可能使原来可推断的类型失去约束;这不是运行时行为变化,而是编译器可用信息变化。
编译错误中的 CAP#1 一类名称表示编译器为通配符创建的捕获类型,不是需要在源码中声明的类。诊断说明两个位置无法证明为同一类型时,应回到签名寻找缺失关系;若关系真实存在,用辅助泛型方法命名它,若不存在,就不能靠强制转换制造证明。
发布后的签名变化
泛型签名既服务源码检查,也会影响调用方重新编译时的可用性。把参数从 List<T> 放宽为 List<? extends T> 可能扩大源代码可调用范围,但方法体可执行的写操作也随之减少;改变边界则可能让原来合法的类型实参在重新编译时失败。
擦除后描述符相同不代表变更一定兼容。桥接方法、重载选择、返回位置的编译器转换和反射读取的泛型签名都可能受影响。发布库时应分别测试旧二进制调用方、新源码调用方和反射消费者,不能只看当前模块能否编译。
推断失败时的修复顺序
先确认 API 想表达的关系,再调整签名。可以按这个顺序排查:
- 给中间表达式增加有意义的局部变量类型,定位哪组约束冲突。
- 检查
extends与super是否按数据流方向使用。 - 确认多个参数是否真的共享一个
T,还是应各自拥有类型参数。 - 只有契约能证明安全时才使用显式类型实参或局部强制转换。
把所有变量改成原始类型通常会让报错消失,却也删除了最有价值的证据。保留警告并缩小问题表达式,才能区分 API 过窄、调用方类型错误和编译器需要更多目标信息这三类原因。
擦除的完整规则、签名属性与桥接细节属于 Java 类型擦除;通配符 API 的更多推导属于 Java 通配符与 PECS。当前主题只保留设计泛型接口所需的交界部分。
4个问题 · 1 道输出预测题 · 1 道找错题