1. 为什么泛型值得单独开一篇笔记
刚学Java那会儿,我对泛型的理解长期停留在“尖括号里填个类型”这个层面。写List<String>和写List好像都能跑,编译器偶尔报个unchecked警告,加个@SuppressWarnings就糊弄过去了。直到有一次在项目里做数据聚合,一个List没加泛型,运行时从里面取出来的对象强转抛了ClassCastException,排查了半天才发现是上游塞了个不同类型的对象进去。那次之后我才认真把泛型、通配符、可变参数这一套东西从头捋了一遍。
这篇笔记就是那次系统梳理的产物。它适合已经会写Java基础代码、但对泛型还停留在“会用但说不清”阶段的同学,也适合想重新夯实集合与API设计基础的开发者。核心要解决三个问题:泛型到底在编译期帮你挡掉了什么、通配符? extends和? super什么时候用哪个、可变参数背后藏着哪些容易踩的坑。这三块内容在实际写工具类、封装通用方法、设计API时出现频率极高,绕不过去。
我尽量不写成教科书,而是按“我当时是怎么想通的”这个顺序来组织,把每个设计决策背后的理由讲清楚。你看完应该能直接在自己的代码里用起来,而不是只记住几个结论。
2. 泛型:把类型检查从运行时提前到编译期
2.1 泛型要解决的核心痛点
在没有泛型的年代,集合里存的都是Object。你往List里放一个字符串,再放一个整数,编译器完全不拦着。取出来的时候你得自己记得每个位置是什么类型,然后手动强转。问题在于,这个“记得”是靠不住的——代码一多、人一换,强转就出错,而且错误发生在运行时,测试没覆盖到就上线了。
泛型的本质是把类型当作参数传给类、接口或方法。List<String>的意思是“这个列表只装String”,编译器在编译阶段就帮你检查所有add操作,一旦类型不符直接编译失败。这就把原本运行时才暴露的ClassCastException提前到了编译期,而编译期发现问题的成本远低于线上崩溃。
我常跟人打这个比方:泛型就像快递柜的格子。你声明这个格子只放“文件类”包裹,那么任何非文件类的东西塞进来时,系统当场就拒收,而不是等你取件时才发现拿错了。这个“当场拒收”就是编译期检查的价值。
2.2 泛型类、泛型方法、泛型接口的写法差异
泛型类最常见的就是各种容器。声明方式是在类名后面加类型参数:
public class Box<T> { private T content; public void set(T content) { this.content = content; } public T get() { return content; } }这里的T是类型参数,实例化时确定:Box<String> box = new Box<>();。注意后面的<>叫钻石运算符,Java 7之后可以省略具体类型,编译器会自动推断。
泛型方法则是在方法返回值前声明类型参数,它和泛型类没有必然关系:
public static <E> List<E> arrayToList(E[] arr) { List<E> list = new ArrayList<>(); for (E e : arr) list.add(e); return list; }这里<E>必须写在返回值List<E>之前,否则编译器不认识E。泛型方法的好处是类型参数的作用域仅限于这个方法,不会污染整个类。
泛型接口的典型代表是Comparable<T>和Comparator<T>。实现时可以选择指定具体类型,也可以继续保留泛型:
public class Person implements Comparable<Person> { private int age; @Override public int compareTo(Person o) { return this.age - o.age; } }指定Comparable<Person>之后,compareTo的参数就是Person而不是Object,省去了强转,也更安全。
2.3 类型擦除:泛型在运行时到底变成了什么
这是理解泛型很多“奇怪行为”的钥匙。Java的泛型是编译期概念,编译完成后类型参数会被擦除。List<String>和List<Integer>在运行时都是List,它们共享同一个Class对象。
擦除规则大致是:无界的类型参数擦成Object,有界的擦成它的上界。比如<T extends Number>擦除后T就变成Number。这解释了几个现象:
- 你不能写
new T(),因为运行时不知道T是什么。 - 你不能写
new T[],同样因为运行时类型信息丢失。 - 静态方法里不能用类的类型参数,因为静态上下文不依赖实例,而类型参数是跟实例绑定的。
instanceof后面不能跟带参数的泛型,只能写instanceof List,不能写instanceof List<String>。
注意:类型擦除是Java为了兼容老版本代码做的妥协。理解它之后,很多编译报错你就能自己解释原因,而不是死记“这里不能这么写”。
2.4 泛型带来的一个实际收益:消除强转
我拿一个真实场景对比。假设要做一个缓存,存各种类型的值。不用泛型:
Map cache = new HashMap(); cache.put("user", userObj); User u = (User) cache.get("user"); // 强转,可能出错用泛型封装一层:
public class TypedCache<K, V> { private Map<K, V> map = new HashMap<>(); public void put(K key, V value) { map.put(key, value); } public V get(K key) { return map.get(key); } }调用方TypedCache<String, User> cache = new TypedCache<>();之后,cache.get("user")直接返回User,不需要强转,也不可能转错。这就是泛型在API设计上的直接价值——把类型约束写进签名,让调用方少犯错。
3. 泛型通配符:? extends 和 ? super 到底怎么选
3.1 为什么需要通配符
先看一个会让人困惑的现象。List<Integer>是List<Number>的子类型吗?直觉上Integer是Number的子类,那List 应该也是List 的子类吧?但Java里不是。List<Integer>和List<Number>之间没有继承关系,它们是两个独立的类型。
这个设计是为了类型安全。如果List<Integer>能赋给List<Number>,那你就可以往里面add一个Double,而实际这个列表只该装Integer,类型安全就崩了。所以Java用通配符来解决“我想接收一个元素类型不确定的列表”这种需求。
3.2 ? extends:上界通配符,适合“只读”
List<? extends Number>表示“元素类型是Number或其子类的某个列表”。你可以从里面读元素,读出来的东西一定能赋给Number:
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>。但你不能往里面add任何东西(除了null),因为编译器不知道这个列表实际是Integer还是Double,加任何具体类型都可能不安全。
所以记住一句话:? extends适合生产者场景,你只从里面取数据,不往里写。业内常说的PECS原则里,它就是Producer Extends。
3.3 ? super:下界通配符,适合“只写”
List<? super Integer>表示“元素类型是Integer或其父类的某个列表”。你可以往里面addInteger及其子类:
public static void addNumbers(List<? super Integer> list) { list.add(1); list.add(2); }这个方法可以接收List<Integer>、List<Number>、List<Object>。但读出来的元素只能当作Object,因为编译器不知道实际类型是Number还是Object。
这就是Consumer Super:你主要往里写数据,读的时候拿不到精确类型。
3.4 PECS原则的实战判断法
PECS是Producer Extends, Consumer Super的缩写。判断方法很简单,问自己一个问题:这个集合参数,我是主要从它里面读,还是主要往它里面写?
- 主要读:用
? extends T - 主要写:用
? super T - 既读又写:那就不该用通配符,直接用确定的类型参数
<T>
我举个JDK里的真实例子。Collections.copy的签名是:
public static <T> void copy(List<? super T> dest, List<? extends T> src)源列表是生产者,用extends;目标列表是消费者,用super。这个签名设计得非常精准,你照着这个思路去设计自己的API就不会错。
3.5 无界通配符 ? 的使用场景
List<?>表示“元素类型未知的列表”。它和List<Object>不一样:List<Object>只能接收元素为Object的列表,而List<?>可以接收任何类型的列表。
什么时候用List<?>?当你只关心列表本身的操作,不关心元素类型时。比如打印列表长度、清空列表:
public static void printSize(List<?> list) { System.out.println(list.size()); }但注意,List<?>同样不能add(除null),因为类型未知。它本质上和? extends Object是一回事。
实操心得:我见过不少人图省事,方法参数直接写
List(裸类型),觉得反正都能传。裸类型会丢失所有泛型检查,编译器只给警告不报错,运行时该炸还是炸。宁可写List<?>,也别写裸List。
4. 可变参数:语法糖背后的数组真相
4.1 可变参数的基本写法与调用
可变参数的语法是类型... 参数名,它让方法可以接收任意数量的该类型参数:
public static int sum(int... nums) { int total = 0; for (int n : nums) total += n; return total; }调用时可以传任意个:sum()、sum(1)、sum(1, 2, 3),甚至直接传数组sum(new int[]{1,2,3})。
编译器会把可变参数自动包装成数组。也就是说,sum(1, 2, 3)在编译后等价于sum(new int[]{1, 2, 3})。方法内部,nums就是一个普通的数组,你可以用数组的一切操作。
4.2 可变参数的三个硬性规则
第一,可变参数必须是参数列表的最后一个。因为如果它后面还有参数,编译器无法判断从哪里开始算可变部分。
第二,一个方法只能有一个可变参数。原因同上,两个可变参数无法划分边界。
第三,可变参数可以和普通参数共存,但普通参数在前:
public static void log(String level, String... messages) { ... }调用log("INFO", "a", "b")时,level拿到"INFO",messages拿到["a", "b"]。
4.3 重载时可变参数的优先级陷阱
这是很容易踩的坑。看这组重载:
public static void method(int... nums) { System.out.println("varargs"); } public static void method(int a, int b) { System.out.println("two args"); }调用method(1, 2)时,输出的是"two args"。因为编译器优先匹配固定参数的方法,可变参数是最后的选择。这个规则叫“固定参数优先于可变参数”。
但如果你调用method(1),只有一个固定参数的方法不存在,就会走可变参数版本。理解这个优先级,能帮你避免重载歧义。
4.4 可变参数与泛型的组合:@SafeVarargs
当可变参数的类型是泛型时,会有一个堆污染警告:
@SafeVarargs public static <T> List<T> listOf(T... elements) { return new ArrayList<>(Arrays.asList(elements)); }因为泛型数组在运行时类型信息不完整,编译器会警告“possible heap pollution”。@SafeVarargs是告诉编译器“我确认这个方法不会往数组里塞错误类型的东西”。它只能用在static、final或private方法上,因为这些方法不能被重写,从而保证安全承诺不会被破坏。
注意:
@SafeVarargs不是随便加的。如果你的方法内部真的把不兼容的类型塞进了这个泛型数组,加了注解也挡不住运行时的问题。加之前先确认方法体确实安全。
4.5 可变参数的性能与可读性权衡
可变参数每次调用都会创建一个数组。在极端高频的调用路径上,这个数组分配是有成本的。JDK里有些方法为了性能会提供固定参数的重载,比如String.format就有多个版本。
但在绝大多数业务代码里,这点开销可以忽略。可读性收益远大于性能损失。我的建议是:工具类、日志、构建器这类场景放心用可变参数;如果是每秒调用百万次的核心循环,再考虑用固定参数或直接传数组。
5. 三者结合:写出通用又安全的工具方法
5.1 一个综合案例:通用集合合并方法
把泛型、通配符、可变参数放一起,写一个合并多个集合的方法:
@SafeVarargs public static <T> List<T> merge(List<? extends T>... lists) { List<T> result = new ArrayList<>(); for (List<? extends T> list : lists) { result.addAll(list); } return result; }这里每个部分都有理由:<T>声明类型参数,让返回值和输入关联;List<? extends T>...用上界通配符,使得List<Integer>和List<Double>都能合并到一个List<Number>里;@SafeVarargs消除泛型可变参数的警告。
调用示例:
List<Integer> a = Arrays.asList(1, 2); List<Integer> b = Arrays.asList(3, 4); List<Number> merged = merge(a, b);5.2 参数选择决策表
我把常见场景和推荐写法整理成表,方便对照:
| 场景 | 推荐写法 | 理由 |
|---|---|---|
| 只读集合参数 | List<? extends T> | 生产者,支持协变 |
| 只写集合参数 | List<? super T> | 消费者,支持逆变 |
| 读写都要 | List<T> | 类型必须确定 |
| 不关心元素类型 | List<?> | 避免裸类型 |
| 任意数量同类型参数 | T... | 可变参数 |
| 泛型可变参数 | @SafeVarargs+T... | 消除堆污染警告 |
5.3 常见编译错误与对应原因
学泛型时遇到的报错,基本都能用类型擦除和通配符规则解释。我列几个高频的:
cannot be applied to given types:多半是通配符方向用反了,比如往? extends里add。unchecked call:用了裸类型或未经检查的强转。non-static type variable T cannot be referenced from a static context:静态方法里用了类的类型参数。generic array creation:试图new T[]或new List<T>[]。
看到这些报错,先回想擦除规则和PECS,基本能定位。
6. 踩坑记录与排查清单
6.1 我实际踩过的三个坑
第一个坑是拿List<? extends Number>当参数,方法里想add一个Integer,编译不过。当时不理解,后来才想通:这个列表可能是List<Double>,加Integer就破坏了类型安全。解决办法是把参数改成List<Number>或List<? super Integer>。
第二个坑是可变参数传null。method(null)在参数是String...时会编译警告,因为null既可能是“一个null元素”,也可能是“null数组”。实际运行时如果按数组处理会抛NPE。正确做法是显式传(String[]) null或new String[]{null},把意图写清楚。
第三个坑是重载可变参数方法导致的歧义。两个方法一个收int...一个收Integer...,调用时编译器无法决定走哪个,直接报ambiguous。解决办法是避免这种重载组合,或者调用时显式指定类型。
6.2 排查速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 运行时ClassCastException | 用了裸类型或强转 | 检查集合声明是否带泛型 |
| 编译警告unchecked | 裸类型、泛型数组 | 补全类型参数或用@SafeVarargs |
| 无法add到通配符集合 | 用了? extends | 改用? super或确定类型 |
| 静态方法报类型参数错误 | 引用了类的T | 把T声明到方法上 |
| 可变参数传null异常 | null被当作数组 | 显式传数组或单个null元素 |
6.3 几条实操建议
写工具方法时,参数能用通配符就用通配符,这样方法适用范围更广。返回类型不要用通配符,因为调用方需要确定的类型来做后续操作。泛型可变参数尽量加@SafeVarargs,但加之前确认方法体安全。遇到泛型相关的编译错误,先别急着加@SuppressWarnings压掉,搞清楚原因往往能发现真正的设计问题。
最后分享一个我自己的习惯:每次写带泛型的公共方法,我都会问自己两个问题——这个类型参数能不能用通配符替代让签名更灵活?这个可变参数会不会在重载时产生歧义?这两个问题问下来,大部分设计缺陷在写的时候就暴露了,比等到code review被指出来要省事得多。