06泛型

第 8 章 泛型程序设计
8.1 为什么要使用泛型程序设计
前面用了无数次的 List<Employee>、Comparable<Employee>,尖括号里的东西就是泛型。先看没有它会怎样。
8.1.1 类型参数的好处
假设要一个「能装任何东西」的盒子。没有泛型时,只能用 Object:
public class ObjectBox { private Object value;
public void set(Object value) { this.value = value; } public Object get() { return value; }}用起来是这样:
ObjectBox box = new ObjectBox();box.set("张三");String name = (String) box.get(); // 取出来必须强转
box.set(123); // 放个整数进去——编译通过(123 自动装箱成 Integer)String oops = (String) box.get(); // 运行时才炸:ClassCastException问题很清楚:盒子里装了什么,编译器不知道。它只能等到运行时强转的那一刻才发现不对。
泛型就是把这个信息写进类型里:
public class Box<T> { private T value; public void set(T value) { this.value = value; } public T get() { return value; }}Box<String> box = new Box<>();box.set("张三");String name = box.get(); // 不用强转,编译器知道是 String
box.set(123); // ❌ 编译期就报错T 叫类型参数,String 叫类型实参。三个好处一次到位:
- 类型错误从运行期提前到编译期;
- 取出来不用强转;
- 看
Box<String>就知道里面装什么,不用读代码猜。
vs C:C 只能用
void*或宏做通用容器,同样没有编译期检查——把int塞进void*再当字符串用,编译器也一声不吭。
8.1.2 谁想成为泛型程序员
泛型有两种用法,难度差很远:
- 用别人写好的泛型类——会写
List<String>、Map<String, Employee>就够,这是日常 90% 的场景。 - 写泛型类/方法给别人用——要理解擦除、通配符、限制,这是写库和框架的人干的活。
本章的路线:8.2–8.4 先学会写最基本的泛型(类、方法、限定);8.5 看编译器怎么实现(擦除);8.6 列出限制;8.7–8.8 讲类型之间的关系;8.9 只用认得。
8.2 定义简单泛型类
一个泛型类长这样:
public class Pair<T> { private T first; private T second;
public Pair(T first, T second) { this.first = first; this.second = second; }
public T getFirst() { return first; } public T getSecond() { return second; } public void setFirst(T first) { this.first = first; }}用的时候指定 T 是什么:
Pair<String> names = new Pair<>("张三", "李四");String a = names.getFirst(); // 不用强转
Pair<Employee> pair = new Pair<>(e1, e2);Employee e = pair.getFirst();几点:
<>叫菱形运算符,作用是让编译器推导类型实参(从构造器实参推,必要时参考左边):var names = new Pair<>("张三", "李四");也能得到Pair<String>。它只能跟在new后面,写成Pair<> names = ...是语法错误。Pair<String>和Pair<Employee>来自同一个类,只是类型实参不同,编译后是同一个类型(8.5.1)。T只是习惯叫法,E常用于「元素」、K/V用于键值,换成A也照样编译,但别这么干。
一个类可以带多个类型参数:class Pair<K, V> { K key; V value; }。
8.3 泛型方法
类型参数也可以只给一个方法用。写法是把 <T> 放在返回值前面:
public class ArrayAlg { public static <T> T getMiddle(T... a) { return a[a.length / 2]; }}这是个静态方法,直接 ArrayAlg.getMiddle(...) 就能调,不用 new ArrayAlg()。
T... a 是 5.5 讲的可变参数,编译后会变成数组 T[] a。所以 a.length 是数组的长度,跟 T 是什么类型无关——数组天生有 length,也天生能 a[i] 取元素。
但 T 自己只有 Object 的本事:a[0].toString()、a[0].equals(...) 可以,a[0].compareTo(...) 不行。想让 T 会别的,只能加限定(8.4)。
String s = ArrayAlg.getMiddle("张三", "李四", "王五"); // 推出来 T = String编译器从实参推导类型,一般不用手写。要强制指定就写在方法名前:
String s = ArrayAlg.<String>getMiddle("张三", "李四", "王五");泛型类和泛型方法的 <T> 是两套东西:
public class Util<T> { // 类上的 T:整个类里有效 private T value;
public static <T> T pick(T a, T b) { // 方法上的 T:只管这个方法 return a; }}两个 T 同名纯属巧合,互不相干。静态方法不能用类上的 T,原因见 8.6.7。
8.4 类型变量的限定
getMiddle 只要求元素能装进数组,所以方法体里除了 Object 的方法,别的都不能调。想在方法里用 compareTo 呢:
public static <T> T min(T[] a) { T smallest = a[0]; for (T e : a) if (e.compareTo(smallest) < 0) smallest = e; // ❌ Object 没有 compareTo return smallest;}报错的原因是:方法体只编译一份,要同时服务 String、Employee、LocalDate 所有调用。调用点知道传的是什么,方法体本身不知道,所以只能调用「所有候选类型都保证有」的方法——默认就是 Object 那一套。
想让编译器多认识几个方法,就得把要求写出来:
public static <T extends Comparable<T>> T min(T[] a) { T smallest = a[0]; for (T e : a) if (e.compareTo(smallest) < 0) smallest = e; return smallest;}读作「T 是实现了 Comparable<T> 的某种类型」。
用的是 extends,哪怕限定的是接口。 这里它表示「子类型」——类继承和实现接口都会产生子类型关系,限定只需要这个关系,不区分来源。所以 <T extends ...> 后面类、接口都能放;普通类声明写 class A extends Comparable 依然非法,那里只能 implements。
多个限定用 & 连接:
<T extends Number & Comparable<T> & Serializable>规则:类最多一个,且必须写在最前面;后面能跟任意多个接口。
vs C:C++ 的模板在实例化时才检查成员存不存在,报错点往往在模板内部,很难读;Java 必须在类型变量上把要求写出来,编译期就核对。
8.5 泛型代码和虚拟机
核心认知只有一句:泛型只活在编译期。编译器拿它做检查,检查完就抹掉,字节码里没有泛型。
8.5.1 类型擦除
Pair<T> 编译后的样子,概念上等于:
public class Pair { private Object first; // T 被换成了 Object private Object second;
public Pair(Object first, Object second) { ... } public Object getFirst() { return first; }}类型参数被擦成了它的边界:
- 没写限定 → 擦成
Object; - 写了
<T extends Comparable<T>>→ 擦成Comparable。
所以运行时彻底不知道 T 是什么:
Pair<String> a = new Pair<>("x", "y");Pair<Integer> b = new Pair<>(1, 2);System.out.println(a.getClass() == b.getClass()); // true为什么要这么设计?为了兼容。
泛型是 Java 5 才加进来的。在那之前,ArrayList list 这种原始类型写法统治了整个生态。如果泛型信息要写进字节码,老 JVM 根本读不懂新编译出来的 class 文件;而且 JDK 里 ArrayList 一泛型化,所有按老签名编译的代码也都得跟着改——等于逼全世界重来一遍。
所以选了折中方案:泛型只在编译期用,编译完就扔掉。老 JVM 看到的还是普通的 Pair,什么都不用改。
代价就是:泛型不是对象自带的属性,只是编译器贴在变量上的标记。一旦绕过编译器,它就管不了了:
Pair<String> a = new Pair<>("张三", "李四");Pair raw = a; // 原始类型,绕过检查raw.setFirst(123); // ⚠️ 只有 unchecked 警告,照样编译String s = a.getFirst(); // 编译器仍按 String 处理 → 运行时 ClassCastExceptiona 明明声明的是 Pair<String>,里面却躺着一个 Integer——因为对象身上根本没有 T 的信息,编译器只能相信你在代码里写的声明。
vs C:C++ 模板是「每个类型实参生成一份代码」,
vector<int>和vector<double>是两份机器码;Java 只有一份,靠擦除 + 强转糊弄过去。C++ 快但代码膨胀,Java 省空间但丢了运行时类型。
8.5.2 转换泛型表达式
既然擦除后 getFirst() 返回 Object,为什么这行能过?
Pair<Employee> p = new Pair<>(e1, e2);Employee e = p.getFirst();因为编译器偷偷插了一次强制转换。你写的是一行,实际效果是:
Employee e = (Employee) p.getFirst();转错了照样抛 ClassCastException——只不过编译器保证:只要你老老实实写 Pair<Employee>,就不会把别的类型塞进去。
但不是每处都有强转。 判断标准是方向:
p.setFirst(e); // 传进去:Employee → Object,向上转,不需要Employee e = p.getFirst(); // 取出来:Object → Employee,向下转,必须强转判断发生在编译期,依据是静态类型:编译器知道 getFirst() 声明返回 Employee,也知道擦除后实际返回 Object,两者一比较就知道该不该补转换。跟运行时无关。
一旦绕过编译器(原始类型、反射),这层保证就没了,8.5.1 那个 raw.setFirst(123) 就是这么炸的。
8.5.3 转换泛型方法
6.1 写过的 Employee implements Comparable<Employee>,源码是这样:
public class Employee implements Comparable<Employee> { private double salary;
@Override public int compareTo(Employee other) { return Double.compare(salary, other.salary); }}可擦除后,接口里的方法变成了 compareTo(Object),而类里实现的是 compareTo(Employee)——签名对不上,通过接口调用时就找不到实现。
于是编译器自动补一个桥方法,编译后概念上变成两份:
public class Employee implements Comparable { public int compareTo(Employee other) { // 你写的那个 return Double.compare(salary, other.salary); }
// 编译器生成的桥方法 public int compareTo(Object other) { return compareTo((Employee) other); }}接口调用走的是 compareTo(Object),桥方法把参数转成 Employee,再转调你写的那份。这样 Comparable<Employee> c = e; c.compareTo(other) 才能落到正确的方法上。
所以反射里会看到多出来一个方法,不用奇怪。
这一层认得即可。
8.5.4 调用遗留代码
泛型是 Java 5 才有的。调用泛型化之前的老库,返回的是原始类型,编译器会警告 unchecked:
@SuppressWarnings("unchecked")Pair<Employee> p = oldLibrary.getPair(); // 我确认它装的是 Employee@SuppressWarnings 最好只加在最小的范围上(一个变量、一个方法),别加在类上——那等于把整片警告都关了。
8.6 限制与局限性
十条限制,根因都是同一个:擦除之后 T 不存在了。
8.6.1 不能用基本类型实例化类型参数
Pair<int> p; // ❌ 基本类型不能当类型实参Pair<Integer> q; // ✅ 用包装器因为擦除后 T 变成 Object,而 int 不是对象。所以泛型容器里存的永远是包装器,装箱拆箱的开销跑不掉。
vs C:C++ 的
vector<int>直接存int,没有装箱;Java 的ArrayList<Integer>里是一个个Integer对象。
8.6.2 运行时类型查询只适用于原始类型
if (x instanceof Pair<String>) { } // ❌ 编译错误if (x instanceof Pair) { } // ✅ 只能这么写运行时压根没有 <String> 这个信息。getClass() 也一样,只返回 Pair.class。
真要检查元素类型,得额外传一个 Class 参数(8.9.2)。
8.6.3 不能创建参数化类型的数组
Pair<String>[] arr = new Pair<String>[10]; // ❌Pair<?>[] arr2 = new Pair<?>[10]; // ❌ 通配符也不行数组在运行时会检查元素类型(塞错类型抛 ArrayStoreException),但擦除后 Pair 数组只知道「元素是 Pair」,管不了里面装什么——这个检查就废了,所以编译器干脆不让写。
假如允许,后果是这样:
Pair<String>[] arr = new Pair<String>[10]; // 假设这行合法Object[] objs = arr; // 数组协变,允许objs[0] = new Pair<Integer>(1, 2); // 只检查「是不是 Pair」,放行String s = arr[0].getFirst(); // 实际是 Integer → ClassCastException数组的类型检查兜不住泛型,编译器就宁愿让你写不了,也不留下这个洞。
要用就用集合:List<Pair<String>>。
8.6.4 Varargs警告
泛型可变参数本质是数组,同样有问题:
@SafeVarargspublic static <T> void addAll(List<T> list, T... values) { for (T v : values) list.add(v);}不加 @SafeVarargs 编译器会警告 unchecked。加了等于向编译器保证「我没往这个数组里塞错类型」。它只能用在 static、final、private 方法或构造器上。
8.6.5 不能实例化类型变量
public static <T> T create() { return new T(); // ❌ 运行时不知道 T 是什么}改法之一:传一个工厂。6.2 学的 Supplier 正好用上:
public static <T> T create(Supplier<T> factory) { return factory.get();}
Employee e = create(() -> new Employee("张三", 5000));改法之二:传 Class<T>,用反射创建(8.9.2)。
8.6.6 不能构造泛型数组
T[] arr = new T[10]; // ❌同一个原因。常见绕法是让调用者把数组传进来(ArrayList.toArray(T[] a) 就是这么设计的),或者用反射的 Array.newInstance。
集合类内部其实是 Object[] 加一层强转,只是这些脏活被包在 JDK 里了。
8.6.7 泛型类的静态上下文中类型变量无效
public class Box<T> { private static T value; // ❌ public static T get() { return null; } // ❌}static 成员属于类本身,在创建对象之前就存在;而 T 是每个实例各自指定的——Box<String> 和 Box<Integer> 的 T 不一样,静态成员该用哪个?
静态方法想用类型参数,只能自己声明一个:
public static <U> U identity(U x) { return x; } // ✅ 另一个类型变量8.6.8 不能抛出或捕获泛型类的实例
class MyException<T> extends Exception { } // ❌ 泛型类不能继承 Throwabletry { ... } catch (T e) { } // ❌异常处理要在运行时按类型匹配,而 T 被擦掉了,匹配不了。认得即可。
8.6.9 可以取消对检查型异常的检查
利用泛型可以让受检异常「绕过」编译器的检查,社区里叫 sneaky throw:
@SuppressWarnings("unchecked")static <T extends Throwable> void sneakyThrow(Throwable t) throws T { throw (T) t;}原理:T 可以被推断成某个非受检异常,编译器就以为调用点不用处理;但运行时不看泛型,照样把真正的异常抛出去。
危险技巧,认得即可,业务代码里不要用。
8.6.10 注意擦除后的冲突
public class Pair<T> { public boolean equals(T value) { ... } // ❌ 编译错误}equals(T) 擦除后变成 equals(Object),和 Object.equals(Object) 撞车,编译器报 name clash。
改个名就行,比如 sameAs(T value)。认得即可。
8.7 泛型类型的继承规则
Pair<Manager> 不是 Pair<Employee> 的子类型,尽管 Manager extends Employee:
Pair<Manager> pm = new Pair<>(m1, m2);Pair<Employee> pe = pm; // ❌ 编译错误为什么不能允许?反证一下。假如允许:
pe.setFirst(new Employee("张三", 5000)); // 往「经理的 Pair」里塞了个普通员工Manager m = pm.getFirst(); // 再当经理用——炸了问题就出在 setFirst,它会被用来破坏类型。所以泛型之间只有完全相同的类型实参才算同一类型——只要容器能写,就不能协变。
对比数组——数组是协变的。「协变」的意思是:子类型关系会沿着容器传递,Manager 是 Employee 的子类型,所以 Manager[] 也是 Employee[] 的子类型。泛型没有这个性质,叫不变。
Manager[] ms = new Manager[10];Employee[] es = ms; // ✅ 编译通过es[0] = new Employee("张三", 5000); // 运行时 ArrayStoreException数组允许这种赋值,代价是把检查推到了运行时。泛型不允许,所以更安全:错误在编译期就被拦住。
那「Employee 的任意子类型的 Pair」怎么表达?用通配符。
8.8 通配符类型
8.7 说 Pair<Manager> 不能当 Pair<Employee> 用。可很多方法其实不关心具体是哪个子类型,只想读一读、或者写一写。通配符就是为这种场景准备的。
8.8.1 通配符概念
先看一个实际问题。写个方法打印 Pair 里员工的名字:
public static void printName(Pair<Employee> p) { System.out.println(p.getFirst().getName());}传 Pair<Employee> 没问题,传 Pair<Manager> 编译不过——8.7 刚讲过。可方法内部只是读名字,压根不关心是经理还是普通员工。
把参数改成 Pair<? extends Employee>:
public static void printName(Pair<? extends Employee> p) { System.out.println(p.getFirst().getName()); // ✅ 读出来是 Employee}现在 Pair<Employee>、Pair<Manager>、Employee 任何子类型的 Pair 都能传进来。
为什么读安全? 因为不管实际是哪个子类型,它都是 Employee,getName() 一定有。
为什么写不进去?
public static void printName(Pair<? extends Employee> p) { p.setFirst(new Employee("张三", 5000)); // ❌ p.setFirst(new Manager("张三", 5000)); // ❌ 连 Manager 也不行}实际类型可能是 Pair<SalesManager>——Employee 的另一个子类。往里写 Manager 就把它弄脏了。编译器不知道实际是哪一个,只能一律禁止。
所以规则是:能读不能写,唯一能写的是 null。
8.8.2 通配符的超类型限定
再来一个反方向的需求:写一个方法,往盒子里放经理。
public static void addManager(Pair<? super Manager> p) { p.setFirst(new Manager("李四", 8000)); // ✅}? super Manager 表示「元素是 Manager 的某个父类型」。这个参数能接 Pair<Manager>、Pair<Employee>、Pair<Object>。
写:只能写 Manager 及它的子类。写 Employee 不行——万一实际是 Pair<Manager>,又变成往经理盒子里塞普通员工。
读:能读,但读出来只能当 Object。因为实际可能是 Pair<Object>,里面装什么都说不准。
规则:能写(Manager 及子类),读出来只有 Object。
| 通配符 | 读出来是什么 | 能写什么 |
|---|---|---|
? extends T | T | 只能 null |
? super T | Object | T 及子类 |
8.8.3 无限定通配符
Pair<?> p;什么都不限定:读出来是 Object,写一律不行(null 除外)。
适合「我不关心里面是什么,只要是个 Pair」:
public static boolean isEmpty(Pair<?> p) { return p.getFirst() == null;}8.8.4 通配符捕获
想把 Pair<?> 的两个元素交换,直觉写法行不通:
public static void swap(Pair<?> p) { ? t = p.getFirst(); // ❌ ? 不是类型名,不能用来声明变量 ...}办法是转调一个泛型方法,让编译器在调用时把 ? 变成具体的 T:
public static void swap(Pair<?> p) { swapHelper(p);}
private static <T> void swapHelper(Pair<T> p) { T t = p.getFirst(); // ✅ 这里的 T 是具体的 p.setFirst(p.getSecond()); p.setSecond(t);}认得即可,实际写代码基本碰不到。
8.9 反射和泛型
框架作者的领域,整节认得即可。
8.9.1 泛型Class类
Class 是 JVM 为每个类型建的档案对象:Employee.class 里存着名字、父类、字段、方法。每个类型一份,所有实例共用。
String.class 的类型是 Class<String>,所以:
Class<String> c = String.class;String s = c.getDeclaredConstructor().newInstance(); // 直接返回 StringT 只是编译期标签:擦除后 newInstance() 返回 Object,编译器按 T 补一次强转。
8.9.2 使用 Class<T> 参数进行类型匹配
public static <T> T create(Class<T> c) throws Exception { return c.getDeclaredConstructor().newInstance();}
Employee e = create(Employee.class); // 返回 EmployeeEmployee.class 是 Class<Employee>,和形参 Class<T> 一对,T 就推成 Employee。这是 8.6.5「不能 new T()」的标准解法。
8.9.3 虚拟机中的泛型类型信息
擦除抹掉字段和方法上的 T,但 class 文件另存了一份「签名」,反射能读到:
class MyList extends ArrayList<String> { }
Type t = MyList.class.getGenericSuperclass(); // ArrayList<String>Spring、Jackson 靠它知道 List<User> 里装的是 User。
8.9.4 类型字面量
new TypeLiteral<List<String>>() { } 把泛型信息藏进匿名子类的父类签名,供框架取用。认得即可。
附录
泛型符号速查
| 写法 | 含义 |
|---|---|
Box<T> | 泛型类,类型参数 T |
new Box<>() | 菱形运算符,类型实参由编译器推导 |
<T> T foo(T x) | 泛型方法,<T> 在返回值前 |
<T extends Comparable<T>> | 上界限定,T 必须是 Comparable 的子类型 |
<T extends A & B> | 多重限定,类在前、接口在后 |
<? extends Employee> | 上界通配符,能读不能写 |
<? super Manager> | 下界通配符,能写不能读(读只得 Object) |
<?> | 无限定通配符,只能当 Object 用 |
| 类型擦除 | 编译后 T 变成 Object 或第一个限定类型 |
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!





