泛型拾遗
基本设计原则
- how codes should vary in different types.
- compatible with other release.
- before generic class,generic programming was achieved with inheritance:在所有后来使用T的地方使用Object或者Object数组。经典的猫狗列表问题的来源。
基本语法
- java 的泛型没有 template 关键字。表面上(superficially)看和 C++ 并无二致,实际上有大量差别
- 三种基本概念:
- Type Parameter(类型形参):在泛型类、接口或方法声明时使用的标识符。
class Box<T>中的 T。- 但是在 Java 核心技术中,
List<String>中的String也经常被描述为 Type Parameter。Container.class.getTypeParameters()得到的也是T。
- Type Variable(类型变量):类型参数在代码中的引用。
- 可以认为是类型参数的一个"实例"。在
class Box<T> { T value; }中,value 的类型T是一个类型变量(而不是占位符T)。许多文档中"类型参数"和"类型变量"可互换使用。
- 可以认为是类型参数的一个"实例"。在
- Type Argument(类型实参):在使用泛型类型时,提供的具体类型。
- 在调用或实例化时提供。
Box<Integer>中的Integer。
- 在调用或实例化时提供。
- Type Parameter(类型形参):在泛型类、接口或方法声明时使用的标识符。
1 | |
- 泛型类的 type parameter 的声明在类背后。泛型方法的 type parameter 在 modifier(
public static)和return value之间。- 在 C++ 里,
f(g<a,b>(c))有两个意思:使用g<a,b>(c)的结果调用f;或者使用g<a和b>(c)调用 f。所以 java 泛型参数要写在方法前面,类型见证也在方法前面。
- 在 C++ 里,
List<T>是 generic type。List<String>是 Parameterized type。
边界/绑定类型(bounding type)
在Java泛型的上下文中,“bound"一词最准确的翻译是**“边界”(boundary)**而非"绑定”。Java官方文档一致使用"upper bound"(上边界)和"lower bound"(下边界)这样的术语,这些术语在数学和集合论中表示边界概念。
- 在类型参数 T 声明时就指定 bound,而不是在作为局部变量或者返回值时。
- 明确要绑定 type variable 到某个类型(
T extends BoundinType,擦除到这个 BoundinType)-得到 type argument,才可以在接下来的方法里调用那个类型的方法,如需要compareTo方法,就必须<T extends Comparable<T>>。extends本身可以后接多个绑定类型,用&分隔。- 这点同 C++ 是一样的,我们是为了调用某个方法才指定了 bound。
- extends 代表 subtype concept 的 approximation,并不是扩展的意思,在 bound 的使用里,它统一了扩展与实现。
- extends 只能有一个 class,用ampersand(
&)来分隔各种类型,用,来分隔 type variables。这会制造有限的菱形继承问题-这和 java 单继承的思路一致。T extends Comaprable & Serializable会把 T 擦除到 Comaprable(first bound),在某些需要作为 Serializable 的时候则编译器插入一个 cast instruction(如果必要的话,java 甚至会对.取值操作符进行 cast指令插入) - java 的编译器做这种插入对程序的其他部分算得上是可插拔的了。
- extends 只能有一个 class,用ampersand(
递归泛型边界模式(Recursive Generic Type Bounds)
这是被称作“F-bounded多态”(F-bounded polymorphism)或"递归泛型边界"的高级泛型设计模式。
1 | |
- T必须是RepresentationModel的子类
- 并且这个子类的泛型参数必须是T自身或T的某个子类
在这个例子里:RepresentationModel - type parameter 是 -> T <- 是子类,且这个子类的 type argument 是 T 自己 - RepresentationModel。
如果不使用这种类型:
1 | |
那么使用时会遭遇编译错误:
1 | |
这个问题的核心是,有些 fluent API 本身返回自身,这种自身不能返回子类时,父子混合 build 会产生奇怪的问题。
解法:
1 | |
使用时:
1 | |
这种方案的做法是:
- 父类所有方法都使用 T 作为 type variable。在父转子的时候,使用转型,忽略警告。
- 流式调用的时候都转为自己。
核心优势:
- 类型安全的方法链:子类调用父类方法时返回的仍然是子类类型。
- 代码复用:父类可以实现通用功能,子类无需重写这些方法。
- IDE支持:智能提示和自动完成可以正确显示返回对象的可用方法。
这种模式广泛应用于各种Java库中:
- Java的
Enum<E extends Enum<E>>。public abstract class Enum<E extends Enum<E>> implements Comparable<E>, Serializable {}- 确保类型安全:
<E extends Enum<E>>这种递归泛型定义确保了枚举类型只能被其子类实例化 - 提供类型正确的方法:使得像 compareTo 这样的方法能够正确接收子类类型的参数
- 实现自引用泛型:允许枚举类引用自身类型
- 极端重要!更复杂的例子,
<前边的部分,恰好是>前边的部分加上一个<?>,中间插入一个? extends:java.lang.Object&java.lang.Serializable&java.lang.Comparable<? extends java.lang.Object&java.lang.Serializable&java.lang.Comparable<?>>
- 确保类型安全:
- JPA的CriteriaBuilder
- Stream API-易被忽略
- 各种构建器模式实现
注意和 Comparable 比较
<T extends Comparable<T>>意味着 T 加入了一种以自己为目标的混型能力。RepresentationModel<T extends RepresentationModel<? extends T>>意味着这种类型要使用某些父子类型都共用的操作,必须让操作能够返回子类型而不是父类型。实现自引用类型层次结构。- 类似的例子还有:
public static <T> List<T> unmodifiableList(List<? extends T> list)。这种 T 总是在远处作为一个起点使用。实际的类型的多重绑定还要出现在方法参数或者返回值里。
- 类似的例子还有:
Comparable<T extends Comparable<? super T>>,意味着需要与父类型进行比较操作。
交叉类型(Intersection Types)
类型参数可以同时指定多个边界,语法为 <T extends A & B & C>。规则如下:
- 最多只能有一个类边界,且必须放在第一位
- 接口边界可以有多个
- 擦除后的类型为第一个边界
1 | |
交叉类型在 Lambda 表达式中也有特殊用途——可以将 Lambda 同时转换为多个类型:
1 | |
递归类型边界
递归类型边界(Recursive Type Bound)是指类型参数以自身为边界的模式,最典型的形式为 <T extends Comparable<T>>:
1 | |
递归类型边界确保了类型参数具备与自身同类型对象交互的能力。Comparable<T> 的 compareTo 方法接受 T 类型参数,因此 T extends Comparable<T> 保证了 T 的实例可以与其他 T 实例进行比较。
自限定类型(Self-bounded Types)
自限定类型是递归类型边界的一种特殊形式,常见于基类定义中:
1 | |
java.lang.Enum 的声明 Enum<E extends Enum<E>> 是自限定类型的经典应用。这一模式确保子类型的方法返回值和参数类型是子类型自身,而非父类型。
🔑 模式提炼:类型自约束
递归类型边界和自限定类型的核心思想是"类型参数约束自身"。当需要在基类中定义返回子类型的方法(如 Builder 链式调用、compareTo 比较)时,使用 <T extends Base<T>> 模式。Enum<E extends Enum<E>> 是 JDK 中最著名的应用。
类型擦除(type erasure)
类型擦除是 Java 泛型实现的核心机制。与 C++ 模板在编译时为每个类型参数生成独立代码不同,Java 编译器在编译后将所有泛型类型信息移除,替换为其边界类型(无边界时为 Object)。这一设计的根本原因是向后兼容:Java 5 引入泛型时,必须确保泛型代码能与 Java 1.4 及更早版本的字节码无缝互操作。
擦除遵循以下规则:
| 源码中的类型 | 擦除后的类型 | 说明 |
|---|---|---|
T(无边界) |
Object |
最常见的情况 |
T extends Number |
Number |
替换为第一个边界 |
T extends Comparable & Serializable |
Comparable |
多边界取第一个 |
List<String> |
List |
参数化类型擦除为原始类型 |
T[] |
Object[](或边界类型数组) |
泛型数组擦除 |
🔑 模式提炼:擦除即退化
类型擦除的本质是编译期的类型丰富信息在运行时退化为边界类型。理解这一点是理解所有泛型限制(不能 new T()、不能 instanceof T、不能创建泛型数组)的钥匙:这些操作都需要运行时的具体类型信息,而擦除恰恰移除了这些信息。
- 擦除的目的:为了 interoperability between generic and legacy code。所以把类型信息全擦除成无泛型,能在 java 1.0 上运行的字节码。
- 类型擦除的结果:将 type variable 替换成绑定类型(BoundingType)而不是类型实参的 ordinary class。如果
B<T extends Comparable>而A extends B<C>。那么A编译过后,得到的是一个B内所有T都替换成Comparable的类,但是在写代码的时候,编译器会在赋值的时候检查,且在取值的时候转型,保证所有的T相关的方法的类型全部都是C而不是 Comparable。如果我们声明的是B<T>,我们会得到使用 raw type 的 class(如ArrayList之于ArrayList<String>)-在字节码层面T全部换成了Object,即使我们在IDE里感觉我们仍然在使用C这一类型。 - 有了类型擦除,所有的泛型表达式都是可以被翻译的。带有泛型的代码被编译器翻译后通常分为两个部分:1. ordinary class,带有 bounding type,2. casting code,带有类型转换的代码。
- cast code 是为了保证类型安全而存在的。这些代码有些(普通的泛型set)是在编译时通过检查实现的,有些则是在代码里加上一些字节码指令实现(get和桥接的set)的。
- 假设
B extends A<C>,我们调用calc(new C())的时候,父类型的相关方法总是会被擦除到 bounding type(假定 C 的擦除类型是 Object),所以子类的方法也带有这个 bounding type 的方法实现calc(Object object)。但为了保持多态,如果子类也实现了一个calc(C c),编译器就会生成了一个calc(Object c)去调用calc(C c)(相当于把重载桥接成多态,见下面的“擦除的实际工作流程”),真正要使用多态,就必须产生一个 synthesized bridge method,执行calc(Object object) {calc((C)object);}。这个东西是为了保持多态(preserve polymorphism)。 - 为了兼容性(compatibility)考虑,
A a = new A<String>()之类的赋值(从泛型到擦除类型的赋值总是会成立的)总是会成立,但编译器总是会报 warning(作为原始类型 'generic. Pair' 的成员对 'setFirst(T)' 的未检查的调用)。在 Jackson 中经常遇见如下问题:实际反序列化的生成 class 是 LinkedHashMap,但Entity<A<B>> = JsonUtil.toObject(str)等还是会赋值成功(此处 实际得到的是A<LinkedHashMap>)。
擦除的实际工作流程
下图展示了类型擦除的完整流程:
flowchart TD
%% 类型擦除流程图
subgraph SourceCode["源代码阶段"]
A["泛型类型定义
public class Box~T~ {
private T value;
public T getValue() { return value; }
public void setValueT value { this.value = value; }
}"] --> B["编译器处理
1. 类型检查
2. 类型转换插入"]
end
subgraph ErasureProcess["类型擦除过程"]
B --> C{类型参数 T 有边界吗?}
C -->|无边界| D["擦除为 Object
类型变量 T → Object"]
C -->|有边界 T extends Number| E["擦除为边界类型
类型变量 T → Number"]
D --> F["生成桥接方法(如需要)
保持多态性"]
E --> F
end
subgraph Bytecode["字节码阶段(运行时)"]
F --> G["擦除后的代码
public class Box {
private Object value;
public Object getValue() { return value; }
public void setValueObject value { this.value = value; }
// 可能包含桥接方法
}"]
end
subgraph Runtime["运行时"]
G --> H["类型检查
编译时插入的 checkcast"]
H --> I["实际类型信息丢失
无法获取 T 的实际类型"]
end
style SourceCode fill:#e1f5ff
style ErasureProcess fill:#fff4e1
style Bytecode fill:#e8f5e9
style Runtime fill:#fce4ec
note1["类型擦除是编译时过程
泛型信息在编译后消失"] -.-> C
假设我们有一个 Pair 类:
1 | |
派生子类如下
1 | |
按照常见的规则,编译器会把所有的方法签名都改掉,如setSecond(T newValue)会被先编译为setSecond(LocalDate newValue)。
但是如果我们使用反编译器去尝试反编译这个类javap -c DateInterval.class,可以观察到如下事实:
1 | |
并不存在一个被重载的方法,什么换签名方法都不存在!
而如果我们这样写:
1 | |
子类中会出现两个 setSecond 方法:
1 | |
public void setSecond(java.time.LocalDate)是开发者定义的显式方法,而public void setSecond(java.lang.Object);是编译器合成(synthesized)的桥接方法(bridge method)。
1 | |
所以总结起来:
Pair<T>本质上 BoundingType 是 Object。extends Pair<LocalDate>得到的是一个所有 T 都 substitute 成 Object 的类型,作为运行时的无类型化类型使用。- 但是编译器会在编译 set 方法的时候严格限制类型,如果使用了非 LocalDate 的类型设值必定发生编译错误,而如果使用 get 方法,取出来的值会隐藏一个 checkcast 的字节码指令。
- 在调用子类实例的方法之前,会产生一个擦除类型的方法,即
setSecond(java.lang.Object),在内部会有一个 checkcast 指令的实现,达到setScond((LocalDate)second)的效果。 pair.setSecond(LocalDate.now());的时候,指向的是父类Pair<LocalDate>而不是DateInterval,调用setSecond只能指向父类方法,而不能是子类方法。但实际上我们调用的首先是桥接方法,然后才到具体类型的方法(栈帧为证)。也就是说,编译器知道推测方法的调用顺序,以及开发者在试图 override 实际上并未 override 的方法,因为父方法的签名是setSecond(java.lang.Object),我们声明一个setScond((LocalDate)second)实际上是在overload这个方法。编译器将 overload 转换为 override,即使加上@override注解也没问题,而且调用的时候能够实现多态。
所谓的泛型确实只有编译器才存在,参数实例化的泛型类里只留存一些绑定类型的边界信息,无边界泛型类的父方法的类型信息都是 Object,类型安全都是在编译器通过1检查、2插入桥接方法、3取值的时候cast实现的。这种实现保证了运行时无类型,又确保程序有多态效应。
验证桥接方法有2种方法:
- 反射检查:使用Method.isBridge()判断方法是否为桥接方法。
Method method = DateInterval.class.getMethod("setSecond", Object.class); - 字节码查看:通过
javap -c DateInterval.class查看生成的桥接方法,其修饰符包含ACC_BRIDGE(本例中没有试出来)。如果直接查看 Pair.class,我们会看到方法仍然使用泛型:public void setSecond(T);。桥接方法仅在子类主动重写父类泛型方法时生成。
桥接方法的另一种例子
一个类型实现了 Clonable,天然是 Object 的子类。它实现自己的clone的时候,会得到两个方法:
1 | |
桥方法的生成规则
桥方法(Bridge Method)是编译器为维持类型擦除后的多态性而自动生成的合成方法。根据 JLS §13.1,桥方法具有以下特征:
- 生成时机:当子类覆写父类泛型方法,且类型擦除导致方法签名不一致时,编译器自动插入桥方法。
- 字节码标志:桥方法在字节码中带有
ACC_BRIDGE和ACC_SYNTHETIC标志。 - 转发调用:桥方法的方法体仅包含类型转换和对实际方法的转发调用。
下图展示了桥接方法的生成和调用流程:
sequenceDiagram
%% 桥接方法工作原理图
participant Source as 源代码
participant Compiler as 编译器
participant SubClass as 子类<br/>Node
participant Bridge as 桥接方法
participant RealMethod as 实际方法<br/>setDataT data
participant SuperClass as 父类<br/>MyNode
Note over Source: 泛型类定义
Source->>Compiler: class MyNode~T~ {
setDataT data
}
Note over Source: 子类重写
Source->>Compiler: class Node extends MyNode~Integer~ {
setDataInteger data // 重写
}
Compiler->>Compiler: 类型擦除
Note right of Compiler: MyNode.setDataObject data
Compiler->>SubClass: 生成桥接方法
Note right of Compiler: 保持多态性
SubClass->>Bridge: 桥接方法签名
Note right of Bridge: public void setDataObject data {
this.setDataInteger data // 类型转换
}
SubClass->>RealMethod: 实际方法
Note right of RealMethod: public void setDataInteger data {
super.setDatadata; // 调用父类
}
Note over SuperClass: 父类方法
SuperClass->>SuperClass: setDataObject data
rect rgb(200, 220, 240)
Note over Bridge,SuperClass: 运行时调用流程
Client->>SubClass: 调用 setDataObject
SubClass->>Bridge: 桥接方法
Bridge->>Bridge: 检查类型<br/>if data instanceof Integer
Bridge->>RealMethod: 调用实际方法<br/>this.setDataInteger data
RealMethod->>SuperClass: super.setDatadata
end
Note over Bridge: 桥接方法作用:
1. 保持方法签名与父类一致
2. 执行类型检查
3. 委托给实际方法
1 | |
使用 javap -c -p StringComparator.class 可以观察到桥方法的存在。桥方法的签名与擦除后的父类方法签名一致(参数为 Object),方法体将参数强制转换为具体类型后转发给实际的覆写方法。
🔑 模式提炼:擦除桥接
桥方法是编译器为弥合"源码级泛型多态"与"字节码级擦除签名"之间的鸿沟而生成的适配器。当覆写泛型方法时,编译器自动生成一个签名与擦除后父类方法一致的桥方法,内部通过强制转换转发调用。这一机制对开发者透明,但在反射调用(Method.isBridge())和字节码分析时需要注意区分。
类型 clash
1 | |
两个method本身不是重载,直接签名冲突:'method(T)' 与 'method(Object)' 冲突;两个方法具有相同的擦除。
与遗留代码的冲突
把 generic class 的实例赋给 raw type class 的实例会遇到编译器警告;反过来,把 raw type class 的实例赋给 generic class 的实例也会遇到编译器警告-在跨模块传递数据时,后者往往更危险。
现代代码编写原则是:
- 远离早期的数据结构。
- 全程使用泛型类而非原始类型——除非在反射或反序列化场景中不得不获取原始类型,此时必须明确内存中的实际类型,并使用
@SuppressWarnings("unchecked")标注。 - 通过不安全的类型转换,可能将
List<Integer>赋给List<String>,这会导致堆污染(heap pollution)。
钻石操作符 <>
Java 7 引入了钻石操作符(Diamond Operator),允许编译器从赋值目标推断构造器的类型参数:
1 | |
Java 9 进一步扩展了钻石操作符的适用范围,允许在匿名内部类中使用:
1 | |
钻石操作符是类型推断改进的一部分,与 Type Witness 互为补充:钻石操作符简化了构造器调用,Type Witness 则在推断失败时提供显式类型指定。
Type Witness 类型见证/目击者
类型见证的抽象模式:
1 | |
常见使用场景:
-
当没有足够的上下文进行推断时:
1
var result = Collections.<String>emptyList(); -
在复杂的泛型嵌套中:
1
2// 假设存在 someMethod 方法
// someMethod(Collections.<Map<String, Integer>>emptyList()); -
在 Java 7 及以前:这行代码可能不能编译,因为
processStringList的参数可能是个泛型类型,而实参赋值的时候无法推导列表类型:1
2// 假设存在 processStringList 方法
// processStringList(Collections.emptyList());
其中第二种情况更常见,如果方法调用左边有足够的目标类型,编译器不会产生有歧义的推导(Inference)路径。
泛型不能做什么(Restricstions and Limitations)
所有泛型的 bug 都与一个基本假设有关,在虚拟机里运行的类型实际上使用的是 raw type,不注意这一点就可能出错。parameterized type vs raw type。
不能使用基本类型实例化 type parameters
C++可以。
动态类型识别(inquiry)只能在 raw type 上工作
instanceof、Class ==、getClass() 这类操作都只能当做 raw type 工作:
- 实际上不能
a instanceof Pair<String>,这会导致一个编译错误 - 可以
(Pair<String>)a,这会导致一个编译警告。 stringPair.getClass() == employeePair.getClass() // true
不能创建 ParameterizedType 作元素的数组
但可以声明和赋值到泛型数组,这就是 GenericArrayType 的用处。
- GenericArrayType 是
T[]或List<String>[],List<String>是ParameterizedType。 - -创建不可能:
Pair<Sring>[] a = new Pair<String>[10]是不可能的。是Pair<String>而不是 T。T[] arr = new T[10];是不可能的。var table = (Pair<String>[])new Pair<?>[10]是合法的,可以 new 通配符数组,然后强转型。
Object[] arr = new Pair<String>[10]这样是合法的,而且这个arr实际上是Pair[],所以它可以接受任意的Pair-不管是Pair<String>还是Pair<Employee>,但不能接受String。这会导致 cast 异常。
类型参数可以和vargargs 一起工作
如果使用@safevarargs连警告都不会有。也就是说可以这样创建泛型数组Pair<String>[] pair = makePairs(pair1, pair2)// makePairs 的签名是(T... arr),且直接返回这个 arr (这一条破坏了“不能创建parameterized type 的数组”规则)。
但泛型数组还是危险的,因为数组本身还是 raw type,往里面写入一个Pair<Integer>可以,取出来会有转型问题。
不能初始化 type variables:
- 最常见的非法写法是
new T()。- 因为 T 实际上会擦除到 Object,不能调用程序员意图里的那个构造器。
- 但 T t 是可以且很常见的。
- workaround:
- java8:
Supplier<T> constr constr.get(),然后在使用的时候,构造器的方法引用就是一个Supplier<T>。 - java8以前:在使用 T 的地方都使用
Class<T>,然后借反射来生成对应的对象:Class<T> cl cl.getConstructor().newInstance()。
- java8:
不能实例化泛型数组
T[] a = new T[1] // error
如果我们需要得到某种类型的数组,可以采取如下方法:
- 先找到一个泛型 List,然后使用下文提到的“空泛型数组的拷贝方法”。
- 想办法生成一个数组,然后
(T[])做转型-实例化不可以,但是转型可以。
这一条和上面的 ParameterizedType 条目差不多。
泛型类的 type variables 不能在它的 static 上下文里工作
但静态方法自己可以有 type variables。
我们可以声明一个class Single<T>,但这个Single里的静态方法不能引用T。静态方法里可以自己声明另一个<U>。
不能抛出和捕获泛型类
T extends Throwable合法。method throws T合法,throw (T)t合法。catch(T t)非法。Problem<T> extends Exception非法。
巧妙地使用泛型,可以破坏 checked exception 的限制
1 | |
我们骗过了编译器,让它认为asRunnable抛出的不是受检异常:
- 调用 asRunnable 以后,我们不需要写受检异常的 handler,也不需要
new RuntimeException(ex)来 wrap。 - 运行时
throw (T) t;不会报错,因为擦除是擦除到 Throwable,我们仍然在更上游遇到这个异常时和遇到普通异常的流程是一致的。
永远不要写boolean equals(T object)
因为每个泛型方法都有一个兜底的 raw type 方法兜底,如果兜底方法和父类的非泛型方法相冲突(clash),编译会报错。
编译器会擦除出 boolean equals(Object object),制造同签名的方法,连重载都算不上。
只能写:
1 | |
这样构成了显式override。
所以这里有一个极端重要的认知:
- 我们不能在泛型类里声明擦除后有同签名的方法。
foo(T o)和foo(Object o)不算一种重载,算一种 clash。但我们可以写@override foo(Object o){}来 override。 - 我们可以在实例化的子类里面写具体类型来 overrload 擦除方法,编译器会帮我们把overload转成 override。例子见上面“擦除的实际工作流程”。
再谈数组和泛型类的继承和协变
数组隐藏有类型信息。所以虽然数组是协变的,但是
1 | |
但是泛型类是完全擦除的,所以对于 A List 里插入 B 元素,如果允许继承,则真的会发生混合插入的问题。
Java 有一个习惯,就是不完全防止堆污染,有时候靠在取值的时候抛出异常来解决。
泛型与数组
数组协变 vs 泛型不变
Java 数组是协变的(covariant):如果 B 是 A 的子类型,则 B[] 是 A[] 的子类型。这一设计在 Java 1.0 时代为多态数组操作提供了便利,但也引入了运行时类型安全风险:
1 | |
泛型则是不变的(invariant):List<String> 不是 List<Object> 的子类型。这一设计将类型错误从运行时提前到编译时:
1 | |
为什么不能创建泛型数组
new T[] 和 new List<String>[10] 都是非法的。根本原因在于类型擦除与数组的具化(reification)特性冲突:
- 数组是具化的:数组在运行时知道自己的元素类型,并在每次赋值时执行类型检查(
ArrayStoreException)。 - 泛型是擦除的:泛型类型参数在运行时不存在,无法为数组提供运行时类型检查所需的信息。
如果允许创建泛型数组,将导致类型安全漏洞:
1 | |
替代方案:
1 | |
🔑 模式提炼:具化与擦除的冲突
数组的协变+具化设计与泛型的不变+擦除设计存在根本矛盾。当需要"泛型容器"时,优先使用 List<T> 而非 T[]。当确实需要泛型数组时,通过 Array.newInstance() 配合 Class<T> 令牌在运行时创建。
泛型能够做什么
- type variable 可以拿来做目标参数,如
T t = get()、(T),(T[])。 - 我们始终可以把 parameterized type 往 raw type 转。
ArrayList<Integer>可以往List<Integer>转-我们经常是这样声明的。
通配符(wildcard)
- ? 表明了 Type variable 是怎样 vary的。
- 泛型不是协变的,但 ? 用来实现某些人类似协变的需求,产生了一个泛型类家族的父类/子类(通配符允许泛型类的继承树在泛型层面出现,而普通泛型则只允许泛型类的继承树在外部类的层面出现)。这增加代码的灵活性,同时保持类型安全。
1 | |
反直觉的两种子类关系,子类意味着类型兼容:
Pair<? extends Employee>意味着它的子类 可以是Pair<Employee>和Pair<Manger>。Pair<? super Employee>意味着它的子类(而不是超类)可以是Pair<Employee>和Pair<Object>。
PECS 原则
编译器在编译时并不会真的把 ? 替换为具体类型——虽然开发者经常在概念上这样想。
PECS 讨论的是,Employee 作为目标类型,什么时候只能消费-作为参数/右值,什么时候只能生产-作为返回值/左值。
? extends T作为一个 type parameter 证明可以在 T t 处读。
? super T 作为一个 type parameter 证明可以在 T t 处写。
这个原则很好地示范了**定义(简单性)-例子(复杂性)-助记符(简单性)**之间的关系。
1 | |
由于编译器无法确定具体类型,所以干脆不允许 set/call(消费),但可以get。
1 | |
用赋值和设值,用 collection 的 get 和 add 比较好理解这个规则。
🔑 模式提炼:读写分离边界
PECS 的本质是泛型的读写分离:? extends T 保证读出的值至少是 T(安全读取),? super T 保证写入的 T 值一定兼容容器类型(安全写入)。设计 API 时,参数如果只读就用 extends,只写就用 super,既读又写就用精确类型 T。
下图展示了通配符类型的关系和 PECS 原则:
graph LR
%% 通配符继承关系与 PECS 原则
subgraph WildcardTypes["通配符类型"]
A["? 无界通配符
Unknown Type"]
B["? extends T 上界通配符
Covariant (协变)"]
C["? super T 下界通配符
Contravariant (逆变)"]
end
subgraph PECS["PECS 原则"]
P["Producer Extends
生产者使用 extends"]
C2["Consumer Super
消费者使用 super"]
end
subgraph Examples["使用示例"]
E1["List~? extends Number~
只能读取不能写入
Number n = list.get0;"]
E2["List~? super Integer~
只能写入不能读取
list.addnew Integer1;"]
end
subgraph Inheritance["继承关系"]
I1["Integer <: Number"]
I2["List~Integer~ <: List~? extends Integer~"]
I3["List~Integer~ <: List~? super Integer~"]
end
A -.->|"可以视为"| B
A -.->|"可以视为"| C
B -->|"适用于"| P
C -->|"适用于"| C2
P --> E1
C2 --> E2
style WildcardTypes fill:#e3f2fd
style PECS fill:#f3e5f5
style Examples fill:#e8f5e9
style Inheritance fill:#fff3e0
note["? extends T: 只读,不能写入
? super T: 只写,不能读取确型"] -.-> B
日期的例子-?和 T 同时出现
LocalDate是ChronoLocalDate的子类(顺序就是这样,没有反过来),但 ChronoLocalDate 已然实现了 comparable<ChronoLocalDate>。这时候 LocalDate 的比较方法就应该声明成<T extends Comparable<? super T>>。这是减轻 caller 程序员负担,而把复杂度留给库本身的一种做法。
还有一个例子:int compareTo(? super T),这意味着通配符不是T的实参。
从 A 派生出 B,则 B 的 comparable 方法必须声明为可以支持 super,这样对 A 的 compare 才能同时兼容 A、B - 而不只是 B,Lists 的 removeIf 方法的谓词同理。
举例,Java 8 中的 ArrayList 有个 removeIf 的方法,它的参数是个 predicate,但这个 predicate 的实参可以是,比如 Employee,也可以是 Object(用上了 super):default boolean removeIf(Predicate<? super E> filter)。
更严格的<?>
?在没有其他类型 extends/super 的时候,只能在 get 赋值用,等同于<? exntends Object>。再次强调,?不是T的实参。
Pair<?> 是一个功能受限的类型(原文称为 wimpy type),其 setFirst(? object) 方法无法被调用——根据 PECS 法则,编译器无法确定传入的值是否兼容未知的通配符类型。而 getFirst 方法的返回值也只能赋给 Object。
但 Pair 的 set 方法可以使用任意类型,而 get 方法可以赋给 Object。
如果某些场景只需从 Pair<T> 内部读值,那么 Pair<?> 比 Pair<T> 更加简洁易读。
通配符捕获:单层<?>参数方法套着单层<T>参数方法,而逻辑是在内层方法里写的
? 不是 type variable,是 wildcard variable。
通配符捕获要解决的问题是:无法用 ? 来写代码,不能当作左值的类型来用。? a = Pair.getFirst() 是不合法的。因此无法编写 swap 方法:
通配符捕获的解法是:引入一个 T 来捕获通配符,用 T 来写代码:
1 | |
在这里,T 捕获了通配符。T 不知道 ? 的具体类型,但知道它是某个确定类型-就当 Object 用。
编译器只有在确定 T 可以捕获确定的通配符的时候才允许编译通过。例如List<Pair<?>> 多了一个间接层,一个 list 可能有不同的 Pair<?>(这足以证明?即使是在一个数据结构里,也可能产生多个类型Pair1<?>内部的的?是同一种?,但是 Pair2<?>完全是另一种),持有不同的具体类型,编译器不会允许捕获 List<Pair<T>>里的 T。
1 | |
需要的类型: List <List<T>> 提供的类型: List <List<?>> 原因: 不兼容的相等约束: T 和 ?
🔑 模式提炼:通配符捕获桥接
当需要对通配符类型执行读写操作时,编写一个私有泛型辅助方法来"捕获"通配符:外层方法接受 <?>,内层方法用 <T> 捕获具体类型。编译器通过类型推断将 ? 绑定到 T,从而在辅助方法内部获得完整的类型操作能力。注意:多层嵌套的通配符(如 List<List<?>>)无法被捕获,因为内层的 ? 可能代表不同的具体类型。
幽灵类型
例子来自于《Java中的幽灵类型》和《你真的懂 Builder 设计模式吗?论如何实现真正安全的 Builder 模式》。
忽略了特别复杂的阶段式构造,因为它是与泛型无关的。
构造类型的用法
- 用接口子类当作状态,而不是枚举,这样可以作为 generic 的bound使用。
- 定义泛型类,泛型类只有私有构造器,不允许外部创建,因为这是泛型类,所以构造器在调用的时候是一定可以带有类型见证的,如
new Plane<Landed>(10)。 - 定义初始构造方法,只允许生成初始态实例。
- **关键:**定义复制构造方法,允许
? extend FlightStatus这个类型族的实例相互拷贝,这样产生了外层方法。 - 定义各种跳转方法, 关键:在签名和返回值上做幽灵类型的切换,内部只使用同一个参数族方法复制来复制去,每次复制的时候使用新的类型见证
return new Plane<Flying>(p);。
不基于接口的用法
1 | |
builder pattern
1 | |
- 把 builder 泛型化,每个字段对应一个泛型参数。
- 定义 TRUE、FALSE 两个状态。
- 构造器要求字段对应的泛型参数全部都是 TRUE。
- 每个 builder 只保留一个本参数对应的类型实参,其他部分仍然泛化-部分泛型。
- 如果一个字段有默认值,它不具有对应的泛型参数。
🔑 模式提炼:编译期状态机
幽灵类型将运行时状态检查提前到编译期:用不同的类型(而非枚举值)表示状态,用泛型参数携带状态信息,用方法签名的类型变换表达状态转移。非法的状态转移在编译期就会报错,而非等到运行时才抛出异常。这一模式特别适合 Builder、状态机、协议流程等需要强制执行调用顺序的场景。
泛型与反射
泛型与 class
String.class是Class<String>的一个 object。
Class 的 type variable 实际上限制了它方法的种种返回值。
Java 在5以后,确切地把各种constructor相关的方法都<T>参数化了。
反射能够在擦除后知道些什么
可以知道的以下东西:
- 一个方法或者类型有个 type parameter T。
- T 有 super 或者 extends 的 bound。
- T 有个 wildcard variable - wildcard 和 T 会同时存在。
不可以知道的东西
- 到底运行时绑定的 type parameter 是什么?how type parameter were resolved.
获取类型信息
Type[] actualTypes = ((ParameterizedType) type).getActualTypeArguments()什么时候能够得到String?
丢失类型信息就无法 ParameterizedType 转型。
什么情况下会丢失类型信息
- 方法参数(如
void process(List<T> list))。 - 局部变量(如
List<T> list = new ArrayList<>())。 - 泛型方法的类型推断(如
Collections.emptyList())。
什么情况下能够获取到类型信息
- 类或接口继承时显式指定泛型参数(如 class MyList extends ArrayList
)。 - 使用匿名内部类:
List<String> list = new ArrayList<String>() {}; // 匿名子类- gson的类型令牌:
List<String> list = gson.fromJson(json, new TypeToken<List<String>>() {}.getType());// 这里获得的 Type 是最顶层的java Type 接口
- gson的类型令牌:
1 | |
🔑 模式提炼:匿名子类类型捕获
TypeToken 利用了一个关键事实:虽然泛型在运行时被擦除,但类定义中的泛型信息会保留在字节码的 Signature 属性中。通过创建匿名子类 new TypeToken<List<String>>() {},泛型参数 List<String> 被固化到子类的类定义中,可通过 getGenericSuperclass() 在运行时获取。Gson、Jackson、Spring 的 ParameterizedTypeReference 都基于这一原理。
反射的类型 hierarchy

下图展示了 Java 反射 Type 接口的完整层次结构:
classDiagram
%% Java 反射 Type 接口层次结构图
class Type {
<<interface>>
Type 类型表示
}
class Class {
<<class>>
表示原始类型和泛型类
+getTypeParameters()
+getComponentType()
}
class TypeVariable {
<<interface>>
表示类型变量(如 T, E)
+getName()
+getBounds()
+getGenericDeclaration()
}
class ParameterizedType {
<<interface>>
表示参数化类型(如 List~String~)
+getActualTypeArguments()
+getRawType()
+getOwnerType()
}
class GenericArrayType {
<<interface>>
表示泛型数组类型(如 T[])
+getGenericComponentType()
}
class WildcardType {
<<interface>>
表示通配符类型(如 ? extends T)
+getUpperBounds()
+getLowerBounds()
}
Type <|-- Class
Type <|-- TypeVariable
Type <|-- ParameterizedType
Type <|-- GenericArrayType
Type <|-- WildcardType
note for Type "java.lang.reflect.Type\n所有类型的顶级接口"
note for Class "表示具体的类类型\n如 String, List, Integer"
note for TypeVariable "泛型类型变量\n如 T, E, K, V"
note for ParameterizedType "参数化类型\n如 List~String~, Map~String, Integer~"
note for GenericArrayType "泛型数组\n如 T[], List~E~[]"
note for WildcardType "通配符类型\n如 ?, ? extends Number, ? super String"
反射的基础类型是Type接口,它有五个子类:
Class
描述具体类型(而不是接口)。我们常见的类型如果本身不带有<>就属于这一类型。对 Java 1.5 以后的泛型,一个Map.class和Map<String, Integer>的 class 在求等上完全相等,但唯有引入其他 Type 子类才能说明String和Integer的存在。
- 最常见的 Type 实现类。
- 代表原始类型(raw types)和基本类型(primitive types)
1 | |
TypeVariable(类型变量)
- 表示泛型中的类型变量
- 例如泛型定义中的 T、K、V 等
1 | |
ParameterizedType(参数化类型)
- 描述泛型类或接口类型,如
Comparable<? super T>- 注意,如果丢失了类型信息(非子类型),此处是 T 而不是 String。常见的 ParameterizedType 有 Collection 的 Class 实例。可以简单把 ParameterizedType 理解为 指向 Collection 类的特殊的 class,它携带的 actualParamters 就是String/Integer 的 class,而 WildcardType 的 bounds 就是 extends A 的那个 A 的 class。它还有一个 getRawType(也就是 Map),和 ownerType(也就是 Map 之于 Entry)。 - 主要方法:
getActualTypeArguments(): 获取泛型参数的实际类型getRawType(): 获取原始类型getOwnerType(): 获取所属类型
1 | |
GenericArrayType(泛型数组类型)
GenericArray 接口,描述泛型数组如 T[]。这可以被用在获取方法参数上。获取到具体参数以后,可以把它转化为 ParameterizedType,然后获取它携带的 actualParamters。例如:T[] 或 List<String>[]。
1 | |
1 | |
主要使用场景
- 反射获取泛型信息
- 泛型类型系统的实现
- 框架开发中的类型判断和处理
- 注解处理器的开发
- 序列化/反序列化框架
这些类型系统的实现让 Java 能够在运行时获取和处理泛型信息,尽管 Java 的泛型是通过类型擦除实现的,但通过这些接口仍然可以在运行时获取到泛型的类型信息。
🔑 模式提炼:Type 五子类分治
处理反射中的泛型类型时,采用分治策略:对 Type 实例依次判断是否为 Class(具体类型)、ParameterizedType(参数化类型)、TypeVariable(类型变量)、WildcardType(通配符)、GenericArrayType(泛型数组),分别提取对应的类型信息。这五种子类型覆盖了 Java 类型系统的所有可能形态。
TypeLiteral
有一个可以捕获多重泛型的实参的方案。
1 | |
运行时捕获类型参数的方法
方法一
1 | |
方法二
方法三
1 | |
这个方法来自于 JDK 5 的作者的博客《super-type-tokens》。
- abstract class 保证了这个类型必须通过子类确定,这样
getGenericSuperclass必定会得到一个 ParameterizedType 而不仅仅是一个 GenericType。 implements Comparable<TypeReference<T>>并不是真的希望子类覆写一个比较方法,而是希望子类型不要实现成一个 raw type。
保证了这两天_type一定是一个 concret class。
泛型的重载限制
由于类型擦除,以下重载在 Java 中是非法的:
1 | |
擦除后 List<String> 和 List<Integer> 都变为 List,导致方法签名冲突。解决方案:
- 使用不同的方法名:
processStrings(List<String>)和processIntegers(List<Integer>) - 使用通配符统一:
process(List<? extends CharSequence>) - 添加额外参数区分:利用幽灵类型参数(参见本文"幽灵类型"章节)
各种泛型黑魔法
类似幽灵类型的模式匹配
1 | |
获取通配符的 bound
此处并非在代码中使用通配符,而是确切知道?的 bound 是什么:
1 | |
Optional 里通配符转成类型参数
可见@SuppressWarnings("unchecked") java 的基础库里到处都是。
1 | |
确认消费类型的两种方法
1 | |
}
1 | |
这个方法的特点:
- 自动调整数组大小。
- 可以像 go 一样往参数填充数据。
- 类型安全:
String[] rightWay = list.toArray(new String[0]);优于String[] wrongWay = (String[]) list.toArray();。
泛型在 Lambda 与 Stream 中的类型推断
Java 8 引入的 Lambda 表达式和 Stream API 大量依赖泛型类型推断。编译器通过目标类型推断(Target Type Inference)确定 Lambda 表达式的类型参数。
目标类型推断
1 | |
Stream 链式调用中的类型传播
1 | |
当类型推断失败时,可以通过类型见证(Type Witness)显式指定:
1 | |
🔑 模式提炼:目标类型驱动
Lambda 和 Stream 的类型推断遵循"目标类型驱动"原则:编译器从表达式的使用上下文(赋值目标、方法参数、返回类型)反向推断类型参数。当推断失败时,使用类型见证 ClassName.<Type>method() 显式指定。
泛型与注解(Type Annotations)
Java 8(JSR 308)引入了类型注解,允许在任何使用类型的位置添加注解,包括泛型类型参数:
1 | |
类型注解与 Checker Framework 等静态分析工具配合,可以在编译期检测空指针、线程安全等问题,将运行时错误提前到编译期。
泛型最佳实践
避免原始类型
原始类型(Raw Types)绕过了泛型的类型检查,应始终使用参数化类型:
1 | |
优先使用 List 而非数组
泛型与数组存在根本矛盾(参见"泛型与数组"章节),在泛型上下文中应优先使用集合:
1 | |
PECS 原则的实践
在设计 API 时,遵循 Producer Extends, Consumer Super 原则:
1 | |
模式速查表
| 场景关键词 | 对应模式 | 方案 |
|---|---|---|
| 类型擦除后方法签名冲突 | 擦除桥接 | 编译器自动生成桥方法,反射时用 Method.isBridge() 过滤 |
| 需要泛型数组 | 具化与擦除的冲突 | 使用 Array.newInstance() 或 List<T> 替代 |
| 基类方法返回子类型 | 类型自约束 | <T extends Base<T>> 自限定模式 |
| 运行时获取泛型类型 | 类型令牌 | TypeToken / TypeReference 匿名子类捕获 |
| Lambda 类型推断失败 | 目标类型驱动 | 类型见证 ClassName.<Type>method() |
| 泛型方法不能重载 | 擦除签名冲突 | 使用不同方法名或通配符统一 |
| 读取用 extends,写入用 super | PECS | <? extends T> 生产者,<? super T> 消费者 |
| 编译期空值检查 | 类型注解 | @NonNull + Checker Framework |





