尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java泛型深入:类型擦除、通配符与PECS的底层逻辑与实战指南

Java泛型深入:类型擦除、通配符与PECS的底层逻辑与实战指南 开头劝退一个错觉很多同学觉得看完了泛型那几篇博客、会写ListString就算“吃透 Java 泛型”了。结果去面试被一问ListString能不能传给ListObject或者问为什么不能new T()当场就卡壳。我早年在写集合复制工具时也干过这种事儿直接写ListObject dest new ArrayListString()编译器一脸冷漠地报错。老同事扫了一眼说“泛型是 invariant 的你要么用? extends要么就别这么传。”我当时满脑子问号String 明明是 Object 的子类凭什么不行后来把类型擦除、通配符、PECS 这些底层的机制捋了一遍才真正明白泛型这套语法背后的设计逻辑。这篇算是我的一个系统性整理。不是从头到尾铺开来讲泛型的每个语法点而是把“为什么不能这样写”“为什么必须那样设计”“实际项目里怎么避免踩坑”这几条主线讲透。内容覆盖类型擦除、泛型不变性、通配符边界、类型推断、泛型禁区以及一套真实可落地的 Repository 写法。适合准备 Java 面试的人也适合日常写 Java 但偶尔被泛型搞到头皮发麻的开发者。看完之后你不光能答上面试题写代码时也会下意识地避开那些常见的坑。1. 先从一条报错想起泛型的不变性是什么意思1.1 没有泛型的年代集合代码是怎么写的在 JDK 1.5 之前Java 的集合类统统是裸类型raw type。List里可以塞任意对象取出来的时候全靠调用方自己记得当初放进去的是什么类型List list new ArrayList(); list.add(hello); list.add(Integer.valueOf(42)); String first (String) list.get(0); // 这里还只是别扭 String second (String) list.get(1); // 运行期直接 ClassCastException这段代码对编译器来说完全合法因为它只知道get返回的是Object。类型对不对只有到了运行期JVM 做强制转换时才会发现。这种把类型错误拖到运行期的行为在过去造成了大量线上故障——代码可能在某个测试分支上跑了很久直到有人往里放了一个意料之外的类型程序才轰然倒地。Java 泛型出现以后最直观的变化就是把这些错误提前到编译期ListString list new ArrayList(); list.add(hello); list.add(Integer.valueOf(42)); // 编译期就报错根本走不到运行期 String first list.get(0); // 不需要强转编译器替你保证了类型这里面的价值怎么强调都不过分类型安全一旦交给了编译器IDE 能帮你检查代码审查能帮你检查甚至重构时都能靠类型系统提前发现错误。换句话说泛型让 Java 在“编译期”就把一批潜在的生产事故拦在了门外。1.2 所谓“不变性”到底哪里不变现在回到开头那条报错。ListString和ListObject之间直觉上很容易认为存在子类型关系——毕竟 String 是 Object 的子类。但 Java 的事实是对于泛型类型ListT来说不管 T 是什么只要 T 不同ListT之间就不存在父子关系。用术语讲泛型是“不变”invariant的。String 是 Object 的子类 ListString 不 是 ListObject 的子类这不是语法上随手定的规则而是经过权衡之后的选择。我举个破坏安全性的例子你就能明白为什么 Java 不敢允许这种赋值。如果下面这段代码能编译通过ListString strings new ArrayList(); ListObject objects strings; // 假设编译器允许 objects.add(Integer.valueOf(42)); // String 类型的 list 里混进了 Integer String value strings.get(0); // 运行期必然炸一旦ListString被当成ListObject使用编译器就没法阻止另一个持有ListObject引用的代码往里塞完全不相干的类型。等到原引用再取元素时get返回的实际对象不是 String类型系统就失效了。所以 Java 的泛型选择了一条更“保守”的路编译期就拒绝这种赋值宁可让你在写代码时多绕一下也不允许运行期崩。1.3 那该怎么表达“String 的列表”和“Object 的列表”之间的包容关系需求是客观存在的我确实想把一个ArrayListString遍历一遍然后统一按Object处理。能不能既保留泛型安全又允许某种程度的“子类型包容”Java 给出的答案是通配符wildcard? extends T和? super T。// 可以接收任意 Number 子类型的集合 void printNumbers(List? extends Number numbers) { ... } // 可以接收任意 Object 超类型的集合用于往里写 void addNumber(List? super Number numbers) { ... }但要真正理解这两个通配符为什么“放得宽”为什么一个只让读、另一个只让写就得先看清 Java 泛型的一个底层事实类型擦除。这是下一节的重点。2. 类型擦除Java 泛型背后的“唯一真相”2.1 编译前有类型参数编译后没有先看一个最常见的泛型类public class BoxT { private T value; public void set(T value) { this.value value; } public T get() { return value; } }这段源码里T 是一个类型参数。但 Java 编译器处理完它之后生成的字节码其实是下面这个样子的public class Box { private Object value; public void set(Object value) { this.value value; } public Object get() { return value; } }也就是说T在编译阶段会被替换成它的上界默认上界就是Object。这在 Java 里叫“类型擦除”type erasure。你不是在运行期拿到一个“真实的BoxString对象”它本质上还是Box只是编译器替你插入了各种类型转换代码。比如BoxString box new Box(); box.set(hello); String s box.get();编译后box.get()返回的是Object但编译器在调用处自动加上了(String)强转。所以“泛型安全”是编译器构建起来的一种幻觉——它负责检查写进去的类型也负责在你读出来时强转回正确的类型。这个设计在 JVM 层完全没有额外负担。运行期 JVM 看到的只有 raw type根本没有BoxString这种类。2.2 为什么 Java 当初非要选“擦除”这条路如果你对比过 C# 的泛型会发现 C# 的Listint和Liststring在运行期是真实的、不同的类型JIT 会为它们生成各自的代码。那 Java 为什么不这么做核心原因是历史包袱。JDK 1.5 之前Java 已经有大量代码基于裸类型List、Map等集合接口开发。如果泛型改成像 C# 那样在运行期保留完整类型信息那么所有旧类和旧方法签名都需要跟着变天二进制兼容性会碎一地。类型擦除给了一个漂亮的折中新代码可以享受编译期类型检查旧代码不需要为了适配新编译器而改动任何东西——因为擦除之后大家还是老样子。在语言设计上这算是一种“向后兼容优先”的务实选择。代价也明确泛型信息在运行期不可见导致我们后面要讲的一堆限制不能 new T()、不能 T.class、不能泛型数组全都由此而来。所以评价 Java 泛型时别只骂它把它放在特定的历史背景里看你会更清楚哪些地方是“做不到”哪些地方是“当初为了避免更大的破坏而主动放弃”。2.3 擦除带来的三个可见后果第一你拿不到ListString.class这样的类字面量。类字面量只能是List.class因为运行期就只存在一个List。第二两个泛型签名不同的方法无法构成重载public void process(ListString list) { } public void process(ListInteger list) { } // 编译报错擦除后都是 List擦除后两个方法的参数类型完全相同JVM 无法区分。这个坑在代码演进时很容易踩尤其当你先写了一个ListString的参数版本后来又想把ListInteger也加进来的时候编译器能做的只有拒绝。第三getClass()拿到的类型参数信息只是占位符BoxString box new Box(); System.out.println(box.getClass().getTypeParameters()[0].getName()); // 输出 T它告诉你“这个类的源码里有个叫 T 的参数”但并不会告诉你 T 被替换成什么具体类型。很多人在试图用反射获取泛型实际参数时都会撞上这堵墙原因就在这里。2.4 桥方法编译器偷偷生成的多态胶水擦除带来的另一个隐蔽后果是泛型在子类覆写父类方法时可能产生“签名不一致”的问题。标准教科书案例是Comparableclass Parent implements ComparableParent { Override public int compareTo(Parent o) { return 0; } } class Child extends Parent { Override public int compareTo(Child o) { // 这里还重载了 Parent 的 compareTo return 0; } }问题来了Child覆写的是哪个方法它新增了一个compareTo(Child)但因为泛型擦除Comparable的compareTo在字节码层面要求参数是Parent。编译器为了让Child仍然满足ComparableParent的约束会偷偷在Child里生成一个桥方法bridge methodpublic int compareTo(Parent o) { return compareTo((Child) o); }这看不见的“胶水”是泛型和多态兼容的必要保障。你平时不一定要关注它但如果用反射去列出子类的方法会看到一些奇怪的签名——那是桥方法不是 bug。这个知识点在面试里出现频率不低属于“懂得原理就能答得漂亮”的一类。3. 数组协变与泛型不变两个设计之间的安全角力3.1 数组在 Java 里是“协变”的Java 数组遵守一个非常古老的设计String[]是Object[]的子类型。因此下面这段代码能编译通过String[] strings new String[10]; Object[] objects strings; // 编译通过但数组的每个实例在运行期记住了自己的实际组件类型于是写错类型时会立刻暴露objects[0] 42; // 运行期抛出 ArrayStoreException为什么数组敢这样设计因为 Java 1.0 时没有泛型为了让Arrays.sort(Object[])这种通用方法能处理所有对象数组数组就必须跟协变结构性绑定。既然编译器在编译期做不到完全的类型安全数组就选择在运行期检查每个数组都携带自己的“组件类型”标签赋值时一旦类型不匹配立即报错。所以数组协变的“安全底线”其实是由 JVM 运行时在兜底——C 系语言的很多数组在运行期没有任何类型信息而 Java 数组居然实现了“带运行期检查的协变”。这个设计在当时是合理的但也为后来泛型的引入埋下了复杂的伏笔。3.2 泛型没有照搬数组的协变是因为那会击穿类型系统如果泛型也协变即ListString是ListObject的子类型上一节的推演已经展示了后果一个持有ListString引用的变量可能在毫不知情的情况下被塞入 Integer等到读取时ClassCastException才会炸。而且泛型没有运行期的组件类型检查——擦除之后泛型容器不记得自己当初装的是什么——因此 JVM 无法像数组那样把违规写入挡在门口。换句话说数组可以靠“运行期组件类型”付出代价换协变泛型在擦除后连这个代价都付不起所以只能放弃协变。Java 设计团队选择了“编译期安全优先”我宁可让ListString不能直接传给ListObject也不允许后来的人用脏手段破坏类型安全。3.3 需要“子类型关系”时用通配符补位你既想要泛型安全又想在两个容器之间表达某种兼容性该怎么办通配符就是为此设计的逃逸窗口。// 读方向这个 List 的泛型参数是某个 Number 子类 List? extends Number numbers new ArrayListInteger(); // 但不能往里写任何具体类型 // numbers.add(Integer.valueOf(1)); // 编译报错 // 写方向这个 List 的泛型参数是某个 Number 超类 List? super Number objects new ArrayListObject(); // 可以往里写 Number 及子类 objects.add(Integer.valueOf(1));这里就牵出了面试高频点为什么? extends Number不能写而? super Number能写下一节我会从“未知具体类型”的角度完整推导一遍顺带给出 PECS 这个治背不会的万能口诀。4. PECS今天把 extends 和 super 的用法彻底记住4.1 从“未知类型”出发推导你只能做什么先说List? extends Number。它表示“某个正好是 Number 子类的类型”编译器的视角里这个具体类型是未知的它可能是Integer也可能是Double。你从这个 list 里读元素元素一定可以安全地当成 Number 处理——因为不管实际是 Integer 还是 Double都继承了 Number。这就是“读”合法。但往里写呢如果你写numbers.add(Integer.valueOf(1))万一实际类型是ListDoubleInteger 放进去就破坏了类型不变量如果你写numbers.add(Double.valueOf(1.0))万一实际是ListInteger同样炸。编译器既不知道也不可能替你挑选一个“对所有子类型都安全”的元素——最安全的只有 null——于是干脆剥夺了写的能力。所以官方默认规则是? extends T只读。再说List? super Number。它表示“某个正好是 Number 超类的类型”可能是Object也可能是Number自己。不管实际类型是哪个它一定容得下 Number 以及 Number 的子类因为 super 方向保证了集合元素的“上界”至少比 Number 大。所以写objects.add(Integer.valueOf(1))是合法的。但读呢你从里面拿到的元素只知道是Object因为连编译器也不知道你的上界到底是 Object 还是 Number只能按最小公共类型 Object 返还。所以默认规则是? super T主要用于写读出来的东西只能当 Object 看。4.2 Producer ExtendsConsumer SuperPECS 是 Java 泛型界最出名的四条字母含义如下Producer Extends如果这个方法要“产出” T 对象给你用往外读声明参数时就用? extends TConsumer Super如果这个方法要“消费” T 对象往里塞声明参数时就用? super T看一个教科书级案例Collections.copy的签名public static T void copy(List? super T dest, List? extends T src)src是生产者只管往外面读所以是? extends Tdest是消费者只负责往里写所以是? super T。这个签名保证了把ListInteger复制到ListNumber是合法的反过来把ListNumber复制到ListInteger编译期就会被拦下。再拿Collections.sort来说public static T void sort(ListT list, Comparator? super T c)排序需要一个比较器“消费”元素来两两比较。Comparator? super T的意思是比较器可以比较 T 的父类型。比如一个ComparatorNumber也可以用来排序ListInteger因为 Integer 是 Number 的子类比较 Number 的逻辑天然适用于 Integer。这给方法带来了巨大的复用弹性。4.3 嵌套边界的读法T extends Comparable? super T写框架或读框架源码时经常会看到这种高阶写法public static T extends Comparable? super T T max(List? extends T list)拆开看T extends Comparable...T 必须实现某个ComparableComparable? super TT 实现的角色是“可与 T 的某个超类型比较”的 Comparable举例Parent实现了ComparableParent而Child extends Parent。此时Child extends Comparable? super Child是真的成立吗算一下? super Child的合法候选包括Parent、Object这类超类型而Parent implements ComparableParent正好匹配Comparable? super Child。所以一个没有自己实现 Comparable 的 Child 也能被这样的泛型方法接收。这种写法最大的价值是“放宽了泛型约束又不牺牲安全性”在处理继承体系中的比较操作时极其实用。4.4 实际开发里的经验判断我在代码评审里最常见的错误是有人为了“看起来严谨”把所有方法参数都写上? extends或? super结果把自己的代码搞得没人敢动。比如一个只在自己内部使用的方法传ListString就够了没必要写成List? extends String——因为这种泛型参数根本没带来任何额外价值反而让调用方被通配符签名吓到。真正该用通配符的场景是你在设计一个“服务于他人”的公共 API你需要让不同类型的容器都能进来。此时先问自己一个问题这个方法要从参数里读数据还是写数据读用 extends写用 super两个都要就分别定义。PECS 的真谛不是背口诀而是每次写签名前重新从“编译器看到的未知类型”角度想一遍——想通了你就再也不会用错方向。5. 泛型方法类型参数不只是类的特权5.1 泛型方法独立于类的小宇宙很多人以为泛型只能写在类名后面其实方法也能声明自己的类型参数。语法很简单在修饰符后面、返回值前面加上Tpublic static T T firstOrNull(ListT list) { if (list null || list.isEmpty()) { return null; } return list.get(0); }泛型方法最大的现实意义是静态方法无法使用类的类型参数但可以通过自身的泛型化实现同样的“类型通用”效果。换句话说静态工具方法要泛型化全得靠泛型方法。同时要注意一个泛型类里的非静态方法也可以有自己的独立泛型参数和类上的 T 并不冲突public class UtilsT { // 类级 T T value; // 方法级 E与类级 T 没有关系 public E E convert(E input) { return input; } }别把这两层作用域搞混。方法上的类型参数只属于这个方法的调用上下文类上的类型参数属于整个实例。5.2 类型推断编译器其实挺会猜的调用泛型方法最常见的问题是不写T时编译器怎么知道 T 是什么Java 的类型推断type inference会结合调用上下文来猜。比如public static T T identity(T value) { return value; } // 显式指定类型参数 String s1 Identity.Stringidentity(hello); // 依赖赋值目标的类型推断 String s2 identity(hello); // 编译器从参数就知道 T 是 String // 依赖返回值目标类型的推断 ListString empty Collections.emptyList(); // Java 8 之后可行在 Java 8 之前Collections.emptyList()这种方法的返回值推断还不够智能经常需要手动写Collections.StringemptyList()。Java 8 强化了目标类型推断之后很多场景不需要显式类型参数也能工作。但好推断归好推断遇到复杂嵌套的泛型类型比如一堆MapString, ListFunction? extends T, ?时编译器也会犯迷糊这时候显式的.String反而能救你一命。5.3 泛型方法在工厂与函数式编程里的典型应用写框架时一个极其常见的模式是“泛型工厂方法”public class Pair { public static K, V PairK, V of(K key, V value) { return new Pair(key, value); } }静态泛型工厂的好处是调用方几乎不需要写类型参数编译器从传参就能把 K 和 V 都推出来。这种写法在众多开源项目的 builder 和 DTO 构造场景里到处可见。另一类典型是自定义函数式接口FunctionalInterface public interface ConverterF, T { T convert(F from); } ConverterString, Integer converter Integer::parseInt; Integer result converter.convert(123);泛型在这里承担了两个职责编译期保证输入输出类型一一对应同时让 Lambda 表达式能够自动适配接口签名。你要是写过自己的回调接口八成也踩过“为了兼容多种类型干脆把参数写成 Object回调里面再强转”的土办法——现在可以换成泛型方案代码会干净一个量级。6. 泛型的六个“禁区”为什么 new T()、T.class、instanceof T 全都不能写6.1 三个最经典的编译错误源头只有一个先看一组“想当然”的代码public class ImpossibleT { private T newInstance() { return new T(); // 错误Type parameter T cannot be instantiated } private ClassT getType() { return T.class; // 错误cannot select from a type variable } private boolean isType(Object obj) { return obj instanceof T; // 错误illegal generic type for instanceof } }这三个操作有一个共同的根源运行期根本不存在 T 这个类。类型参数在编译后被擦除JVM 不知道 T 具体是什么也就没法构造 T 的实例、没法拿到 T 的类对象、没法检查instanceof T。这看起来像“功能缺失”但其实是类型擦除的必然代价理解了擦除这几个错误就一点都不神秘了。6.2 绕路的正确姿势把 Class 传进来既然运行期没有 T那就让调用方把类型信息传给你。最常见的方案是在构造器里接收一个ClassT参数public class JsonParserT { private final ClassT type; public JsonParser(ClassT type) { this.type type; } public T parse(String json) { // 通过 type 来反射创建对象或者用它驱动反序列化框架 ... } } // 使用 JsonParserUser parser new JsonParser(User.class);你会发现几乎所有反序列化框架的底层 API 都长这样Gson 的fromJson(String json, ClassT classOfT)、Jackson 的readValue(String content, ClassT valueType)。它们为什么非得要一个 Class 参数正是因为框架要在运行期创建目标对象而 T 这个泛型参数在运行期是空气。6.3 泛型数组和静态成员更难搞的两个禁区创建泛型数组也是被禁止的public class ArrayProblemT { private T[] array new T[10]; // 错误generic array creation }原因要落到 JVM 对数组的运行时检查上。数组在创建时就必须知道自己的具体组件类型以便将来每次赋值时执行ArrayStoreException检查。而 T 在运行期不存在JVM 不可能为一个“位置待定”的类型分配数组。常见的绕法是创建Object[]然后强转SuppressWarnings(unchecked) private T[] array (T[]) new Object[10];这种写法在 ArrayList 源码里很经典但它本质上是用 unchecked 警告换来的“信任”只能你自己保证不往里面写错误类型。能用安全容器比如ListT就别碰这种强转这是原则。静态成员不能用类型参数也很容易理解public class StaticProblemT { private static T instance; // 错误non-static type variable T cannot be referenced from a static context }因为T依赖于类型的实例化过程而静态字段在类加载时就要分配内存。一个被所有实例共享的静态字段没法凭空确定自己该存哪种子类型。换个思路看如果你真的需要“全局单例的类型参数”通常意味着设计需要调整把逻辑挪到实例方法里即可。6.4 高级但实用的路子TypeToken 或 getGenericSuperclass如果真的要在运行期拿到“泛型参数的具体类型”可以通过“捕获泛型父类信息”的反射技巧。典型实现是 Gson 的TypeTokenType type new TypeTokenListString() {}.getType();关键点在花括号——它创建了一个匿名子类。由于匿名子类在字节码中会保留对父类泛型签名的最新信息反射可以通过getGenericSuperclass()读取到ListString中的 String而不是擦除后的 Object。不过我要提醒一句这个技巧牵涉对 JVM 反射机制的依赖不到必要时刻别用。正常业务代码里优先用ClassT传参只有在极少数场景比如你要写通用 JSON 工具、ORM 转换器时才值得动用ParameterizedType这一套。面试中若被问到能说出原理和适用场景即可。7. 实战复盘一个泛型 Repository 是怎么从“能用”改到“靠谱”的7.1 设计骨架T 和 ID 一起上假设我要写一个通用的仓储层避免每个实体都重复一套 CRUD。最直觉的设计是定义一个BaseRepositoryT, IDpublic abstract class BaseRepositoryT, ID { private final ClassT entityClass; protected BaseRepository(ClassT entityClass) { this.entityClass entityClass; } public T findById(ID id) { return queryOne(SELECT * FROM getTableName() WHERE id ?, id); } protected abstract String getTableName(); SuppressWarnings(unchecked) protected T queryOne(String sql, Object... params) { // 通过 entityClass 做反射实例化再填充字段 ... } }子类只要确认了具体实体类型代码量立刻降维public class UserRepository extends BaseRepositoryUser, Long { public UserRepository() { super(User.class); } Override protected String getTableName() { return user; } }这套设计的核心思想是泛型让父类代码可以复用“任意实体”ClassT在运行期为反射、反序列化提供必要信息。两者缺一不可——只靠泛型没有运行期类型只传 Class 又损失了编译期类型检查。7.2 写这套代码时最容易忽略的三个点第一如果父类断言需要通过反射实例化 T那就必须在构造器里强制子类传入ClassT否则你只能沿用(T) obj的强转思路谁用谁知道多难受。第二方法参数里如果出现ListT在实现里转换时建议用ListT去接而不是裸的List否则会触发 unchecked 警告后续维护的人根本分不清这警告是你故意的还是忘了处理。第三泛型方法上的T与类上的T千万别混——比如在 BaseRepository 里写一个静态泛型工具方法如果你用T当它的类型参数名很容易让阅读代码的人产生误解。我还踩过一个记忆犹新的坑早期版本里findById直接调用了反射框架把查询结果塞进实体对象但忘了考虑继承字段。后来在实体类加了一层父类反序列化结果里父类属性全为空。原因就是框架依据运行时 Class 找出字段时只遍历了当前类的声明字段没走上父类。这不是泛型本身的锅但属于“在泛型框架里做反射”时的典型并发症。检查清单里写着一条反射读取字段时要递归遍历父类。7.3 千万别为了泛型而泛型泛型是个好工具但用错地方会反噬代码清晰度。我见过最夸张的一个类全身上下七八个类型参数类名还没读通泛型声明先吓退两个人。一个简单的判断方法如果一个泛型参数从头到尾只用了一次或者它的存在没有带来“编译期约束”那么它多半是多余的。泛型的价值体现在“多个位置通过同一个类型参数互相约束”的时候。比如ConverterF, T里的 F 和 T 分别出现在方法的入参和返回类型上编译器能强制要求输入输出类型配对再比如MapK, VK 和 V 在 put/get 之间形成约束。真正克制的泛型设计是让每个类型参数都有明确的“契约”而不是把类写成一个类型混沌池。8. 面试高频问答与日常避坑速查8.1 结论型题目背下来别含糊整理了我在面试中被问到最多的几个泛型问题供快速自查问题核心结论什么是类型擦除泛型类型参数在编译后被替换为边界或 Object运行期不存在泛型类为什么选择类型擦除兼容 JDK 1.5 之前的旧代码保持二进制兼容性泛型在运行期真的存在吗绝大多数情况不存在instanceof、T.class 都拿不到只能借助 ClassT 或 TypeToken 间接保留数组协变与泛型不变的区别数组协变但有运行期检查泛型不变且无运行期检查二者安全策略不同什么是 PECS生产者用 extends消费者用 super如何获取泛型参数的实际类型通过传入 ClassT或子类继承时保留 ParameterizedType 的反射技巧这些问题在“java面试题”和“java八股文”的搜索里常年占据版面其实背后都是类型擦除的延伸。背结论容易我更建议把第 2、3、4 节的推导过程理解一遍这样就算面试官换个角度问你也能现场推出来。8.2 场景题看看这段代码为什么炸有几个高频的“找出问题”场景平时写代码时其实就潜伏在身边。场景一ListString list new ArrayList(); list.add(ok); Integer x (Integer) list.get(0); // 强转炸没加泛型的new ArrayList()是 raw type编译器不会拦你运行期必然ClassCastException。正确写法是new ArrayListString()或菱形运算符new ArrayList()。场景二public void add(List? extends Number nums) { nums.add(1); // 编译报错 }原因就是第 4 节说的? extends Number代表未知子类型往里面写任何具体类型都是不安全的。如果你真的需要能写改成List? super Number。场景三public T T getValue() { return T.valueOf(x); // 编译报错 }T.valueOf不存在因为 T 在编译期是类型变量运行期被擦除不能调用任何具体的静态方法。如果一定要实现请传入ClassT再用反射调用Class.forName。8.3 日常代码评审时我会盯的几个泛型坏味道第一满屏 unchecked 警告却无动于衷。每个 unchecked 警告背后都隐藏着一个“清除类型擦除检查”的角落即便你现在能保证安全三个月后换个人维护就未必了。能加SuppressWarnings(unchecked)的地方旁边必须写上注释说明为什么安全。第二签名里无意义的通配符。比如List? extends StringString 是 final不存在任何子类这个通配符等于脱裤子放屁纯粹制造阅读噪音。更常见的是把List?挂在参数上却只在方法里遍历一次就完事——其实裸类型List会更简单但如果你在意安全直接ListT或具体类型就好。第三泛型与重载的暧昧组合。我已经不止一次在维护旧代码时看到这种操作原来有个方法接受ListString后来需求变了要支持ListInteger于是人很自然地复制粘贴了一个参数为ListInteger的重载——编译没过才发现是类型擦除的锅。遇到这种需求一开始就用通配符或者重新设计方法签名远比事后补救轻松。我的个人习惯是在写公共工具类时多花三分钟想清楚每个泛型参数到底在约束谁在写业务代码时尽量让泛型“隐形”——调用方不需要写一堆类型参数编译器自己推断就够了。吃透 Java 泛型其实不是为了在代码里炫技而是为了任何一次类型不匹配发生时你能迅速判断出它是该在编译期被拦截还是应该在设计阶段被避免。把底层逻辑想明白了下次编译器再给你红色报错时你就不会一脸困惑而是会心一笑哦这是擦除在提醒我该换一种更尊重类型系统的方式了。
返回列表