第 13 章 · 泛型:类型安全的复用
本章目标:理解泛型解决的类型安全与代码复用问题;能定义泛型类、泛型接口、泛型方法;掌握类型参数界限(extends/super);理解通配符与 PECS 原则;理解类型擦除及其限制;为 toolkit-demo 实现通用的 Result<T>、PageResult<T> 与 Repository<T, ID>;对照 python-dev 的 TypeVar 与 Generic。
学时建议:4~5 小时(含 1.5 小时跟练)
前置:完成 ch01~ch12;已学 ch08 集合 与 ch09 Stream。
13.1 场景说明:为什么还要学泛型?
ch08 中你写过 List<String>、Map<String, Integer>——<> 里就是泛型。但当 toolkit-demo 需要「既能装商品又能装订单的分页结果」「既能查商品又能查用户的仓储接口」时,每个类型各写一遍显然不可维护:
没有泛型:ProductPage / OrderPage / UserPage …… 三份几乎相同的代码
有了泛型:PageResult<T> 一份代码,编译器保证类型安全
| 不用泛型 | 用泛型 |
|---|---|
List 裸类型,取出要强转 | List<Product> 取出即是 Product |
强转错误运行时才炸(ClassCastException) | 类型错误编译期拦截 |
| 每类一份重复代码 | 一份代码服务所有类型 |
说明:本章所有路径、域名、密钥均为教学虚构,练习仍在 ~/learn-java/toolkit-demo 中进行。
13.2 泛型类与泛型接口
类型参数写在类名后的 <> 中,惯例用单个大写字母:T(类型)、E(元素)、K/V(键值)、R(结果)。
package demo.generic;
/** 通用二元组 — 演示多类型参数 */
public class Pair<K, V> {
private final K key;
private final V value;
public Pair(K key, V value) {
this.key = key;
this.value = value;
}
public K key() { return key; }
public V value() { return value; }
}
使用处由调用方决定具体类型,编译器全程检查:
Pair<String, Double> price = new Pair<>("BK-001", 59.9);
Double p = price.value(); // 无需强转
// Integer bad = price.value(); // 编译错误:类型不匹配
泛型接口(衔接 ch06 接口与 ch11 Repository):
package demo.generic;
import java.util.List;
import java.util.Optional;
/** 通用仓储契约 — 任何实体类型都能复用 */
public interface Repository<T, ID> {
void save(T entity);
Optional<T> findById(ID id);
List<T> findAll();
boolean deleteById(ID id);
}
| 要点 | 说明 |
|---|---|
| 类型参数是占位符 | 编译时用实际类型代入检查 |
菱形 <> | Java 7+ 右侧可省略类型,由左侧推断 |
| 裸类型(raw type) | Repository 不带参数等于放弃检查,禁止在新代码使用 |
13.3 泛型方法
方法级泛型:类型参数声明在返回类型之前的 <> 里,与类是否泛型无关。
package demo.generic;
import java.util.List;
public class ListUtils {
/** 取列表第一个元素;空列表返回 null(教学简化) */
public static <T> T first(List<T> list) {
return list.isEmpty() ? null : list.get(0);
}
/** 交换数组两个位置 — 数组也能用泛型方法 */
public static <T> void swap(T[] arr, int i, int j) {
T tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
}
调用时类型由实参推断,通常无需显式指定:
String s = ListUtils.first(List.of("a", "b")); // T 推断为 String
Integer n = ListUtils.first(List.of(1, 2, 3)); // T 推断为 Integer
13.4 类型参数界限:bounded type
限制类型参数必须满足某个上界,才能在泛型代码里调用上界的方法:
package demo.generic;
import java.util.List;
public class Stats {
/** T 必须是 Number 的子类,才能调用 doubleValue() */
public static <T extends Number> double sum(List<T> list) {
double total = 0;
for (T item : list) {
total += item.doubleValue();
}
return total;
}
/** T 必须可比较,才能找最大值 */
public static <T extends Comparable<T>> T max(List<T> list) {
if (list.isEmpty()) {
throw new IllegalArgumentException("空列表无最大值");
}
T best = list.get(0);
for (T item : list) {
if (item.compareTo(best) > 0) {
best = item;
}
}
return best;
}
}
| 界限写法 | 含义 |
|---|---|
<T extends Number> | 上界:T 是 Number 或其子类(类/接口都用 extends) |
<T extends Comparable<T>> | 自限定:T 能与 T 比较,常见于排序场景 |
<T extends A & B> | 多上界:同时是 A 的子类且实现 B(类只能一个且在前) |
13.5 通配符与 PECS 原则
List<Number> 与 List<Integer> 没有父子关系——List<Integer> 不能赋值给 List<Number>(否则能往 Integer 列表里塞 Double,破坏安全)。需要「某种类型的列表」时用通配符 ?:
// 生产者:从列表读出 Number 用 —— 上界通配符
static double total(List<? extends Number> nums) {
double sum = 0;
for (Number n : nums) {
sum += n.doubleValue();
}
return sum;
}
// 消费者:往列表写入 Integer —— 下界通配符
static void fillDefaults(List<? super Integer> target) {
target.add(0);
target.add(100);
}
PECS 口诀(Effective Java):Producer Extends,Consumer Super。
| 角色 | 通配符 | 能做的 | 不能做的 |
|---|---|---|---|
| 生产者(只读出来) | ? extends T | 读出为 T | 写入(null 除外) |
| 消费者(只写进去) | ? super T | 写入 T 或其子类 | 读出具体类型(只能当 Object) |
回忆 ch09:Stream.map的签名map(Function<? super T, ? extends R> mapper)正是 PECS 的教科书应用——函数消费 T、生产 R。
13.6 类型擦除与限制
泛型只在编译期存在,编译后类型参数被「擦除」为上限(通常是 Object)。这带来一组必须记住的限制:
| 限制 | 原因 | 替代方案 |
|---|---|---|
不能 new T() | 运行时不知道 T 是什么 | 传入 Supplier<T> 或 Class<T> |
不能 T.class / new T[10] | 同上 | List<T> 替代泛型数组 |
不能 instanceof List<String> | 运行时只剩 List | instanceof List<?> 再逐个检查元素 |
| 静态成员不能用 T | 静态属于类而非实例 | 静态方法自己声明 <T> |
| 基本类型不能作参数 | 擦除后是 Object | 用包装类 Integer 而非 int |
擦除的实际效果(编译前后对比):
// 源码
class Box<T> { private T value; }
// 编译后(等效)
class Box { private Object value; }
面试高频:List<String> 和 List<Integer> 在运行时是同一个类,反射 getClass() 结果相同——这就是擦除。