一个接口返回List,调用方把其中元素强转为Order;直到线上流量遇到一个String,才抛出ClassCastException。泛型的主要价值是让这种不匹配尽量在编译期暴露,并让 API 清楚表达“容器里应放什么”。不过泛型不是运行时自动校验器,理解类型擦除与通配符边界,才能写出可靠的集合和库接口。
一、从参数化类型开始
recordOrder(Stringid){}List<Order>orders=newArrayList<>();orders.add(newOrder("O-1"));Orderfirst=orders.get(0);编译器会阻止向List<Order>加入字符串,取出元素时也不需要业务代码显式强转。菱形语法<>让构造侧从左边推断类型。相比之下,原始类型List raw = orders会绕过许多检查;混用原始类型时可能在后续读取位置才出现运行时异常。老接口不得不使用原始类型时,应把不安全转换集中在边界,并验证元素类型,而不是把警告传遍整个项目。
注意List<String>不是List<Object>的子类型,即使 String 是 Object 的子类型。如果允许把字符串列表当成对象列表,就可以写入整数,破坏原列表的承诺。这叫泛型不变性。数组有协变行为,Object[] a = new String[1]合法,但写入 Integer 会在运行时报错;泛型更倾向于在编译时阻止这种危险。
二、extends 用于读取,super 用于写入
需要汇总数字列表时,可以接收多种 Number 子类型:
staticdoublesum(List<?extendsNumber>values){doubleresult=0;for(Numbervalue:values){result+=value.doubleValue();}returnresult;}List<Integer>、List<Double>都能传进来;但方法内部不能安全地往其中添加一个非 null 的 Number,因为实际列表可能是List<Integer>。? extends Number保证读出来至少是 Number,却不承诺能放入哪一种具体子类。
反过来,向目标列表写入订单,可以用下界通配符:
staticvoidappendSample(List<?superOrder>target){target.add(newOrder("O-2"));}它既接受List<Order>,也接受List<Object>;但从 target 读取时只能保证得到 Object。常见记忆方式是 PECS:Producer Extends,Consumer Super。要注意“生产者”与“消费者”是从方法的角度看参数如何使用,而不是对象名字。例如一个既读又写同一具体类型的列表,通常直接使用List<T>比通配符更清楚。
三、方法级类型参数表达关联
如果两个参数必须具有同一种类型,可以用方法类型参数,而不是两个互不相干的问号:
static<T>voidcopyAll(List<?extendsT>source,List<?superT>target){for(Tvalue:source){target.add(value);}}这里 T 把源和目标联系起来。List<Integer>可以复制到List<Number>,无需手写强制转换。库 API 设计中要避免用无意义的类型参数制造复杂度:若方法只是打印任何对象,使用 Object 即可;若方法返回值、输入和回调类型之间存在约束,类型参数才真正传达契约。
带约束的类型参数也能约束操作能力。例如static <T extends Comparable<? super T>> T max(List<T> values)表明元素能与同类或其父类型比较。边界中的Comparable<? super T>看似复杂,是为了支持父类定义比较规则而子类沿用的情形。设计公共 API 时可从最简单可用的签名开始,确认真实调用场景后再放宽。
四、类型擦除到底擦掉了什么
Java 泛型主要通过编译器实现:编译后类型变量会按上界替换,必要时加入类型转换,某些继承场景还会生成桥接方法以保持多态。因此在运行时不能简单问一个对象是否为List<String>:
List<String>names=newArrayList<>();System.out.println(namesinstanceofList<?>);// true// names instanceof List<String> // 不能这样检查List<String>与List<Integer>通常共享同一个原始 Class 对象。你可以检查它是不是 List,却无法仅凭这个对象的运行时 Class 断言其中每个元素都是 String。反射能读取某些声明位置上的泛型签名,但这并不等于集合实例会在运行时强制检查元素类型。
因此缓存或消息框架若只拿到Class<List>,并不知道元素应是什么。反序列化复杂嵌套类型需要额外的类型描述,具体形式由所用库决定。不能因为方法签名写了List<Order>,就直接相信来自 JSON、数据库或插件的所有元素都符合预期;数据边界仍需验证。
五、常见限制与堆污染
不能写new T(),因为擦除后没有足够的信息确定要构造哪个类。可显式传入构造函数:
static<T>Tbuild(java.util.function.Supplier<T>factory){returnfactory.get();}Orderorder=build(()->newOrder("O-3"));不能直接创建new List<String>[10],因为数组在运行时会检查组件类型,而参数化类型的元素参数已经擦除。通常改用List<List<String>>。基本类型也不能直接作为类型实参,使用List<Integer>而不是List<int>;拆装箱有成本,数值密集场景还要考虑专用数组。
原始类型、未经检查的强转以及泛型可变参数都有机会造成堆污染。例如:
List<String>names=newArrayList<>();Listraw=names;raw.add(42);// 编译警告;读取 String 时可能失败不要把@SuppressWarnings("unchecked")放到整个类上掩盖问题。若确实无法避免转换,应把抑制缩小到一处,说明其安全前提,并为来自外部的数据做元素检查。@SafeVarargs只可用于满足语言限制的方法或构造器,且声明者必须保证没有不安全地操作可变参数数组;注解不是替代证明的开关。
六、设计一个更稳的转换边界
面对来自旧系统的List<?>,可以逐项检查并返回新的有类型列表:
static<T>List<T>checkedCopy(List<?>source,Class<T>type){List<T>result=newArrayList<>(source.size());for(Objectvalue:source){result.add(type.cast(value));}returnList.copyOf(result);}Class.cast在出错的元素处立即失败,错误位置比“经过十层调用后再取出元素”更接近输入边界。List.copyOf会拒绝 null;如果输入允许 null,就需要自行定义复制和校验策略。这个方法处理的是运行时可表示的元素类;对于List<List<Order>>,单个Class<T>不能完整描述嵌套结构。
对外 API 最好不要返回裸Map<String, Object>再要求调用者猜字段类型。稳定业务结构可使用明确的 record 或普通类;只有字段集合确实开放时才用动态结构,并附带校验规则。泛型能表达容器和方法之间的类型关系,但不能代替业务对象的语义。
七、测试与实践建议
为公共泛型工具写两类测试:编译层面的正向调用样例,确保预期类型可传入;运行时边界测试,确保原始类型或不可信输入能在靠近入口的位置报错。若库面向外部使用者,还可保留明确的示例代码,防止看似等价的签名调整让旧调用点无法编译。
八、泛型与继承中的桥接方法
假设父类声明T get(),子类把 T 固定为 String 并覆写String get()。擦除后父类的方法签名可能以 Object 形式存在,编译器需要生成桥接方法,保证通过父类引用调用时依然体现正确的动态分派。这是语言兼容机制,平时不必手写,但在反射扫描方法时可能看到isBridge()为 true 的方法。框架如果把所有反射方法都当成独立业务方法,可能重复注册同一个处理器。
不能仅凭getClass()恢复某个实例完整的泛型实参。虽然某个具名子类的父类型声明可能保留参数化类型元数据,直接创建的new ArrayList<String>()实例依然不会记录“每个元素必须是 String”。反射看到的声明信息、容器实际持有的元素、业务希望的类型是三个不同层次;库开发者要把这些层次区分开。
九、通配符捕获与 API 易用性
List<?>表示“元素类型未知,但对同一个列表是某一种确定类型”,不等价于可随便装任意值的List<Object>。有时一个接受List<?>的方法想交换两个位置的元素,会因为编译器无法证明传入元素类型一致而报错。可以使用私有的泛型辅助方法捕获未知类型:
staticvoidswap(List<?>list,inti,intj){swapCaptured(list,i,j);}privatestatic<T>voidswapCaptured(List<T>list,inti,intj){Told=list.get(i);list.set(i,list.get(j));list.set(j,old);}对调用者保留简洁的List<?>签名,方法内部通过同一个 T 表示“两个位置来自同一元素类型”。这种技巧应该服务于明确操作,不要把通配符捕获当成炫技。若方法既有多个互相独立的列表,又需要元素类型匹配,就要用方法类型参数显式连接它们,而非假设多个问号天然相同。
泛型限制也提醒我们:API 提供的编译期承诺与运行时信任边界不同。公开库应保留类型约束,外部输入应做检查;两者配合才有可靠性。不要因为类型擦除就放弃泛型,也不要因为编译成功就跳过输入验证。
十、重载与返回类型的边界
类型擦除也会影响方法重载:不能在同一个类里同时声明只在泛型实参上不同的handle(List<String>)和handle(List<Integer>)。擦除后它们的参数签名冲突。可改用不同的方法名、包装类型,或一个带显式策略的泛型方法。仅靠返回类型不同也不足以构成 Java 方法重载;API 名称要表达业务差异,而不是试图让编译器从上下文猜意图。
泛型推断常能省去类型实参,但复杂链式调用若让编译错误难懂,不妨拆出局部变量并注明类型。可读的中间变量也让审查者看到实际的类型约束。公共 API 的签名尤其要稳定:把List<Order>改成List<? extends Order>可能改善读取灵活性,却也可能改变调用方可用的写入操作;调整前应运行调用端的编译测试。
写泛型签名时先描述数据流:这个参数只读、只写,还是既读又写?两个参数是否共享一个类型?运行时是否真的拥有类型信息?回答这些问题后再选择 T、? extends T、? super T或具体类。类型越精确,编译器能帮忙的地方越多;复杂度则应与真实约束相匹配。
参考资料:Oracle 泛型教程、类型擦除、通配符使用建议。