我一直觉得,泛型是Java里最容易被低估的一个知识点。你天天写ArrayList<String>、天天用Map<String, Object>,但要真被问一句“List<String>和List<Integer>在运行时到底有什么区别”,大部分人会愣住。早年在做后台接口时,有一次线上突爆ClassCastException,查了一下午才定位到有人在一个没加泛型约束的List里同时塞了订单对象和字符串。那次之后我才真正明白,泛型不只是一层“写着好看”的语法糖,它是在帮你把原本运行期才爆炸的雷,提前到编译期拆掉。
这篇文章我打算从一个实际操作过的Java开发者的角度,把泛型的来龙去脉掰开揉碎讲一遍:它解决什么问题、各种写法怎么用、底层到底怎么擦除、通配符上下界怎么选、以及反射和框架里那些一踩一个准的坑。适合刚学完Java基础、准备跳槽面试、或者写了两年代码但没深究过泛型原理的人。读完以后,再看网上那些泛型面试题,应该会顺畅很多。
1. 泛型到底在解决什么问题:从一次线上ClassCastException说起
1.1 没有泛型的Java集合世界
Java 5之前是没有泛型的,那个年代的代码长这样:
List list = new ArrayList(); list.add("张三"); list.add(18); list.add(new Order()); String name = (String) list.get(0); Integer age = (Integer) list.get(1); Order order = (Order) list.get(2);这段代码能编译,但隐患非常大。List里什么都能塞,取出来的时候全是Object,你必须手动强转成自己期望的类型。问题是编译器无法验证你的强转是否合理,只有运行到那一行时,JVM发现类型对不上,才会抛ClassCastException。
我那次线上事故就是这么来的:一个公共的List被多个模块共用,某个模块往里塞了一个其他类型的对象,另一个模块按自己的预期去强转,结果运行期直接炸了。如果那段代码里的List加上了泛型,编译器在add的时候就会拦住错误类型,根本轮不到线上出问题。
1.2 泛型改变了什么:把运行期错误变成编译期错误
泛型引入之后,同样一段逻辑可以写成这样:
List<String> names = new ArrayList<>(); names.add("张三"); // names.add(18); // 编译直接报错 String name = names.get(0);这里有三层价值,光盯着“省了强转”是不够的:
- 编译期类型检查:编译器知道你往
List<String>里放的都是String,往里放Integer时直接编译失败,错误左移到了开发阶段。 - 消除强转噪音:从
get取出来的元素本身就是String,不用每次手写(String)。 - 自文档化:方法签名
List<User>、List<Order>比裸的List信息量大得多,读代码的人一眼就知道里面装了什么,不用翻实现。
用一个生活化类比:泛型相当于给收纳箱贴标签。你有一个写着“衣服”的箱子,就不会把螺丝刀塞进去;取的时候也知道拿出来的只能是衣服。没有泛型的List就是一个巨大纸箱,所有东西混在一起,每次都要翻一翻、猜一猜。
1.3 泛型不是Java独有的,但Java的取舍很独特
C++的模板、C#的泛型、Java的泛型,虽然都在说“泛型”,机制差异却很大。C++模板是编译期实例化出多份独立代码,每种类型都有一份实体;C#泛型在运行时保留类型参数,List<int>和List<string>是真实不同的类型。而Java采用的方案是类型擦除(Type Erasure):源码里有泛型,编译之后大部分类型参数被擦掉。
这个取舍决定了Java泛型的一大堆奇怪规则——为什么不能new T(),为什么不能创建泛型数组,为什么List<String>和List<Integer>运行时是同一个类。这些都是后续章节要展开的重点。先记住结论:Java泛型主要是一个编译期工具,它在源码层面帮你保证类型安全,但不会为每种类型生成独立代码。
2. 泛型语法全景:泛型类、泛型接口、泛型方法
2.1 泛型类:一个类型参数从定义到实例化
定义泛型类很简单,尖括号里写类型参数,通常用单个大写字母表示,约定俗成:T表示Type,E表示Element,K和V表示键值对里的Key和Value。
public class Box<T> { private T value; public T getValue() { return value; } public void setValue(T value) { this.value = value; } }使用的时候,在类名后面跟上实际类型:
Box<String> stringBox = new Box<>(); stringBox.setValue("hello"); String value = stringBox.getValue(); // 不需要强转这里有个细节值得多说一句:创建实例时,如果右边也写完整泛型,从Java 7开始可以用菱形运算符省略:
Box<String> stringBox = new Box<String>(); // 完整写法 Box<String> stringBox = new Box<>(); // 菱形运算符,编译器根据左边推断类型参数可以有多个,比如Map接口的定义就是Map<K, V>。自己写业务类时,Pair<L, R>这种二元组也很常见:
public class Pair<K, V> { private K key; private V value; public Pair(K key, V value) { this.key = key; this.value = value; } public K getKey() { return key; } public V getValue() { return value; } }泛型也可以设置边界,比如希望类型参数必须是某个类的子类,或者实现某个接口:
public class NumberBox<T extends Number> { private T value; public double doubleValue() { return value.doubleValue(); } }2.2 泛型接口:实现类如何决定类型参数
泛型接口最常见的例子就是集合框架里的List、Set、Map,自己写代码时,数据访问层经常会定义一个泛型接口:
public interface Repository<T> { T findById(Long id); void save(T entity); }实现类有两种姿势。第一种,实现时直接指定具体类型:
public class UserRepository implements Repository<User> { @Override public User findById(Long id) { // 具体实现 return null; } @Override public void save(User entity) { // 具体实现 } }第二种,实现类继续保留泛型,适合“基类模式”:
public abstract class BaseRepository<T> implements Repository<T> { @Override public T findById(Long id) { // 公共逻辑 return null; } }继续保留泛型的好处是,后面任意一个子类都可以指定自己的实体类型,公共方法的类型检查依然有效。你现在在MyBatis-Plus里见到的BaseMapper<T>,本质上就是这种泛型接口思想的延伸。
2.3 泛型方法:类型参数只属于方法本身
泛型方法是最容易和泛型类搞混的一块。泛型方法中的类型参数范围仅限于方法,可以在普通类里出现,也可以是static方法:
public class GenericMethodExample { public static <T> T getOrDefault(T value, T defaultValue) { return value != null ? value : defaultValue; } public static <T extends Comparable<T>> T max(List<T> list) { if (list == null || list.isEmpty()) { return null; } T max = list.get(0); for (T item : list) { if (item.compareTo(max) > 0) { max = item; } } return max; } }注意static关键字和泛型位置:public static <T> T getOrDefault(...),<T>在返回类型之前。这是“泛型方法”和“泛型类里的方法”最大的区别——泛型类的类型参数是类级别,而泛型方法的类型参数是方法级别。
调用时通常不需要你显式写出类型参数,编译器根据方法参数就能推断:
String result = GenericMethodExample.getOrDefault("hello", "world"); Integer num = GenericMethodExample.getOrDefault(1, 2);2.4 类型推断与菱形运算符的边界
类型推断是Java泛型容易让新手困惑的另一面。Java 7引入菱形运算符后,Map<String, List<Integer>> map = new HashMap<>();右边不用重复写类型参数;Java 8增强了目标类型推断,使得Collections.emptyList()这类泛型方法在赋值时也能推断:
List<String> list = Collections.emptyList(); // Java 8 以后可以正常推断Java 10以后,你可以用var进一步简化局部变量声明:
var map = new HashMap<String, List<Integer>>();但var只适合局部变量,类字段、方法参数、返回类型还是老老实实写完整泛型。过度使用var会让代码可读性变差,尤其是泛型嵌套比较深的时候。我个人的习惯是:类型复杂时不用var,类型简单且一目了然时才用。
3. 类型擦除:理解Java泛型的最后一公里
3.1 字节码里到底发生了什么
一段简单的代码:
List<String> list = new ArrayList<>(); list.add("hello"); String s = list.get(0);用javap -c反编译后,你会看到add调用的签名其实是add(Object),get返回的也是Object,只不过在赋值给String s之前,多了一条checkcast指令,把Object强制转换成String。
这就是类型擦除的核心:编译器在编译阶段做了类型检查,然后把类型参数擦除成上界。如果没有指定上界,T擦除成Object;如果指定了T extends Number,那T擦除成Number。
擦除发生在几处:
- 泛型类的类型参数,在类内部被擦除为上界;
- 泛型方法的类型参数,在方法内部被擦除为上界;
- 泛型接口的引用,也会被擦除。
所以,List<String>和List<Integer>在运行时都是同一个ArrayList类,getClass()返回的都是ArrayList.class。这一点是Java泛型与C#泛型最大的不同。
3.2 桥方法:编译器为多态打上的补丁
类型擦除会带来一个棘手问题:如果父类是泛型类,子类继承了具体的类型参数,那父类方法擦除前后的签名会不一样,子类的重写方法可能对不上号。编译器需要生成所谓的**桥方法(Bridge Method)**来维护多态。
典型例子:
public class Node<T> { private T data; public void setData(T data) { this.data = data; } } public class StringNode extends Node<String> { @Override public void setData(String data) { System.out.println("StringNode.setData"); } }Node<T>的setData(T)在字节码层面擦除成setData(Object)。如果StringNode只提供setData(String),那么通过Node引用调用setData("xx")时,虚拟机会去找setData(Object)方法,找不到子类的覆盖版本,多态就失效了。
为了解决这个矛盾,编译器在StringNode里悄悄生成了一个桥方法:
public void setData(Object data) { setData((String) data); }桥方法把Object强转成String,再调用子类真实的setData(String)。所以你在源码里看不见它,但用反射getDeclaredMethods()时会发现多了一个名字相同、参数为Object的方法。面试时问“为什么覆盖泛型方法之后,反射能看到奇怪的方法”,答案就是桥方法。
类似的情况也出现在实现泛型接口时。比如实现Comparator<String>的compare(String, String),编译器也会生成compare(Object, Object)桥方法。这是Java保证擦除后多态仍然成立的关键机制。
3.3 泛型擦除后的真实类型信息其实还在
这里要纠正一个很流行的以讹传讹:“Java泛型运行时全部被擦除,一点信息都不剩。”这话不完全对。准确说法是:运行时Class对象本身不包含类型参数,但class文件里还保存着一份泛型签名(Signature属性)。你可以通过反射把这些信息读回来。
比如以下代码可以拿到字段声明时的泛型类型:
Field field = MyClass.class.getDeclaredField("names"); Type genericType = field.getGenericType(); // genericType 可能是 ParameterizedType,里面保存了 List<String> 的 String 信息Java反射包里的Type接口有几个关键子类型:
Class:普通类型,比如String.class;ParameterizedType:参数化类型,比如List<String>,可以拿到原始类型和实际类型参数;TypeVariable:类型变量,比如T;GenericArrayType:泛型数组,比如T[];WildcardType:通配符,比如? extends Number。
框架能实现“把一个JSON数组反序列化成List<User>”,靠的就是这些签名信息。方法体内部的局部变量确实会被彻底擦除,但类、字段、方法参数里的泛型信息并没有完全消失。
3.4 那些天生不能泛型化的东西:基本类型、异常、静态成员
为什么List<int>不合法?
因为类型参数擦除后是Object,而int不是Object的子类,JVM根本没有int类型的引用容器。解决方案是用包装类型List<Integer>,代价是装箱和拆箱带来的额外对象创建。后续Java引入了IntStream、IntBuffer这类专门针对原始类型的特化版本,也是绕开这个限制的一种方式。
为什么异常不能泛型化?
catch子句要求一个具体类型,而泛型类型在运行时被擦除,JVM无法判断该捕获什么异常。所以下面这些写法全部不合法:
// 泛型类不能继承 Throwable class MyException<T> extends Exception { } // catch 子句不能是类型变量 try { // ... } catch (T e) { // 编译错误 }为什么静态成员不能用泛型类型参数?
静态成员属于类本身,在类加载阶段就要初始化,而类型参数要等实例化时才确定。类加载时根本不知道T是什么,所以:
public class Box<T> { private static T value; // 编译错误 }但这不影响泛型方法里的static——因为static <T> T method(T t)里的T是方法自己的类型参数,与方法调用时传入的参数绑定,不依赖类实例。
为什么不能new T()、不能new T[capacity]?
运行时T已经被擦除,JVM不知道该创建哪个类的实例。new T()想调用无参构造,但对JVM来说T可能被擦成Object,直接new Object()显然不是你想要的。创建泛型数组同理,T[]在运行时没法确定数组的组件类型,编译器直接禁止。实际项目里如果非要用数组,通常只能借助(T[]) new Object[size]这种带警告的强转,或者干脆换成List<T>。
3.5 数组与泛型的“互斥”定律
Java数组和泛型之间有一道著名的不兼容规则:不能创建泛型数组。
// 编译错误:generic array creation List<String>[] array = new List<String>[3];为什么?这得从数组的特性说起。数组在运行时是协变的,并且携带组件类型信息:String[]可以赋值给Object[]变量,但如果你往里放一个非String元素,运行时会立刻抛ArrayStoreException。
如果允许new List<String>[3],由于泛型擦除,运行时它实际只是一段List[]数组,数组本身只知道组件是List,却不知道每个元素原来规定要装List<String>。这时候往里面塞一个List<Integer>,数组的运行时保护完全失效,编译器搭好的类型安全防线就被绕过了。所以Java干脆禁止创建泛型数组。
如果你实在需要“泛型数组”,常见替代方案是:
List<List<String>> list = new ArrayList<>();用集合嵌套集合,既能表达相同语义,又不触碰数组与泛型的冲突。
4. 通配符与上下界:生产者extends,消费者super
4.1 无界通配符List<?>到底能干什么
通配符用问号表示,List<?>好读作“某种未知类型的List”。最典型的场景是“我只想遍历,不关心元素具体类型”:
public void printAll(List<?> list) { for (Object item : list) { System.out.println(item); } }这个方法能接收List<String>、List<Integer>、List<User>,因为?表示未知类型。但注意,List<?>只能读,不能安全写。你往里add一个非null元素都会编译报错,原因很直白:编译器不知道这个List实际装的是什么类型,任何写入都可能破坏原有类型约束。
这里要区分List<Object>和List<?>:List<Object>只能接收Object或子类对象,List<String>不能赋给它,因为泛型是不变的;List<?>则能接收任何类型的List,代价是你失去了写入能力。这是Java泛型“不变性”的一个具体体现。
4.2 extends边界:为什么“只读”比“可写”更安全
上界通配符? extends T表示“某个T的子类型”。典型场景是计算一组数字的和:
public double sum(List<? extends Number> list) { double total = 0; for (Number number : list) { total += number.doubleValue(); } return total; }这个方法既能接收List<Integer>,也能接收List<Double>,读取时每个元素都能当作Number处理,非常安全。
但为什么不能往List<? extends Number>里add一个Integer?因为实际运行时,这个List可能是List<Double>,你往里面放Integer显然会让容器失去类型安全。你唯一能确定的是“里面的元素都是Number的子类”,但具体是哪个子类,编译器不知道。所以? extends边界适合**生产数据(读)**的场景。
4.3 super边界:为什么“可写”是消费者利器
下界通配符? super T表示“某个T的父类型”。典型场景是把元素写入一个集合:
public void addIntegers(List<? super Integer> list) { list.add(1); list.add(2); }为什么add是安全的?因为List<? super Integer>可能是List<Integer>、List<Number>、甚至List<Object>。无论实际是哪一个,往里放一个Integer都一定是安全的——Integer是这些类型的子类。
但读取就麻烦了:从List<? super Integer>里取出来的元素,你能确定的最小公共类型是Object,不能直接当作Integer用,除非手动强转。所以? super边界适合**消费数据(写)**的场景。
4.4 PECS原则与Collections.copy的真实签名
读到这里,你应该已经摸到规律了:Producer Extends,Consumer Super,也就是PECS原则。一个方法如果是“从集合里取出元素供外界使用”,集合就是生产者,用? extends T;如果方法是“把元素放进集合”,集合就是消费者,用? super T。
最经典的例子是Collections.copy:
public static <T> void copy(List<? super T> dest, List<? extends T> src) { for (int i = 0; i < src.size(); i++) { dest.set(i, src.get(i)); } }src是生产者,只读,用? extends T;dest是消费者,只写,用? super T。这样就能安全地把List<Integer>复制到List<Number>,但不能反过来复制。
另一个值得研究的是Collections.sort的签名:
public static <T> void sort(List<T> list, Comparator<? super T> c)为什么比较器要用? super T而不是? extends T?因为比较器需要消费T来做比较。如果一个比较器能比较Number,那它自然也能比较Integer;反过来则不一定成立。用? super T让方法的适用范围更宽,这是非常典型的“消费者用super”的设计。
4.5 通配符与instanceof、反射的坑
通配符使用中还有一个经典坑:instanceof不支持具体泛型类型。
if (list instanceof List<String>) { // 编译错误 }因为在运行时,List<String>和List<Integer>都是同一个List类型,类型参数的光环早已被擦除。你最多能写:
if (list instanceof List<?>) { // 合法,但没什么实际约束力 }通配符可以和instanceof配合,但基本等于只检查了“它是不是List”,检查不了元素类型。真实项目里,你想判断一个集合元素类型,通常得遍历集合拿第一个元素再instanceof,或者借助第5章讲的反射获取泛型签名。
5. 反射、序列化与框架设计中的泛型实战
5.1 从TypeToken看“泛型超类”的作用
日常开发里最常见的一个泛型实战场景就是JSON反序列化:Gson的fromJson(String, Type),如果你只传User.class,最多得到User;如果要得到List<User>,就必须把“包含泛型参数的类型”传进去。
Gson的TypeToken用法大家应该眼熟:
List<User> users = gson.fromJson(json, new TypeToken<List<User>>() {}.getType());问题来了:为什么必须写new TypeToken<List<User>>() {},还要带一对花括号?直接new TypeToken<List<User>>()不行吗?
关键在匿名内部类。我们写一个简化版本的理解框架:
public abstract class TypeRef<T> { private final Type type; protected TypeRef() { Type superclass = getClass().getGenericSuperclass(); if (!(superclass instanceof ParameterizedType)) { throw new RuntimeException("请使用匿名子类获取泛型类型"); } type = ((ParameterizedType) superclass).getActualTypeArguments()[0]; } public Type getType() { return type; } }当你写new TypeRef<List<User>>() {}时,编译器会生成一个匿名子类,这个子类的class文件里带有父类TypeRef<List<User>>的泛型签名。调getClass().getGenericSuperclass()就能拿到一个ParameterizedType,里面保存着List<User>的完整信息,再通过getActualTypeArguments()[0]取出User。
但如果你偷懒写new TypeRef<List<User>>(),没有匿名子类,getClass()就是TypeRef.class本身,它的直接超类不再是带泛型参数的父类,自然拿不到ParameterizedType,最后要么返回Object,要么直接抛异常。很多反序列化框架(Gson、Jackson、Fastjson的TypeReference)背后的原理都是这一套。
这也是我实际开发里踩过的一个坑:写通用导入导出工具时,给BaseConverter<T>设计类型获取逻辑,结果调用方没按规范用匿名子类,反射拿到的是TypeVariable而不是具体的User,后续强转直接ClassCastException。后来我在工具里加了一层校验:如果解析出来的类型是TypeVariable,立刻抛出带提示语的异常,把错误暴露在初始化阶段,而不是等到数据处理时炸。
5.2 框架里的泛型设计模式:BaseMapper与TypeHandler
泛型在框架设计里几乎是标配。你用过MyBatis-Plus的话,会对BaseMapper<T>印象深刻:
public interface BaseMapper<T> { int insert(T entity); T selectById(Serializable id); // ... }每个业务Mapper继承它并指定实体类型:
public interface UserMapper extends BaseMapper<User> { }这样UserMapper自动拥有基于User的增删改查方法,而且编译期就知道实体的具体类型。
在MyBatis里,还有个和泛型强相关的组件叫TypeHandler<T>。它负责Java类型和JDBC类型的互转,框架在注册处理器时会解析TypeHandler实现类上的泛型参数,从而决定“这个处理器对应哪个Java类型”。比如你自定义一个JsonTypeHandler,实现TypeHandler<List<Address>>,框架会通过反射读取泛型签名,知道它要处理的是List<Address>类型的字段。
Spring框架里也能看到类似设计,比如ResolvableType,它封装了一套解析泛型信息的工具,能处理复杂的嵌套泛型。写框架层的同学如果不想重复造轮子,直接研究ResolvableType的实现思路会很有帮助。
5.3 我在项目里踩过的泛型反射坑
反射加泛型,最容易出问题的点有三个,都值得单独提醒:
第一,父类泛型参数在子类中被替换。想在抽象父类里通过getGenericSuperclass()拿到当前子类的具体类型,前提是子类在继承时指定了实际类型。如果子类写成class SubDao extends BaseDao<T>保留类型变量,那么父类反射拿到的依然是TypeVariable,不是具体类。
第二,字段泛型比方法泛型好拿。字段的getGenericType()能返回完整的ParameterizedType,但如果你拿的是方法的局部变量,那什么都拿不到。所以框架层面想解析泛型,通常依赖字段声明、方法参数或类继承关系,很少依赖方法体内部。
第三,序列化框架依赖“类型令牌”。Java默认序列化机制其实不会保留泛型字段的TypeVariable,JSON库之所以能还原List<User>,靠的是调用方显式传入TypeToken或TypeReference。这也是很多团队明确要求DTO里不要写裸List的原因——声明List<User> users和声明List users,对反射解析来说完全是两个世界。
6. 我的泛型使用习惯与面试高频追问
6.1 写生产代码时的泛型习惯
泛型用久了,我慢慢形成了一套比较固定的编码习惯,不保证是标准答案,但确实少踩了很多坑。
第一,API边界上优先用通配符,而不是裸类型。如果一个方法只读集合,参数写成List<? extends T>;如果一个方法只写集合,参数写成List<? super T>。这能让调用方传参范围更宽,也更准确地表达方法意图。
第二,返回类型尽量用具体类型,避免暴露泛型内部结构。比如返回Map<String, List<Integer>>没问题,但没必要返回一个Map<?, ?>让调用方自己去猜。
第三,裸类型是禁区。除非是Class<?>、List<?>这样确实不知道类型的场景,否则不要写List、Map这种裸类型。“裸类型”意味着把所有泛型保护全部关掉,等于让编译器闭嘴。
第四,定义泛型方法时,让类型参数和参数列表产生关联。如果一个方法的返回值类型参数和入参完全无关,比如<T> T random(),那编译器根本没法推断T是什么,调用方只能靠强转。好的泛型方法,类型参数通常能通过参数推导出来。
第五,能返回空集合就不要返回null。配合泛型,Collections.emptyList()、List.of()这类方法能帮你省掉一堆判空逻辑,也避免调用方因为空指针怀疑是泛型转换出了问题。
6.2 面试里绕不开的泛型考点拆解
泛型是Java面试的高频区,我作为面试官问候选人的时候,通常会按难度递进往下追:
基础层:List<String>能不能赋给List<Object>?
不能。泛型是不变的,不像数组那样协变。如果允许,你就能往一个“声明为List<Object>的引用”里放Integer,污染了原本是List<String>的容器。这是类型安全的底线。
进阶层:List<?>和List<Object>有什么区别?
List<?>能接收任意类型的List,但不能安全写入非null元素;List<Object>只能接收List<Object>,但可以写入任何Object子类。
进阶层:为什么不能new List<String>[3]?
数组运行时携带类型检查,而泛型类型参数被擦除,两者机制冲突。允许创建泛型数组会让数组的运行时保护失效。
进阶层:桥方法是怎么产生的?
当子类覆盖了父类的泛型方法并指定具体类型时,编译器为了保证多态,会额外生成一个参数为擦除类型的桥方法,把Object参数强转后委托给子类的真实方法。
进阶层:PECS原则能不能举个例子?
Collections.copy(List<? super T> dest, List<? extends T> src)就是最好例子。生产者用extends,消费者用super,保证既能灵活传参,又不会破坏类型安全。
高阶:如何拿到List<User>的泛型真实类型?
通过匿名子类保留泛型超类信息,配合getGenericSuperclass()拿到ParameterizedType,再取getActualTypeArguments()[0]。Gson的TypeToken、Jackson的TypeReference都是这个套路。
高阶:为什么static成员不能使用泛型类的类型参数?
因为静态成员在类加载时就被初始化,而类型参数要等创建实例时才确定。类加载阶段没有任何实例,自然无从得知T是什么。
高阶:泛型方法里的<T>和泛型类里的T有什么区别?
泛型类里的T属于类,整个类范围都可以用;泛型方法里的T只属于方法,每次调用根据实参独立推断。所以泛型方法可以声明为static,而泛型类的类型参数不能出现在静态成员里。
6.3 一个小技巧:用泛型约束API边界
最后分享一个我写工具类时很喜欢的技巧。当你觉得某个API的“泛型约束”总是表达不清楚时,试着引入一个额外的类型变量,并用边界把各个参数关联起来。
比如求最大值的方法,很多人会写<T extends Comparable<T>> T max(List<T> list),这个能用,但不够灵活:传入List<Integer>没问题,可如果集合里是某种实现了Comparable<SuperType>的子类型,这个签名就不好使了。更完整的写法是参考Collections.max:
public static <T extends Comparable<? super T>> T max(Collection<? extends T> coll)这个签名同时用了两处技巧:比较器边界用? super T,集合泛型用? extends T。初看很绕,但理解PECS之后再读它,会觉得每个字符都不是多余的。这种“用泛型边界精确描述API契约”的能力,基本就是泛型从入门到进阶的分水岭。
我在实际工作中很少看到有人把这些边界写得很严谨,更多是图省事直接写List<T>。短业务代码无所谓,但一旦做出公共工具或框架API,边界写得不严谨,调用方的使用范围就会被无谓收窄,后面想改又是一个破坏性变更。泛型的价值从来不是“代码复杂一点显得高级”,而是让编译器替你把类型契约守住。