做Java开发的朋友应该都有体会:泛型这东西入门容易,真要用到项目里------比如写个通用工具类、处理各种类型的集合时,动不动就踩坑。要么通配符用错编译报错,要么类型擦除导致运行时抛ClassCastException,要么想创建个泛型数组直接编译不过。
今天这篇文章,我不跟大家聊纯概念,就结合我实际开发中踩过的坑、写过的通用数据校验工具类,把泛型通配符(?、? extends T、? super T)的用法、类型擦除的坑,还有避坑的实操技巧,都讲清楚。保证都是实战干货,代码能直接跑,看完就能用到自己的项目里。
一、泛型通配符:3种写法的实战区别(PECS原则真的很好用)
泛型通配符的核心就是解决"不同类型集合怎么通用处理"的问题,很多人搞不懂 ?、? extends T、? super T的区别,其实记住PECS原则(生产者用Extends,消费者用Super)就够了。我结合自己写过的工具方法,给大家讲透每个通配符的用法和坑。
1. 无界通配符 ?:只做通用读取,别想着写数据
无界通配符?就是匹配任意类型的泛型,我一般只用在"只读取不写入"的通用工具里,比如打印任意集合、判断集合是否为空。
我当初踩过一个坑:用?定义的List,想往里面加个字符串,结果直接编译报错。后来才明白,编译器根本不知道这个List具体存的是啥类型,为了保证类型安全,除了null,啥都不让加。
实战场景:通用集合打印工具
需求很简单:写个方法,不管传过来的是List、List还是List<自定义对象>,都能打印里面的元素。
java
import java.util.List;
/**
* 无界通配符实战:通用集合打印工具
* 快速打印集合,不用重复写循环
*/
public class WildcardDemo {
// 无界通配符?,匹配任意类型的List
public static void printList(List<?> list) {
if (list == null || list.isEmpty()) {
System.out.println("集合为空");
return;
}
for (Object obj : list) {
// 只能读成Object类型,因为不知道具体是啥类型
System.out.println("元素值:" + obj);
}
// 这里是我踩过的坑:想加个字符串,编译直接报错
// list.add("test"); // 编译错误:没法确定list的具体类型,写入会乱套
// 唯一能加的只有null,因为null是所有类型的子类
list.add(null);
}
public static void main(String[] args) {
List<String> strList = List.of("Java", "泛型", "通配符");
List<Integer> intList = List.of(1, 2, 3);
printList(strList); // 正常打印字符串列表
printList(intList); // 正常打印整数列表
}
}
为啥这么写?
无界通配符的核心就是"通用只读",比如打印、统计长度、判空这些操作,不用关心集合里具体存的是啥,用?就够了。但千万别想着往里面写数据,除了null,写啥都报错。
2. 上界通配符 ? extends T:只读取,不写入(生产者场景)
? extends T的意思是"匹配T或者T的子类",我一般用在"从集合里读数据"的场景,比如给Integer、Long、Double这些数值类型的集合求和------这些集合都是"生产"数值的,所以用extends。
我之前犯过一个错:用? extends Number的List,想往里加个Integer,结果编译报错。现在想通了,编译器不知道这个List是存Integer、Long还是Double,要是加了Integer,万一原本是Long的List,不就乱套了?
实战场景:数值集合求和工具
需求:写个方法,能给Integer、Long、Double的List求和,返回double类型结果。
java
import java.util.List;
/**
* 上界通配符实战:数值集合求和
* 做报表统计时经常用这个方法,不用给每种数值类型都写一遍求和
*/
public class UpperBoundDemo {
// 上界通配符:匹配Number或其子类(Integer、Long、Double都算)
public static double sum(List<? extends Number> numberList) {
double total = 0.0;
for (Number num : numberList) {
// 读数据:所有子类都能转成Number,安全
total += num.doubleValue();
}
// 踩坑点:想加Integer,编译报错
// numberList.add(10); // 编译器不知道list是存Integer还是Long,不敢让加
// 就算加Number也不行
// numberList.add(10.0); // 同样报错
return total;
}
public static void main(String[] args) {
List<Integer> intList = List.of(1, 2, 3);
List<Double> doubleList = List.of(1.5, 2.5);
System.out.println("整数列表求和:" + sum(intList)); // 输出6.0
System.out.println("小数列表求和:" + sum(doubleList)); // 输出4.0
}
}
核心要点 :
? extends T就是"生产者"------集合里的元素都是T的子类,能安全读成T类型,但绝对不能写。比如求和、导出数据、遍历取值这些场景,用这个通配符准没错。
3. 下界通配符 ? super T:只写入,读取只能拿Object(消费者场景)
? super T的意思是"匹配T或者T的父类",我一般用在"往集合里写数据"的场景,比如批量往List里插Integer------不管这个List是List、List还是List,都能插,因为这些都是Integer的父类。
这里也有个坑:用? super T的List读数据时,只能读成Object类型,想转成T类型会报错。因为编译器不知道这个List是存T的哪个父类,没法确定类型。
实战场景:数据批量插入工具
需求:写个方法,能往任意"能存Integer"的集合里批量插整数。
java
import java.util.ArrayList;
import java.util.List;
/**
* 下界通配符实战:数据批量插入
* 用这个方法往不同类型的集合里插数据
*/
public class LowerBoundDemo {
// 下界通配符:匹配Integer或其父类(Number、Object)
public static void batchAddInteger(List<? super Integer> list, int count) {
for (int i = 1; i <= count; i++) {
// 写数据:Integer能存到任意父类集合里,安全
list.add(i);
}
// 读数据:只能拿到Object类型,这是坑点
for (Object obj : list) {
System.out.println("插入的元素:" + obj);
}
// 我踩过的坑:想直接读成Integer,编译报错
// for (Integer num : list) { // 编译错误:list可能是Number或Object类型,没法转Integer
// System.out.println(num);
// }
}
public static void main(String[] args) {
// 场景1:List<Integer>(直接存Integer)
List<Integer> intList = new ArrayList<>();
batchAddInteger(intList, 3); // 插入1、2、3
// 场景2:List<Number>(Integer的父类)
List<Number> numList = new ArrayList<>();
batchAddInteger(numList, 2); // 插入1、2
// 场景3:List<Object>(Integer的顶级父类)
List<Object> objList = new ArrayList<>();
batchAddInteger(objList, 1); // 插入1
}
}
核心要点 :
? super T就是"消费者"------集合能接收T类型的数据,所以写数据绝对安全,但读数据只能拿Object。比如批量插入、批量赋值、数据入库这些场景,就用这个通配符。
4. PECS原则速查表
| 通配符类型 | 能干嘛 | 我常用的场景 | 读写注意点 |
|---|---|---|---|
| ? | 匹配任意类型 | 打印、判空、统计长度 | 读:只能拿Object;写:只能加null |
| ? extends T | 匹配T或其子类 | 求和、导出数据、遍历取值 | 读:能转T;写:啥都不能加(除了null) |
| ? super T | 匹配T或其父类 | 批量插入、批量赋值、数据入库 | 读:只能拿Object;写:能加T类型 |
二、类型擦除:泛型最坑的地方
很多人不知道,泛型其实是Java的"语法糖"------编译的时候,编译器会把所有泛型信息都擦除掉,换成Object或者限定类型。这就导致了一堆坑,我给大家列几个我踩过的高频坑,一个个说怎么解决。
1. 先搞懂类型擦除是啥
编译的时候,编译器会把泛型代码改成"原始代码":
- List会被擦成List,读数据时自动加(String)强制转换;
- List<? extends Number>会被擦成List;
- 泛型类、泛型方法里的都会被擦成Object(有上限就擦成上限类型)。
举个例子,咱们写的泛型代码:
java
// 编译前
List<String> strList = new ArrayList<>();
strList.add("Java");
String str = strList.get(0);
// 编译后(编译器自动改的)
List strList = new ArrayList();
strList.add("Java");
String str = (String) strList.get(0); // 自动加了强制转换
看出来了吧?泛型只在编译期管类型安全,运行时JVM根本不知道泛型是啥,这就是所有坑的根源。
2. 我踩过的5个高频坑(附解决方案)
坑1:泛型数组创建失败(最常见的坑)
我当初想创建一个List[]数组,结果编译直接报错;后来想强行转类型,运行时又抛ClassCastException。
踩坑代码:
java
/**
* 我踩过的坑:泛型数组创建失败
*/
public class TypeErasureDemo1 {
public static void main(String[] args) {
// 坑1:直接创建泛型数组,编译报错
// List<String>[] strArr = new List<String>[10]; // 编译错误:Generic array creation
// 坑2:强行转类型,运行时报错
List<String>[] strArr2 = (List<String>[]) new List[10];
strArr2[0] = new ArrayList<String>();
// 运行时坑:数组实际是List[],能存任意List
List<Integer> intList = new ArrayList<>();
intList.add(123);
strArr2[0] = (List<String>) intList; // 编译过了,运行时抛ClassCastException
}
}
为什么?
数组是"协变"的(比如String[]是Object[]的子类),但泛型是"不变"的(List不是List的子类)。类型擦除后,JVM分不清List[]和List[],所以编译器直接不让创建泛型数组。
我的解决方案 :
别用数组,改用List<List>!这是我现在最常用的办法,简单又安全。
java
/**
* 我的解决方案:用List代替泛型数组
*/
public class TypeErasureDemo1Fix {
public static void main(String[] args) {
// 用List<List<String>>代替List<String>[]
List<List<String>> strListContainer = new ArrayList<>();
List<String> strList = new ArrayList<>();
strList.add("Java");
strListContainer.add(strList);
// 类型安全:想存List<Integer>直接编译报错,不会有运行时问题
// List<Integer> intList = List.of(1);
// strListContainer.add(intList); // 编译错误,类型对不上
}
}
坑2:用instanceof判断泛型类型
我当初想判断一个List是不是List,写了instanceof List,结果编译报错。
踩坑代码:
java
/**
* 我踩过的坑:instanceof判断泛型类型
*/
public class TypeErasureDemo2 {
public static void main(String[] args) {
List<String> strList = new ArrayList<>();
// 坑:instanceof没法判断泛型类型,编译报错
// if (strList instanceof List<String>) { // 编译错误
// 看似能写,但没意义------只能判断是不是List,没法判断泛型
if (strList instanceof List<?>) {
System.out.println("是List,但不知道存的是啥");
}
}
}
为什么?
运行时泛型信息已经被擦除了,JVM只知道是List,不知道是List还是List,所以instanceof根本判断不了。
我的解决方案 :
传个Class类型令牌,遍历集合判断每个元素的类型。
java
import java.util.List;
/**
* 我的解决方案:用类型令牌判断元素类型
*/
public class TypeErasureDemo2Fix {
// 我项目里的通用方法:判断List里的元素是不是指定类型
public static <T> boolean isListOfType(List<?> list, Class<T> type) {
if (list.isEmpty()) {
return false; // 空集合没法判断
}
for (Object obj : list) {
if (!type.isInstance(obj)) {
return false;
}
}
return true;
}
public static void main(String[] args) {
List<String> strList = List.of("Java", "泛型");
List<Integer> intList = List.of(1, 2);
// 用类型令牌判断,靠谱!
System.out.println("是不是String列表:" + isListOfType(strList, String.class)); // true
System.out.println("是不是String列表:" + isListOfType(intList, String.class)); // false
}
}
坑3:泛型类里定义静态泛型变量
我当初在泛型类里写了个static T staticValue,结果编译直接报错,后来才知道静态变量和泛型实例没关系。
踩坑代码:
java
/**
* 我踩过的坑:泛型类里的静态变量不能用泛型
*/
public class TypeErasureDemo3<T> {
// 坑:静态变量用T,编译报错
// private static T staticValue; // Compile error: Cannot make a static reference to the non-static type T
// 勉强能写,但没意义
private static List<?> staticList = new ArrayList<>();
}
为什么?
静态变量属于"类",不是属于"实例"。比如我创建TypeErasureDemo3和TypeErasureDemo3,这两个实例共享同一个静态变量,编译器没法给静态变量绑定具体的T类型。
我的解决方案 :
静态方法单独定义泛型参数,别用类的泛型参数;静态变量要么用具体类型,要么用?。
java
import java.util.ArrayList;
import java.util.List;
/**
* 我的解决方案:静态方法自己定义泛型参数
*/
public class TypeErasureDemo3Fix {
// 静态变量:用具体类型,别用泛型
private static List<String> staticStrList = new ArrayList<>();
// 静态泛型方法:自己定义<T>,和类无关
public static <T> void addElement(List<T> list, T element) {
list.add(element);
}
public static void main(String[] args) {
List<String> strList = new ArrayList<>();
List<Integer> intList = new ArrayList<>();
// 静态方法能支持不同类型,好用!
addElement(strList, "Java");
addElement(intList, 123);
System.out.println(strList); // [Java]
System.out.println(intList); // [123]
}
}
坑4:try-catch捕获泛型异常
我当初想自定义一个泛型异常,然后用catch捕获GenericException,结果编译报错。
踩坑代码:
java
/**
* 我踩过的坑:捕获泛型异常
*/
public class TypeErasureDemo4 {
// 自定义泛型异常(编译不报错,但用不了)
static class GenericException<T> extends Exception {
private T errorData;
public GenericException(T errorData) {
this.errorData = errorData;
}
}
public static void main(String[] args) {
try {
throw new GenericException<>("测试异常");
} catch (GenericException<String> e) { // 编译错误:不让捕泛型异常
e.printStackTrace();
}
}
}
为什么?
异常处理是运行时的事,类型擦除后,GenericException和GenericException都变成了GenericException,JVM分不清,所以编译器直接不让捕。
我的解决方案 :
别定义泛型异常,改用Object存错误数据,然后写个泛型方法取数据。
java
/**
* 我的解决方案:非泛型异常+泛型getter
*/
public class TypeErasureDemo4Fix {
// 非泛型异常,用Object存任意类型数据
static class DataException extends Exception {
private Object errorData;
public DataException(Object errorData) {
this.errorData = errorData;
}
// 泛型方法:安全取数据,自己控制类型转换
public <T> T getErrorData(Class<T> type) {
if (type.isInstance(errorData)) {
return type.cast(errorData);
}
throw new ClassCastException("类型对不上");
}
}
public static void main(String[] args) {
try {
throw new DataException("字符串错误");
// throw new DataException(12345); // 也能传整数
} catch (DataException e) {
// 安全取数据,不会乱转
String errorMsg = e.getErrorData(String.class);
System.out.println("错误信息:" + errorMsg);
}
}
}
坑5:泛型方法重载
我当初写了两个processData方法,一个接List,一个接List,结果编译报错,说方法签名重复。
踩坑代码:
java
/**
* 我踩过的坑:泛型方法重载冲突
*/
public class TypeErasureDemo5 {
// 方法1:处理String列表
public static void processData(List<String> list) {
System.out.println("处理字符串");
}
// 方法2:处理Integer列表,编译报错
// public static void processData(List<Integer> list) { // 编译错误:签名擦除后一样
// System.out.println("处理整数");
// }
}
为什么?
类型擦除后,List和List都变成了List,两个方法的签名都是processData(List),编译器分不清,所以不让重载。
我的解决方案 :
要么加个Class参数区分签名,要么直接改方法名。我一般加类型令牌,不用改方法名。
java
import java.util.List;
/**
* 我的解决方案:加类型令牌区分重载
*/
public class TypeErasureDemo5Fix {
// 通用方法:加类型令牌,判断类型后处理
public static <T> void processData(List<T> list, Class<T> type) {
if (type == String.class) {
System.out.println("处理字符串列表:" + list);
} else if (type == Integer.class) {
System.out.println("处理整数列表:" + list);
} else {
System.out.println("处理其他类型");
}
}
public static void main(String[] args) {
List<String> strList = List.of("Java", "泛型");
List<Integer> intList = List.of(1, 2);
// 传类型令牌,就能区分了
processData(strList, String.class);
processData(intList, Integer.class);
}
}
三、实战案例:通用数据校验工具类
光说不练假把式,我把上面的知识点都揉进一个"通用数据校验工具类"里,这是我实际项目里用来校验各种数据的,能避开前面说的所有坑,大家可以直接拿去改改用。
1. 需求说明
我做的这个工具类,要实现这几个功能:
- 校验任意类型的List里的元素是否符合规则(比如字符串非空、数值大于0);
- 批量把符合规则的数据写到另一个集合里;
- 能获取校验失败的数据,还能抛出带错误数据的异常;
- 避开泛型的各种坑,保证类型安全。
2. 完整代码
java
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Predicate;
/**
* 通用数据校验工具类
* 功能:校验任意类型数据、批量写入有效数据、返回无效数据、抛出带错误信息的异常
* 避坑点:通配符用法、类型擦除、静态泛型、异常处理
*/
public class GenericDataValidator<T> {
// 静态常量:不用泛型,避免静态泛型坑
private static final String DEFAULT_ERROR_MSG = "数据校验失败";
// 类型令牌:解决类型擦除后没法判断类型的问题
private final Class<T> typeToken;
// 构造方法:传入类型令牌,必须的!
public GenericDataValidator(Class<T> typeToken) {
this.typeToken = typeToken;
}
/**
* 校验所有元素是否符合规则(上界通配符:只读取,生产者)
* @param dataList 待校验集合(T或T的子类)
* @param rule 校验规则(比如字符串非空、数值大于0)
* @return true=全部符合,false=有不符合的
*/
public boolean validateAll(List<? extends T> dataList, Predicate<T> rule) {
if (dataList == null || dataList.isEmpty()) {
return true;
}
for (T data : dataList) {
// 先判断类型对不对,避免类型擦除导致的转换异常
if (!typeToken.isInstance(data)) {
throw new ClassCastException("集合里有不是" + typeToken.getName() + "类型的数据");
}
// 再校验规则
if (!rule.test(data)) {
return false;
}
}
return true;
}
/**
* 批量写入有效数据(下界通配符:只写入,消费者)
* @param targetList 目标集合(T或T的父类)
* @param sourceList 源数据集合
* @param rule 校验规则
* @return 成功写入的数量
*/
public int addValidData(List<? super T> targetList, List<? extends T> sourceList, Predicate<T> rule) {
// 先判空,避免空指针
Objects.requireNonNull(targetList, "目标集合不能为null");
Objects.requireNonNull(sourceList, "源数据集合不能为null");
int successCount = 0;
for (T data : sourceList) {
if (rule.test(data)) {
targetList.add(data); // 下界通配符,写入安全
successCount++;
}
}
return successCount;
}
/**
* 获取校验失败的数据(静态泛型方法:自己定义<T>,避开静态泛型坑)
* @param dataList 待校验集合
* @param rule 校验规则
* @param <T> 数据类型
* @return 失败的数据列表
*/
public static <T> List<T> getInvalidData(List<? extends T> dataList, Predicate<T> rule) {
List<T> invalidList = new ArrayList<>();
if (dataList == null || dataList.isEmpty()) {
return invalidList;
}
for (T data : dataList) {
if (!rule.test(data)) {
invalidList.add(data);
}
}
return invalidList;
}
/**
* 自定义异常:非泛型,用Object存错误数据(避开泛型异常坑)
*/
public static class ValidationException extends RuntimeException {
private final Object invalidData; // 存任意类型的错误数据
public ValidationException(String message, Object invalidData) {
super(message);
this.invalidData = invalidData;
}
// 泛型方法:安全获取错误数据
public <E> E getInvalidData(Class<E> type) {
if (type.isInstance(invalidData)) {
return type.cast(invalidData);
}
throw new ClassCastException("错误数据类型不对,想要:" + type.getName());
}
}
/**
* 严格校验:失败就抛异常
*/
public void strictValidate(List<? extends T> dataList, Predicate<T> rule) {
List<T> invalidData = getInvalidData(dataList, rule);
if (!invalidData.isEmpty()) {
// 抛非泛型异常,带错误数据
throw new ValidationException(DEFAULT_ERROR_MSG, invalidData.get(0));
}
}
// 测试方法
public static void main(String[] args) {
// 1. 字符串校验示例(校验非空非空白)
GenericDataValidator<String> strValidator = new GenericDataValidator<>(String.class);
List<String> strList = List.of("Java", "", "泛型", " ", "高级特性");
// 校验规则:非空且非空白
Predicate<String> strRule = s -> s != null && !s.isBlank();
// 校验所有数据
boolean allValid = strValidator.validateAll(strList, strRule);
System.out.println("字符串是否全部有效:" + allValid); // false
// 获取无效数据
List<String> invalidStr = GenericDataValidator.getInvalidData(strList, strRule);
System.out.println("无效字符串:" + invalidStr); // ["", " "]
// 批量写入有效数据到Object集合(下界通配符)
List<Object> targetList = new ArrayList<>();
int successCount = strValidator.addValidData(targetList, strList, strRule);
System.out.println("成功写入有效字符串数量:" + successCount); // 3
System.out.println("目标集合内容:" + targetList); // [Java, 泛型, 高级特性]
// 严格校验,失败抛异常
try {
strValidator.strictValidate(strList, strRule);
} catch (ValidationException e) {
String invalidData = e.getInvalidData(String.class);
System.out.println("校验失败,错误数据:" + invalidData); // ""
}
// 2. 整数校验示例(校验大于0)
GenericDataValidator<Integer> intValidator = new GenericDataValidator<>(Integer.class);
List<Integer> intList = List.of(10, 20, -5, 30);
Predicate<Integer> intRule = num -> num > 0;
boolean intAllValid = intValidator.validateAll(intList, intRule);
System.out.println("整数是否全部有效:" + intAllValid); // false
List<Integer> invalidInt = GenericDataValidator.getInvalidData(intList, intRule);
System.out.println("无效整数:" + invalidInt); // [-5]
}
}
3. 这个工具类的亮点
- 通配符用得对:读取数据用? extends T,写入数据用? super T,严格遵守PECS原则,不会有编译报错;
- 避开了所有类型擦除的坑:用类型令牌判断类型、静态方法自己定义泛型、非泛型异常存错误数据、用List代替数组;
- 类型安全:所有操作都做了类型校验,不会出现运行时ClassCastException;
- 实用性强:我在项目里校验表单数据、批量入库数据时,都是直接用这个工具类,改改校验规则就行。
四、我的泛型避坑清单
最后给大家整理个避坑清单,都是我踩过的教训,记下来能少走很多弯路:
| 坑的场景 | 为啥会坑 | 我咋解决的 |
|---|---|---|
| 泛型数组创建失败 | 类型擦除后数组分不清泛型类型 | 用List<List>代替泛型数组 |
| instanceof判断泛型类型 | 运行时没泛型信息 | 传Class类型令牌,遍历判断元素类型 |
| 静态变量用泛型 | 静态变量属于类,没法绑定实例泛型 | 静态方法自己定义泛型参数,静态变量用具体类型 |
| catch捕获泛型异常 | 运行时泛型信息被擦除,JVM分不清 | 不用泛型异常,用Object存错误数据,手动转型 |
| 泛型方法重载冲突 | 擦除后方法签名一样 | 加Class参数区分,或改方法名 |
| 通配符写入数据报错 | 违反PECS原则 | 读数据用? extends T,写数据用? super T |
五、总结
泛型这东西,真的不用死记硬背概念,核心就两点:
- 通配符记住PECS原则------生产者(读数据)用extends,消费者(写数据)用super;
- 类型擦除记住"编译期管类型,运行时没泛型",遇到坑就用类型令牌、List代替数组、非泛型异常这些办法补。