从 T/? 到协变逆变
文章目录
- [从 T/? 到协变逆变](#从 T/? 到协变逆变)
-
- 泛型到底是什么
- [T 和?](#T 和?)
-
- [为什么需要??只用 T 不行吗?](#为什么需要??只用 T 不行吗?)
- [List 和 List<?> 的天壤之别](#List 和 List<?> 的天壤之别)
- 通配符的读写规则:为什么只能读或只能写?
-
- [无界通配符 `?`:只能读不能写](#无界通配符
?:只能读不能写) - [上界通配符 `? extends T`:只能读不能写](#上界通配符
? extends T:只能读不能写) - [下界通配符 `? super T`:只能写不能精确读](#下界通配符
? super T:只能写不能精确读)
- [无界通配符 `?`:只能读不能写](#无界通配符
- 协变与逆变
- [PECS 原则](#PECS 原则)
-
- [从数据流动方向理解 PECS](#从数据流动方向理解 PECS)
- 总结
泛型语法看着简单, List<String>天天写,但一遇到 ? extends T、 ? super T就开始犯迷糊;协变和逆变背了无数遍,一到实际用的时候还是搞反;最痛苦的是,明明知道 "生产者 Extends 消费者 Super",却永远不知道为什么要这样。
我曾经也和你一样,把泛型当成一堆需要死记硬背的规则,直到我从编译器的视角和类型系统的本质出发,才终于打通了所有关节。这篇文章,我会用最直白的语言和你每天都在写的代码,讲透泛型所有 "反直觉" 设计背后的逻辑。
泛型到底是什么
泛型 是 JDK 5 引入的特性,允许在定义类、接口、方法时使用类型参数 ,将类型作为参数传递,实现代码复用和类型安全。所有泛型的困惑,本质上都源于一个核心事实:Java 泛型是 "伪泛型"。
编译器的语法糖
泛型是 JDK 5 引入的特性,它的唯一终极目标是:让所有类型错误都在编译期被发现,绝对不允许运行时出现ClassCastException。
为了实现这个目标,编译器帮你做了两件事:
- 编译期严格的类型检查:在代码编译阶段就拦截所有类型不匹配的操作
- 编译后自动插入强制类型转换:把所有泛型代码转换成 JDK 1.4 时代的原始类型代码
这就是类型擦除 :所有泛型信息只存在于编译阶段,运行时 JVM 中没有任何泛型信息。Java 泛型通过泛型擦除实现,泛型信息只存在于编译阶段,编译后会被擦除,运行时 JVM 中没有泛型信息。擦除后类型参数会被替换为边界类型(无界则为 Object),编译器自动插入强制类型转换。
java
// 你写的代码
List<String> list = new ArrayList<>();
list.add("Hello");
String str = list.get(0);
// 编译后JVM看到的代码
List list = new ArrayList();
list.add("Hello");
String str = (String) list.get(0); // 编译器自动插入强转
所有泛型限制的根源:类型擦除
所有泛型限制,本质上都是类型擦除导致运行时类型信息缺失的必然结果:
- ❌ 不能用基本类型作为类型参数:擦除后变为 Object,基本类型不能赋值给 Object
- ❌ 不能
new T():擦除后变为new Object(),无法创建 T 的实例 - ❌ 不能创建泛型数组:数组是协变的,会导致类型污染
- ❌ 静态成员不能使用类泛型参数:静态成员属于类,泛型参数属于实例
- ❌
instanceof不能判断具体泛型类型:运行时没有泛型信息
记住这个结论:泛型是编译器给你的保护,不是 JVM 的特性。所有泛型的设计,都是为了在类型擦除的前提下,尽可能保证编译期类型安全。
T 和?
很多人学泛型的第一个困惑就是:既然有了 T,为什么还要有??它们到底有什么区别?
| 特性 | 类型参数 T |
无界通配符 ? |
|---|---|---|
| 本质 | 具体确定的类型,编译时绑定 | 未知的任意类型 |
| 使用阶段 | 定义泛型(类、接口、方法) | 使用泛型(变量、参数、返回值) |
| 读取能力 | 完整读取,返回 T 类型 | 只能读取为 Object |
| 写入能力 | 完整写入,接受 T 类型 | 除 null 外不能写入任何对象 |
| 类型一致性 | 同一实例中 T 完全一致 | 不同实例可以是不同类型 |
| 核心作用 | 定义通用模板,保证类型一致性 | 实现任意类型的通用处理 |
| 典型场景 | 泛型类 / 接口 / 方法定义 | 打印集合、获取集合大小 |
- T 是 "我知道它是什么类型,我要用它"
- ? 是 "我不知道它是什么类型,但我不需要知道它是什么类型"
为什么需要??只用 T 不行吗?
T 确实很强大,但它有一个致命的限制:T 要求类型一致。
假设你需要写一个方法,打印任意类型的 List。如果只用 T,你只能这样写:
java
public static <T> void printList(List<T> list) {
for (T element : list) {
System.out.println(element);
}
}
看起来没问题,但当你需要定义一个 "可以指向任意类型 List" 的变量时,T 就无能为力了:
// ❌ 编译报错!T不能用于变量声明
List<T> anyList;
// ✅ 用?就可以
List<?> anyList;
anyList = new ArrayList<String>();
anyList = new ArrayList<Integer>();
anyList = new ArrayList<Double>();
这就是?存在的意义:当你不需要关心具体类型,只需要做通用处理时,用?。
T 代表一个具体、确定、唯一 的类型.它在泛型实例化时被绑定到某个具体类型.同一泛型实例中,T 的类型必须完全一致.编译器可以对 T 进行完整的类型检查T 的核心作用,定义通用模板,保证输入输出类型一致。
? 代表某个未知的类型(注意:是 "某个" 不是 "任意").编译器不知道它具体是什么类型,但知道它存在.编译器只能对它进行最保守的类型检查?的核心作用,实现任意类型的通用处理,提高 API 的灵活性。
List 和 List<?> 的天壤之别
千万不要把List和List<?>混为一谈:
List是原始类型:完全放弃泛型检查,编译器不会做任何类型验证,是极度不安全的List<?>是无界通配符类型:编译器会做类型检查,保证类型安全
java
// 原始类型:完全不安全
List rawList = new ArrayList();
rawList.add("string");
rawList.add(123); // ✅ 编译通过,运行时炸弹
// 无界通配符:安全的任意类型
List<?> wildcardList = new ArrayList<String>();
wildcardList.add("string"); // ❌ 编译报错!
wildcardList.add(123); // ❌ 编译报错!
通配符的读写规则:为什么只能读或只能写?
通配符有三种形式:?、? extends T、? super T。它们的读写规则看起来非常反直觉,但只要你记住一个原则:编译器永远只会做它 100% 确定安全的事情。
无界通配符 ?:只能读不能写
- 读 :只能读取为
Object类型 - 写 :除
null外不能写入任何对象
编译器只知道List<?>是 "某个未知类型的列表",但不知道具体是什么类型。为了保证类型安全,它只能允许写入所有类型都能接受的值 ------ 也就是null;读取时也只能保证所有元素都是Object的子类。
上界通配符 ? extends T:只能读不能写
- 读 :安全读取为
T类型 - 写 :除
null外不能写入任何对象
为什么只能读?
无论列表实际持有的是哪个 T 的子类,它都可以安全地向上转型为 T,所以读取是安全的。
为什么不能写?
用反证法一推就明白:
java
List<? extends Number> list = new ArrayList<Double>();
// 假设允许add(123)
list.add(123); // 向Double列表中添加Integer
Double d = list.get(0); // ❌ 运行时ClassCastException
编译器不知道?具体是哪个子类,任何写入都可能导致类型不匹配,所以只能禁止所有写入操作。
下界通配符 ? super T:只能写不能精确读
- 写 :安全写入
T或T的子类 - 读 :只能读取为
Object类型
为什么可以写?
无论列表实际持有的是哪个 T 的父类,T 及其子类都可以安全地向上转型为该父类,所以写入是安全的。
为什么不能精确读?
同样用反证法:
java
List<? super Integer> list = new ArrayList<Object>();
list.add("Hello");
// 假设允许读取为Integer
Integer num = list.get(0); // ❌ 将String赋值给Integer,运行时异常
编译器不知道?具体是哪个父类,只能保证所有元素都是Object的子类,所以只能读取为 Object。
协变与逆变
协变和逆变是泛型中最抽象的概念,但它们不是 Java 的发明,而是所有现代类型系统的核心特性。
| 特性 | 解决的问题 | 核心价值 | 一句话总结 |
|---|---|---|---|
| 协变(extends) | 一个方法处理所有子类的对象 | 减少数据生产者的代码冗余 | 我有很多不同的子类对象,我想用一个方法统一处理它们 |
| 逆变(super) | 一个处理器处理所有子类的对象 | 减少数据消费者的代码冗余 | 我有一个通用的处理器,我想把它用在所有子类的场景 |
- 协变是关于数据的:我有很多不同的数据,我想统一读取它们
- 逆变是关于行为的:我有一个通用的行为,我想把它应用到很多不同的数据上
先搞懂子类型关系
所有协变逆变的讨论,都建立在子类型关系 这个基石之上。子类型的本质不是 "继承",而是可替换性 :如果 A 的任何对象都可以安全地替换 B 的任何对象,那么 A 就是 B 的子类型(记作A <: B)。
普通类型的子类型关系是固定的:String <: Object、Integer <: Number。但泛型类型的子类型关系是可变的,这就是协变和逆变。
协变:保留子类型关系
定义 :如果A <: B,那么F<A> <: F<? extends B>。子类型关系被保留了。
最直观的例子:数组协变
Java 数组天生是协变的,但这是一个有缺陷的设计:
java
String[] strArray = new String[10];
Object[] objArray = strArray; // ✅ 数组协变
objArray[0] = 123; // ✅ 编译通过,运行时抛出ArrayStoreException
数组协变把编译期就能发现的错误推迟到了运行时,破坏了类型安全。泛型吸取了这个教训,默认是不变的,但通过? extends T实现了安全的协变。
协变的本质与价值
- 本质:"我保证这个容器里的所有元素都是 T 的子类,所以你可以安全地把它们当成 T 来读"
- 价值:解决数据生产者场景的代码冗余问题
没有协变,你需要为每个 Number 子类写一个求和方法;有了协变,一个方法就够了:
java
public static double sum(List<? extends Number> list) {
double sum = 0.0;
for (Number num : list) {
sum += num.doubleValue();
}
return sum;
}
逆变:反转子类型关系
定义 :如果A <: B,那么F<B> <: F<? super A>。子类型关系被反转了。
这是最反直觉的部分,99% 的人都会在这里卡住:"父类型的泛型,怎么会是子类型泛型的子类型?"
先纠正最致命的误解
❌ 错误理解:逆变是 "把父类对象传给子类变量"
✅ 正确理解:逆变是 " 把能处理父类的处理器 ,传给需要处理子类的地方"
举个生活例子:你需要一个能修苹果手机的师傅,现在有一个能修所有手机的师傅,你会不会雇佣他?当然会!因为他的能力比你需要的更强。
这就是逆变:能处理大的(父类)的处理器,天然就能处理小的(子类)。
逆变的价值:你每天都在使用它
逆变不是什么高深的理论,你每天都在无意识地使用它:
java
List<String> strList = Arrays.asList("a", "b", "c");
strList.forEach(System.out::println); // ✅ 这就是逆变!
你有没有想过,为什么这行代码能编译通过?
forEach需要的是Consumer<? super String>System.out.println是Consumer<Object>
你把一个能处理所有 Object 的消费者,传给了一个需要处理 String 的地方。这就是最典型的逆变!
如果没有逆变,你连System.out.println都不能直接用,必须为每个类型包一层 lambda:
java
// 没有逆变时,你必须这样写
strList.forEach(s -> System.out.println(s));
再看一个更震撼的例子:比较器的复用
java
// 一个能比较所有数字的比较器
Comparator<Number> numberComparator = (a, b) -> a.intValue() - b.intValue();
List<Integer> intList = Arrays.asList(3, 1, 2);
List<Long> longList = Arrays.asList(3L, 1L, 2L);
Collections.sort(intList, numberComparator); // ✅ 逆变
Collections.sort(longList, numberComparator); // ✅ 逆变
一个比较器,搞定所有数字类型的排序。如果没有逆变,你必须为每个数字类型都写一个完全相同的比较器。
逆变的本质
逆变反转的不是对象的子类型关系 ,而是处理器的子类型关系。
- 对象的子类型关系:
Dog <: Animal(Dog 是 Animal 的子类) - 处理器的子类型关系:
Consumer<Animal> <: Consumer<Dog>(能处理 Animal 的消费者,是能处理 Dog 的消费者的子类)
为什么会反转?因为处理器的能力范围越大,它的适用场景就越多,就越应该是子类型。
Consumer<Animal>能处理所有动物,适用范围:所有动物Consumer<Dog>只能处理狗,适用范围:只有狗
显然,Consumer<Animal>的适用范围比Consumer<Dog>更广,所以它应该是Consumer<Dog>的子类型,可以被用在任何需要Consumer<Dog>的地方。
这就是逆变最本质的逻辑:能力越强,越通用,就越应该是子类型。
- 本质:"我保证这个容器可以接受 T 的所有子类,所以你可以安全地写入 T 和它的子类"
- 价值:解决数据消费者场景的代码冗余问题
PECS 原则
PECS = Producer Extends, Consumer Super。很多人把它当成一个需要死记硬背的规则,但实际上它是前面所有结论的自然总结。
从数据流动方向理解 PECS
java
数据流动方向:外部 → 容器 → 外部
当数据从容器流出时:容器是生产者 → 用? extends T
当数据流入容器时:容器是消费者 → 用? super T
- 生产者(Producer):只向外提供数据,不写入数据 → 需要安全读取 → 协变
- 消费者(Consumer):只向内接收数据,不读取数据 → 需要安全写入 → 逆变
- 如果容器既是生产者又是消费者,不要使用通配符,直接使用具体类型 T
总结
Java 泛型的所有 "反直觉" 设计,本质上都是类型安全和历史兼容性之间的权衡。为了兼容 JDK 1.4 的数十亿行代码,Java 选择了类型擦除这种 "伪泛型" 实现;为了在类型擦除的前提下保证类型安全,又不得不做出一系列看起来反直觉的设计。
当你不再把泛型当成一堆需要死记硬背的规则,而是从编译器的视角和类型系统的本质出发,你会发现所有的设计都变得顺理成章。协变不是什么魔法,它只是让你能统一读取所有子类;逆变也不是什么反人类的设计,它只是让你能复用通用的处理器。
希望这篇文章能帮你彻底打通泛型的任督二脉。从此,你不再需要死记硬背任何规则,而是可以从第一性原理出发,推导出所有泛型的正确用法。