引言
本文会先讲讲泛型的作用以体现它的重要性,后面就会讲解我们应如何使用泛型,以及泛型具有什么性质特点。
最后,如果你能明确知晓下列内容,你就能说你差不多掌握 Java 泛型了,即
泛型的基础使用+PECS+擦除机制+不变性+限制清单
如果没有泛型会怎么样?
在 Java 中泛型的作用体现在以下几个维度:
- 类型安全
- 代码可读性
- 代码复用性
- 日常使用
一、类型安全
对于这个,最核心的就是在没有泛型的时候,集合、容器都只能存 Object 类型,编译期是无法在编译期发现类型错误的。
举个例子:
没有泛型的代码:Java1.4 之前
List list = new ArrayList();//什么类型都可以放进去
list.add("hello");
list.add(42); // 可以混入 Integer,编译通过
String s = (String) list.get(0); // OK
String s2 = (String) list.get(1); // ❌ ClassCastException 运行时崩溃!
有泛型之后
List<String> list = new ArrayList<>();//限定只允许放String类型数据
list.add("hello");
list.add(42); // ❌ 编译错误!在 IDE 中就标红了,存不进去
String s = list.get(0); // 并且不需要强制转型!
二、代码可读性
如果没有泛型,代码中会存在大量的强制类型转换和 Object 声明,导致代码意图很模糊,如
//对于这个示例:最终目的是获取到用户所有订单中的第一个订单。
//userId:用户ID orderList:用户订单列表
// ❌ 无泛型:意图模糊
Map userOrders = new HashMap();
userOrders.put(userId, orders);
List orderList = (List) userOrders.get(userId);
Order first = (Order) orderList.get(0);
// ✅ 有泛型:意图明确
Map<Long, List<Order>> userOrders = new HashMap<>();
userOrders.put(userId, orders);
Order first = userOrders.get(userId).get(0);
又比如:
//让人一看就猜到其大致功能可能是对字符串进行处理最终转换为一个整数
Function<String, Integer>
//让人能明白将来会返回一个Response
CompletableFuture<Response>
三、代码复用性
其核心问题便是如果不使用泛型,那么即使是同一个算法逻辑,对于不同类型也要多写几份几乎一样的代码。
而有泛型的话(所有格式问题后面会说):
泛型类:一个容器,适用所有类型
// 一份代码,适用任意类型 T
public class Result<T> {
private final T data;
private final String error;
public Result(T data) { this.data = data; this.error = null; }
public Result(String error) { this.data = null; this.error = error; }
public T getData() { return data; }
public boolean isSuccess() { return error == null; }
}
// 使用
Result<User> userResult = new Result<>(user);
Result<Order> orderResult = new Result<>(order);
Result<Integer> intResult = new Result<>(42);
泛型方法:算法写一次,到处用
// 一个排序方法,适用于任意 Comparable 类型
//这里的<T extends Comparable<T>> 表示T必须是实现了Comparable接口的类型,后面会讲
public static <T extends Comparable<T>> List<T> sortDescending(List<T> list) {
list.sort(Comparator.reverseOrder());
return list;
}
sortDescending(List.of(5, 2, 9, 1)); // 对整数排序
sortDescending(List.of("c", "a", "b")); // 对字符串排序
sortDescending(users); // 对 User(实现了 Comparable)排序
四、日常使用
在日常开发中,我们也是会经常使用到泛型的
集合操作
// 声明
List<Product> products = new ArrayList<>();
Map<Long, User> userCache = new HashMap<>();
Set<String> tags = new HashSet<>();
// 流式操作——泛型让链式调用类型连续
double avg = products.stream()
.filter(p -> p.getPrice() > 100) // Stream<Product>
.map(Product::getPrice) // Stream<Double>
.mapToDouble(Double::doubleValue) // DoubleStream
.average()
.orElse(0.0);
每一步的类型都由泛型推导,写链式调用时 IDE 能给出精确的自动补全——这正是泛型的功力。
Optional 避免 NPE(空指针异常)
// 没有 Optional 时
User user = userRepo.findById(id);
String name = user.getName(); // 可能是 NPE,谁也不知道
// 有 Optional<T> 时
Optional<User> optUser = userRepo.findById(id);
String name = optUser.map(User::getName).orElse("未知用户");
Optional<User> 的类型签名强迫你处理空值——不是靠自觉,是靠编译器。
Repository 模式(一种用来解耦业务逻辑与数据存储逻辑的设计模式)
// 一个泛型基类,所有 DAO 复用
public interface BaseRepository<T, ID> {
Optional<T> findById(ID id);
List<T> findAll();
T save(T entity);
void delete(T entity);
}
// 具体接口一行不写,直接继承
public interface UserRepository extends BaseRepository<User, Long> {
Optional<User> findByEmail(String email);
}
BaseRepository<User, Long> 自动把所有方法的 T 换成了 User,ID 换成了 Long——一劳永逸。
构建工具方法
// 类型安全的工具方法
public static <K, V> Map<K, V> zipToMap(List<K> keys, List<V> values) {
Map<K, V> map = new HashMap<>();
for (int i = 0; i < Math.min(keys.size(), values.size()); i++) {
map.put(keys.get(i), values.get(i));
}
return map;
}
Map<String, Integer> scores = zipToMap(students, grades);
// K 自动推导为 String,V 自动推导为 Integer
泛型的作用就讲到这,接下来开始讲解如何使用泛型
泛型的使用
该处将会讲以下内容:
- 泛型可以用来编写什么
- 泛型的类型参数
泛型可以用来编写什么?
在Java 中泛型可以用来编写类、接口、方法
泛型类
语法:
class ClassName<T1, T2, ...> {
// T1等在类体内可以当作已知类型使用
}
示例:
static class Box<T> {//一个静态的“盒子”泛型类
private T value;
public Box(T value) { this.value = value; }
public T get() { return value; }
public void set(T value) { this.value = value; }
}
Box<String> strBox = new Box<>("Hello");
Box<Integer> intBox = new Box<>(42);
泛型接口
示例:
interface Repo<T> {
void save(T item);
T findById(int id);
List<T> findAll();
void delete(int id);
}
// 实现类保持泛型
static class MemoryRepo<T> implements Repo<T> {
private final Map<Integer, T> storage = new LinkedHashMap<>();
private int nextId = 1;
// 实现方法...
}
泛型接口的两种实现方式:
- 方式A:实现时指定具体类型 ——
class StringRepo implements Repo<String> - 方式B:实现类继续保持泛型 ——
class MemoryRepo<T> implements Repo<T>
泛型方法:
语法:
修饰符 <T> 返回值 方法名(T 参数, ...) { ... }
// ↑ 类型参数声明(必须在返回值之前)
示例:
// 泛型方法:交换数组元素
static <T> void swap(T[] array, int i, int j) {
T temp = array[i];
array[i] = array[j];
array[j] = temp;
}
// 泛型方法:查找最大值(有界类型参数)
static <T extends Comparable<T>> T findMax(T[] array) {
if (array == null || array.length == 0) return null;
T max = array[0];
for (int i = 1; i < array.length; i++) {
if (array[i].compareTo(max) > 0) max = array[i];
}
return max;
}
在泛型中常用到的几种通配符
|
常用符号 |
习惯含义 |
|
|
Type(任意类型) |
|
|
Element(集合中的元素类型,如 |
|
|
Key(Map 的键类型) |
|
|
Value(Map 的值类型) |
|
|
第二、第三个类型参数(用于多个泛型) |
|
|
通配符(未知类型,不是类型参数,不能用于声明) |
但实际上呢?上面的几种统配符其实没有本质的区别,都是人所约定俗成的意义,也就是说
public class pair<T>{} 和 public class pair<E>{} 没有任何区别
public class pair<U,V>{} 和 public class pair<K,V> 也没有区别
甚至你可以选个你喜欢的字母来创建自定义泛型类,如
public class pair<T,s,z>{} 这也是可行的,数量可以多个,可大写可小写
但其中比较值得注意的就是“?”通配符,它的作用及与 T 这类通配符的区别还是比较难以理解的,后面会讲。
泛型的两种用来限定参数类型的统配符:extends&super
两者有什么区别呢?
其中 extends 叫做 上界通配符
List<? extends T> list; //表示这个集合里装的是 T或T的某个未知子类。
//同时对于这个集合来说只能读不能写
那为什么只能读不能写,并且只能读出 T 及 T 的子类类型数据?
这是因为编译期不知道"?"具体是哪个子类,如
List<? extends Number> list = new ArrayList<Integer>();
// ❌ 编译错误!编译器想:"万一它是 List<Double> 呢?放 Integer 不安全"
list.add(Integer.valueOf(1));
list.add(Double.valueOf(1.0));
list.add(new Number() { ... });
// ❌ 同样不行,因为 ? extends Number 可能是比 Number 更窄的子类
// ✅ 唯一能放入的是 null
list.add(null);
// ✅ 读是安全的!不管里面是什么,出来至少是 Number
Number n = list.get(0); // 没问题
Object o = list.get(0); // 也没问题
super 叫做上界通配符
List<? super T> list; //这个集合里装的是 T或T的某个未知父类
//并且只能写不能读
它又为什么能写不能读,而且只能放入 T 以及 T 子类类型数据?
因为不知道"?"具体是 Integer 的哪一个父类
List<? super Integer> list = new ArrayList<Number>();
// ✅ 写入安全!不管容器实际是 Integer/Number/Object,都能接受 Integer
list.add(Integer.valueOf(1));
list.add(10); // 自动装箱也行
// ❌ 不能放父类对象 — "万一它是 List<Integer> 呢?装不下 Number"
list.add(new Number() { ... }); // 编译错误!
// ❌ 读取只知道是 Object
Integer i = list.get(0); // 编译错误!"万一是 List<Object>,取出来的是 Object"
Number n = list.get(0); // 同上
Object o = list.get(0); // ✅ 只有这个保证正确
接下来就要提到泛型的 PECS 原则了
泛型的 PECS 原则
一句话说明就是:为了最大限度地提高 API 的灵活性,当你使用通配符类型时,生产者(Producer,生产者/只读/获取)用 extends,消费者(Consumer,消费者/只写/放入)用 super。
举个例子:比如 Java 中 Collections.copy 签名
//负责将src集合中的数据复制dest集合中
//src只读 dest只写
static <T> void copy(List<? extends T> src, List<? super T> dest) {
for (T item : src) dest.add(item);
}
那为什么Producer 要用 extends?(读取数据)
那这样好了,如果你不使用 extends,反而使用 super 会发生什么?如 List<? super Number>
这样的话,从集合里 get() 出来的元素只能赋给 Object,因为你不知道它到底是 List<Number> 还是 List<Object>,这就丢失了 Number 的具体方法。
而使用List<? extends Number>的话,编译器知道,里面的元素一定是 Number 的子类,所以取出来可以安全地赋值给 Number 类型的引用。
那为什么Consumer 要用 super?(写入数据)
同上,如果你使用List<? extends Number>,你只能往里面放 null,因为编译器不知道底层究竟是 List<Double> 还是 List<Integer>,放入任何具体数字类型都可能引发类型冲突。
而使用List<? super Number>的话,编译器知道,这个列表的下界是 Number,所以Number 及其子类(如 Integer, Double)都可以向上转型放入其中,这是绝对类型安全的。
有关泛型的特点与性质
关于泛型的实现原理 ”类型擦除“
Java 中泛型的实现所依赖的核心机制就是类型擦除,其机制如下:
- 编译器把泛型类型参数替换为上限类型(通常是
Object) - 在需要的地方插入强制类型转换
- 运行时 JVM 不知道泛型的存在
具体擦除规则:
- 无界类型
<T>→ 擦除为Object - 有界类型
<T extends X>→ 擦除为X List<String>→ 擦除为ListList<String>和List<Integer>在运行时是同一个Class对象
如:
List<String> strList = new ArrayList<>();
List<Integer> intList = new ArrayList<>();
// 下面的代码会返回true!
System.out.println(strList.getClass() == intList.getClass());
泛型的不变性
什么是泛型的不变性呢?举个例子来说就是:
String 是 Object 的子类,但 List<String> 不是 List<Object> 的子类
为什么呢?同样,我们假设List<String> 是 List<Object> 的子类
List<String> ss = new ArrayList<>();
List<Object> objs = ss; // 假设合法
objs.add(42); // 往 List<String> 中放了 Integer!
String s = ss.get(0);// 就会触发ClassCastException,所以显然上面行为是不合法的
泛型的限制清单
泛型的限制清单其实就是泛型所不能进行的操作,纯靠记是难以记住的,不时的看一看,然后在实践中有意识的想起这些,然后记住就行了。
|
限制 |
说明 | |
|
1 |
❌ 基本类型不能作为类型实参 |
|
|
2 |
❌ 不能创建泛型数组 |
|
|
3 |
❌ 不能实例化类型参数 |
|
|
4 |
❌ 不能创建参数化类型的数组 |
|
|
5 |
❌ 静态字段不能用类型参数 |
|
|
6 |
❌ 泛型类不能继承 |
|
|
7 |
❌ |
|
|
8 |
❌ 不能重载擦除后签名相同的方法 |
两个方法冲突 |
|
9 |
❌ |
|
|
10 |
⚠️ 类型擦除导致运行时无泛型信息 |
|
|
11 |
⚠️ 不能创建泛型枚举 |
|
|
12 |
⚠️ 可变参数 + 泛型 有堆污染风险 |
用 |
"?"和"T"这类通配符的区别?
这个也算是泛型中比较难以理解的一个点了,总之一句话说就是:T 是给类型起了个名字,方便在多个地方引用同一个类型;? 是匿名的,表示"我不在乎是什么类型,我只用它一次"
同时,如果需要类型之间互相约束(比如参数和返回值同类型),必须用 T;如果只是接收一个泛型参数且不关心具体类型,用 ? 更简洁。
|
|
| |
|
出现位置 |
泛型定义时 |
泛型使用时 |
|
目的 |
声明一个类型变量,后续可以引用它 |
表示"我不知道具体类型,也不关心" |
|
能否被引用 |
✅ 可以在方法体/类体内使用 |
❌ 不能引用,它是匿名的 |
这样说可能还是难以理解,还是用具体的例子来说明吧
使用 T 的场景:定义泛型类/方法
// T 在定义时出现 —— 我们需要 "记住" 这个类型,后续要用
public class Box<T> { // 定义泛型类
private T item; // 用 T 声明字段
public void set(T item) { ... } // 用 T 声明参数
public T get() { return item; } // 用 T 声明返回值
}
// 泛型方法同理
public static <T> T getFirst(List<T> list) { // <T> 声明类型参数,返回值 T 引用它
return list.get(0);
}
关键点:<T> 先声明,然后 T 就可以在多个地方互相约束——参数类型、返回值类型、字段类型必须是同一个 T。
? 的场景:只消费/只生产,不关心具体类型
// 我只想遍历,不关心 List 里到底是什么
public static void printAll(List<?> list) {
for (Object obj : list) {
System.out.println(obj);
}
}
// 我想读取 Number 或其子类的 List,但不关心具体是哪种 Number
public static double sum(List<? extends Number> list) {
double total = 0;
for (Number n : list) {
total += n.doubleValue();
}
return total;
}
又比如在 PECS 原则中,
- 如果你从集合中读取(生产数据)→ 用
? extends T - 如果你向集合中写入(消费数据)→ 用
? super T - 如果既读又写 → 必须用确定的类型参数
T
// 经典例子:Collections.copy
public static <T> void copy(List<? super T> dest, // 写入目标(消费者)
List<? extends T> src) { // 读取来源(生产者)
for (T item : src) {
dest.add(item);
}
}
通过上面这些,应该大致能理解?和 T 这类通配符的区别了吧
最后我想说,关于泛型的学习,在了解基础概念之后,如何能更进一步的理解和使用泛型,一个很好的方法就是去看 Java 容器的设计源码。通过阅读理解源码,就可以去模仿源码的编写,学习源码又是如何巧妙的使用泛型的,毕竟大多数开发者的成长过程就是从模仿开始的。
以上就是本篇文章的所有内容,其实关于泛型还有挺多没讲的,但碍于篇幅以及本人的能力便没有讲述。以上内容或有错误,欢迎各位对本文进行批评指正,若有时间我一定会对其进行修正。
文章创作不易,如对您有帮助,不妨点赞收藏支持一下吧!
1203

被折叠的 条评论
为什么被折叠?



