1. 泛型上下边界到底在解决什么问题
很多写了三五年Java的人,看到List<? extends Number>和List<? super Integer>还是会愣一下。这两个东西在实际业务代码里出现的频率其实不低,尤其是你去看JDK源码、框架源码,或者自己封装一些通用工具类的时候,绕不开它们。但大多数教程一上来就给你画继承树、讲型变规则,看完更晕了。
我先说结论:泛型上下边界本质上是在“读取”和“写入”这两个动作之间做取舍。你不可能既要一个泛型容器能安全地读,又要它能安全地写,Java的类型系统不允许你这么贪心。extends偏向读,super偏向写,记住这一条,后面所有的规则都是它的推论。
1.1 从一个真实的翻车场景说起
假设你在做一个数据统计模块,需要写一个方法,接收一组数字,然后求和。你可能会这么写:
public static double sum(List<Number> list) { double total = 0; for (Number n : list) { total += n.doubleValue(); } return total; }看起来没问题对吧?但当你尝试传入一个List<Integer>的时候,编译器直接报错:
List<Integer> ints = Arrays.asList(1, 2, 3); sum(ints); // 编译错误:不兼容的类型为什么?因为List<Integer>并不是List<Number>的子类型。这是Java泛型的一个基本设计原则——泛型默认是不变的(invariant)。也就是说,List<Integer>和List<Number>之间没有任何继承关系,哪怕Integer是Number的子类。
这个设计不是Java故意为难你,而是为了类型安全。想象一下,如果List<Integer>能赋值给List<Number>,那会发生什么:
List<Integer> ints = new ArrayList<>(); List<Number> numbers = ints; // 假设允许 numbers.add(3.14); // 往一个Integer列表里塞了个Double Integer i = ints.get(0); // 炸了所以Java选择了不变性来保证安全。但这样一来,像sum这种只读的方法就没法复用了。这时候extends就登场了。
1.2 协变、逆变、不变——三个词说清楚
在Java泛型的语境里,这三个词描述的是类型参数在继承关系中的传递方向:
| 术语 | 含义 | Java中的体现 | 典型场景 |
|---|---|---|---|
| 不变(Invariant) | A是B的子类,但List<A>和List<B>无关 | List<T>默认行为 | 既要读又要写的场景 |
| 协变(Covariant) | A是B的子类,则List<A>也是List<B>的子类 | List<? extends B> | 只读场景 |
| 逆变(Contravariant) | A是B的子类,则List<B>是List<A>的子类 | List<? super A> | 只写场景 |
注意,Java的泛型其实只支持使用点(use-site)的型变,不支持声明点(declaration-site)的型变。什么意思?就是你不能在定义类的时候说“我这个类对T是协变的”,只能在用的时候通过通配符来指定。这是Java泛型设计上的一个历史包袱,跟C#和Scala的做法不同,但这不是我们今天要展开的话题。
回到上面那个sum方法,用extends改写:
public static double sum(List<? extends Number> list) { double total = 0; for (Number n : list) { total += n.doubleValue(); } return total; }现在List<Integer>、List<Double>、List<Long>都能传进来了。这就是协变带来的灵活性。
1.3 为什么不能既extends又super
有人可能会想,那我写List<? extends Number super Integer>不就行了?不行,语法上就不允许。而且从逻辑上讲,这也是矛盾的。
extends Number意味着你把这个列表当作“Number的某个未知子类型的列表”,你只能从中读取Number(因为不管实际类型是什么,它一定是Number的子类),但你不能往里写,因为你不知道实际类型到底是Integer还是Double。
super Integer意味着你把这个列表当作“Integer的某个未知父类型的列表”,你可以往里写Integer(因为不管实际类型是什么,它一定是Integer的父类,能容纳Integer),但你读出来的东西只能是Object。
这两个语义是互斥的。一个容器要么偏向读,要么偏向写,不可能同时偏向两边。这就是著名的PECS原则(Producer Extends, Consumer Super)的由来,后面我会详细展开。
2. extends和super的读写规则拆解
理解了“读”和“写”的取舍之后,我们来看具体的规则。这部分是很多人容易记混的地方,我尽量用最直白的方式讲清楚。
2.1? extends T:能读不能写
先看一段代码:
List<? extends Number> list = new ArrayList<Integer>(); Number n = list.get(0); // 可以读,读出来是Number list.add(1); // 编译错误!不能写 list.add(null); // 唯一能写的值为什么不能写?因为编译器不知道list的实际类型参数是什么。它可能是List<Integer>,也可能是List<Double>,还可能是List<Long>。如果你能往里写一个Integer,那万一实际类型是List<Double>呢?类型安全就被破坏了。
所以编译器干脆一刀切:只要用了extends,除了null之外,什么都不能往里加。null是所有引用类型的共同值,所以它是唯一安全的。
那读出来为什么是T?因为不管实际类型是什么,它一定是T的子类,所以用T来接收是安全的。这就是协变的核心价值——保证读取的类型安全性。
2.2? super T:能写不能精确读
再看super:
List<? super Integer> list = new ArrayList<Number>(); list.add(1); // 可以写Integer list.add(2); // 也可以写Integer的子类 Object obj = list.get(0); // 只能读成Object Integer i = list.get(0); // 编译错误!为什么能写?因为不管实际类型是List<Number>还是List<Object>,它们都能容纳Integer。所以往里写Integer是安全的。
为什么读出来只能是Object?因为编译器不知道实际类型是Integer的哪个父类。可能是Number,可能是Object,也可能是其他。为了安全起见,只能退化成Object。
这里有个细节值得注意:list.add(1)之所以能编译通过,是因为add方法的参数类型是? super Integer,而Integer本身满足这个约束。但如果你尝试list.add(3.14),编译器会报错,因为Double不是Integer的子类。
2.3 PECS原则的完整推导
PECS是Joshua Bloch在《Effective Java》里提出的,全称是Producer Extends, Consumer Super。翻译过来就是:如果这个容器是生产者(你从里面读数据),用extends;如果它是消费者(你往里面写数据),用super。
这个原则不是拍脑袋想出来的,而是从上面的读写规则直接推导出来的:
- 你需要读数据 → 读出来的类型要尽量精确 → 用
extends T,读出来是T - 你需要写数据 → 写进去的类型要尽量宽泛 → 用
super T,能写T及其子类 - 既读又写 → 老老实实用具体的类型参数,别用通配符
我见过很多代码,明明只需要读,却用了List<T>,导致方法签名不够通用;也见过只需要写的地方用了extends,结果发现add不进去。归根结底是没有想清楚这个容器在你的方法里扮演什么角色。
2.4 一个容易踩的坑:返回值类型
看这个方法签名:
public static <T> List<T> merge(List<? extends T> a, List<? super T> b) { // ... }这里a是生产者,用extends;b是消费者,用super。但如果你把返回值写成List<? extends T>,调用方拿到之后就没法add了。所以返回值通常用具体的List<T>,把灵活性留给参数。
这个模式在JDK的Collections.copy方法里有体现:
public static <T> void copy(List<? super T> dest, List<? extends T> src) { // ... }src是生产者,用extends;dest是消费者,用super。完美符合PECS。
3. 通配符捕获与类型擦除的交互
这部分稍微进阶一点,但理解了之后,你对泛型的很多疑惑会豁然开朗。
3.1 通配符捕获是什么
看这段代码:
public static void swap(List<?> list, int i, int j) { list.set(i, list.get(j)); // 编译错误! }list.get(j)返回的是?类型(捕获变量),list.set需要的是?类型,按理说应该匹配。但编译器不认,因为这两个?虽然看起来一样,但编译器把它们当作不同的捕获变量。
正确的写法是引入一个辅助方法:
public static void swap(List<?> list, int i, int j) { swapHelper(list, i, j); } private static <T> void swapHelper(List<T> list, int i, int j) { T temp = list.get(i); list.set(i, list.get(j)); list.set(j, temp); }这就是通配符捕获:通过一个泛型方法,把?捕获成具体的类型参数T,然后就可以正常读写了。
JDK里Collections.swap就是这么实现的。这个技巧在实际开发中很有用,尤其是当你需要对一个List<?>做原地修改的时候。
3.2 类型擦除带来的限制
Java泛型是编译期的,运行时会擦除。这意味着:
List<String> a = new ArrayList<>(); List<Integer> b = new ArrayList<>(); System.out.println(a.getClass() == b.getClass()); // true两个不同类型的List,运行时类是一样的。这带来几个实际限制:
- 不能
new T(),因为运行时不知道T是什么 - 不能
new T[],同样原因 - 不能对泛型类型做
instanceof判断(list instanceof List<String>编译错误) - 静态方法不能使用类的类型参数
这些限制在写通用工具类的时候经常遇到。比如你想写一个方法,根据传入的Class对象创建实例:
public static <T> T create(Class<T> clazz) throws Exception { return clazz.getDeclaredConstructor().newInstance(); }这里必须显式传入Class对象,因为运行时拿不到T的信息。这就是所谓的类型令牌(Type Token)模式。
3.3 桥接方法与协变返回类型
类型擦除还会导致一个有趣的现象——桥接方法。看这个例子:
class Parent { Object get() { return null; } } class Child extends Parent { @Override String get() { return "hello"; } }Child的get方法返回String,Parent的返回Object。这是合法的协变返回类型。但擦除之后,Child里实际上有两个get方法:一个是返回String的,一个是编译器自动生成的桥接方法,返回Object,内部调用返回String的那个。
用javap -c反编译就能看到。这个知识点在理解泛型继承体系的时候很有用,尤其是当你遇到一些奇怪的编译错误时。
4. 实战中的典型应用与避坑指南
理论讲完了,来看几个实际开发中常见的场景,以及我踩过的坑。
4.1 通用集合工具方法的设计
假设你要写一个方法,把源列表的所有元素添加到目标列表:
public static <T> void addAll(List<? super T> dest, List<? extends T> src) { for (T item : src) { dest.add(item); } }这个签名非常灵活:
List<Number> numbers = new ArrayList<>(); List<Integer> ints = Arrays.asList(1, 2, 3); addAll(numbers, ints); // 可以 List<Object> objects = new ArrayList<>(); addAll(objects, ints); // 也可以dest用super T,能接受T的任意父类型的列表;src用extends T,能接受T的任意子类型的列表。这就是PECS的完美应用。
4.2 比较器与排序
Collections.sort的签名是:
public static <T> void sort(List<T> list, Comparator<? super T> c)为什么Comparator用super T?因为比较器是消费者——它消费T类型的对象来产生比较结果。如果你有一个Comparator<Object>,它当然能比较Integer,因为Integer也是Object。所以用super T让比较器更通用。
反过来,如果写成Comparator<? extends T>,那你就只能传入Comparator<Integer>来比较Integer,Comparator<Number>反而不能用了,这显然不合理。
4.3 常见编译错误速查
| 错误信息 | 原因 | 解决方法 |
|---|---|---|
List<Integer> cannot be converted to List<Number> | 泛型不变性 | 用List<? extends Number> |
add(capture#1 of ? extends Number) cannot be applied | extends不能写 | 改用super,或去掉通配符 |
incompatible types: Object cannot be converted to Integer | super只能读成Object | 用extends,或显式强转 |
non-static type variable T cannot be referenced from a static context | 静态上下文引用类类型参数 | 把T声明在方法上 |
4.4 几个我踩过的坑
坑一:以为List<?>等于List<Object>。List<?>可以接受任何类型的List,但你只能读成Object,不能写。List<Object>只能接受List<Object>,但可以读写Object。两者用途完全不同。
坑二:在extends上强行add。我见过有人写List<? extends Number> list = ...; list.add(1);然后困惑为什么编译不过。记住,extends是只读的。
坑三:忘记通配符捕获。当你需要对List<?>做原地修改时,不要硬刚编译器,写个辅助泛型方法捕获一下就行。
坑四:过度使用通配符。不是所有地方都需要? extends或? super。如果一个方法内部既读又写,或者类型关系很简单,直接用具体的T更清晰。通配符是给API设计者用的,不是给所有代码用的。
5. 从字节码层面理解型变
这一节给想深入的同学。我们用javap看看通配符在字节码层面到底是什么样子。
5.1 extends的字节码表现
public static double sum(List<? extends Number> list) { double total = 0; for (Number n : list) { total += n.doubleValue(); } return total; }编译后,方法签名里的List<? extends Number>会被擦除成List,但会在Signature属性里保留泛型信息。list.get(0)调用的是List.get(int),返回Object,然后编译器自动插入checkcast Number指令。
这就是为什么说泛型是编译期的语法糖——运行时该强转还是得强转,只是编译器帮你做了。
5.2 super的字节码表现
public static void fill(List<? super Integer> list) { list.add(1); }list.add(1)调用的是List.add(Object),因为擦除后参数类型是Object。Integer自动装箱成Integer,然后向上转型为Object。这里没有任何类型检查的开销,因为写操作在编译期已经保证了安全。
5.3 为什么这些细节值得了解
了解字节码层面的实现,能帮你理解一些“奇怪”的现象。比如为什么泛型方法的重载会有问题:
public static void foo(List<String> list) {} public static void foo(List<Integer> list) {} // 编译错误:名称冲突因为擦除后两个方法的签名都是foo(List),不构成重载。这种错误在写工具类的时候偶尔会遇到,知道原因就能快速定位。
6. 设计API时的型变决策清单
最后给一个实用的决策流程,当你设计一个泛型API时,可以按这个清单走一遍。
6.1 五步决策法
- 这个方法需要从容器里读数据吗?需要 → 考虑
extends - 这个方法需要往容器里写数据吗?需要 → 考虑
super - 既读又写?→ 用具体的
T,别用通配符 - 只读?→
? extends T,读出来是T - 只写?→
? super T,能写T及其子类
6.2 一个完整的API设计示例
假设你要设计一个Stream风格的管道:
public class Pipeline<T> { // 生产者:从上游读取数据 public Pipeline<T> filter(Predicate<? super T> predicate) { ... } // 消费者:把数据写到下游 public <R> Pipeline<R> map(Function<? super T, ? extends R> mapper) { ... } // 终端操作:消费数据 public void forEach(Consumer<? super T> action) { ... } }注意map方法的签名:Function<? super T, ? extends R>。输入用super T(消费者),输出用extends R(生产者)。这是JDK里Stream.map的实际签名,也是PECS原则的教科书级应用。
6.3 什么时候不该用通配符
- 类的类型参数:
class Box<T>不要写成class Box<? extends T>,语法上就不允许 - 返回值类型:返回值用具体的
T,让调用方有完整的操作能力 - 简单场景:如果方法只在内部使用,不对外暴露,没必要为了“通用”而加通配符
- 性能敏感路径:通配符会带来额外的类型检查,虽然现代JVM优化得很好,但在极端场景下仍有影响
6.4 团队协作中的建议
在团队里推广PECS原则时,我建议从代码审查入手。看到List<Object>作为参数的地方,问一句“这里是不是可以用? super T”;看到List<? extends T>上调用add的地方,提醒一下“extends是只读的”。
另外,IDE的提示很有用。IntelliJ IDEA会提示你“Type parameter can be replaced with wildcard”,跟着提示走通常不会错。但也要理解背后的原因,不要盲目接受所有建议。
我个人在实际操作中的体会是,泛型型变这个东西,看再多教程不如自己写几个方法签名试一遍。编译器报错是最好的老师,每次报错都想想为什么,几次下来就形成肌肉记忆了。最开始可以刻意练习:拿到一个集合参数,先问自己是读还是写,然后决定用extends还是super。练上二三十个方法,基本就不会再搞混了。