06泛型

4747 字
24 分钟
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 处理 → 运行时 ClassCastException

a 明明声明的是 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警告#

泛型可变参数本质是数组,同样有问题:

@SafeVarargs
public 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 { } // ❌ 泛型类不能继承 Throwable
try { ... } 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 TT只能 null
? super TObjectT 及子类

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(); // 直接返回 String

T 只是编译期标签:擦除后 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); // 返回 Employee

Employee.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 或第一个限定类型

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
06泛型
https://hecloud.top/posts/tech/java-se/06泛型/
作者
贺小云
发布于
2026-11-12
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
贺小云
热爱技术与折腾的博客 Dancing with uncertainty. 与不确定性共舞
公告
出于成本考虑,贺云已全面转型Serverless架构
分类
标签
碎碎念
站点统计
文章
97
分类
5
标签
45
总字数
117,395
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
GitHub Actions
博客版本
Firefly v6.16.5
文章许可
CC BY-NC-SA 4.0