什么是泛型
泛型,即“参数化类型”,这是泛型的本质
也就是说,允许你在定义类、接口、方法时,将类型(如String、Integer)作为
“参数” 传入
这个操作让代码可以适配多种数据类型,同时保持了编译期的类型安全
定义方法的时候我们都需要定义形参列表,普通方法的形参列表中,每个形参的数据类型是确定的,而变量是一个参数。
在调用普通方法时需要传入形参对应的数据类型的变量(实参),若传入的实参与形参定义的数据类型不匹配,则会报错,也就是ClassCastException
那么泛型就通过将类型参数化,使得数据类型也可以被设置为一个参数,在使用时再从外部传入一个数据类型,而一旦传入了具体的数据类型后,传入变量(实参)的数据类型如果不匹配,编译器就会直接报错。
那么,上述的 T 就是类型参数占位符,代表任意引用类型,
- 当你需要返回一个
User对象时,就用ApiResponse<User>; - 当你需要返回一个
List<CommentDetailVO>时,就用ApiResponse<List<CommentDetailVO>>。
这样一来,data
字段的类型就不是固定的,而是在使用这个类的时候才决定的,消除了强制类型转换
回到上面的例子,Page<CommentDetailVO>
就是泛型的典型用法,Page
是一个泛型类,<CommentDetailVO>
是传入的类型参数,它表示这个 Page 对象里存放的是
CommentDetailVO 类型的数据,编译器会确保你往这个
Page 里放的都是 CommentDetailVO
类型,取出来的时候也不需要强制转换。
所以,泛型就是把 “类型” 变成了可以像参数一样传递的东西,让代码在编译阶段就保证类型安全,避免了运行时的类型转换错误,同时还能大幅提升代码的复用性。
这种参数化类型可以用在类、接口和方法中,分别被称为泛型类、泛型接口、泛型方法。
什么地方需要使用泛型
先以这种 ApiResponse 为一个使用泛型的例子,在做在 Spring Boot 业务开发中,我们需要一个统一的返回格式,而它的类型就不确定,需要使用泛型
如果不用泛型,你只能用 Object 来接收
data,这会带来两个问题:
- 前端无法明确知道
data的结构,增加了对接成本。 - 后端取值时必须强制类型转换,容易引发
ClassCastException。
泛型的核心价值,就是让 data
字段的类型在使用时才确定,同时在编译期就保证类型安全。
我们先定义一个完整的 ApiResponse<T> 泛型类
1 | import lombok.Data; |
然后,众所周知,所有接口的返回值都应该用它来封装,这就是泛型开始发挥作用的位置
1 |
|
那么,总结一下什么时候需要使用泛型,核心思路就是需要让代码适配多种类型、同时保证类型安全的场景,具体来说
- 统一接口封装:接口返回的数据类型是不确定的
- 构造通用的容器或集合:集合需要存储任意类型的元素,但又要保证存入和取出的类型一致。
- 工具类:当工具类需要支持任意类型的数据处理时,泛型是最好的解决方案。
那么,与使用 Object 对象代替一切引用数据类型对象这样简单粗暴方式相比,泛型使得数据类型的类别可以像参数一样由外部传递进来。它提供了一种扩展能力,更符合面向对象开发的软件编程宗旨。
当具体的数据类型确定后,泛型又提供了一种类型安全检测机制,只有数据类型相匹配的变量才能正常的赋值,否则编译器就不通过。所以说,泛型一定程度上提高了软件的安全性,防止出现低级的失误。
然后,泛型提高了程序代码的可读性。在定义泛型阶段(类、接口、方法)或者对象实例化阶段,由于 < 类型参数 > 需要在代码中显式地编写,所以程序员能够快速猜测出代码所要操作的数据类型,提高了代码可读性。
泛型类
泛型的一些约定
一般情况下,对泛型的占位符有这样的约定
- T :代表一般的任何类。
- E :代表 Element 元素的意思,或者 Exception 异常的意思。
- K :代表 Key 的意思。
- V :代表 Value 的意思,通常与 K 一起配合使用。
- S :代表 Subtype 的意思
泛型类的定义
类型参数用于类的定义中,则该类被称为泛型类。
1 | class 类名称 <泛型标识> { |
例如
1 | // 泛型类:T是类型参数,代表任意引用类型 |
尖括号 <> 中的 泛型标识被称作是类型参数,用于指代任何数据类型。
实例化泛型类时,支持菱形运算符<>,不用重复写类型,编译器会自动推导
菱形运算符就是省略实例化时泛型类右侧的类型参数,由编译器自动推导。
实例化泛型类时,左侧声明泛型类型,右侧用
<>代替重复的类型参数,编译器会自动 “推导” 出右侧的类型,就例如1
2
3
4
5
6
7
8// 左侧指定<String>,右侧用<>,编译器会自动推导为String
ApiResponse<String> strResponse = new ApiResponse<>("操作成功", 200, "Hello");
// Spring Boot分页场景的典型用法
Pageable pageable = PageRequest.of(0, 10);
Page<CommentDetailVO> commentPage = commentRepository.findByArticleId(1L, pageable);
// 新建分页对象时用菱形运算符
Page<CommentDetailVO> emptyPage = new Page<>();编译器的推导逻辑很简单:根据变量声明的左侧类型,自动填充右侧的类型参数。
但是注意,必须先声明左侧的泛型类型,否则无法推导,而且匿名内部类中不能直接用,如果你是 JDK8,因为这个好像算一个 bug,JDK 9 + 优化了给
使用泛型类
在创建泛型类的对象时,必须指定类型参数 T 的具体数据类型,即尖括号 <> 中传入的什么数据类型,T 便会被替换成对应的类型。如果 <> 中什么都不传入,则默认是 < Object >。
1 | public class Generic<T> { |
当创建一个 Generic< T > 类对象时,需要会向尖括号 <> 中传入具体的数据类型。
1 |
|
使用泛型的上述特性便可以在集合中限制添加对象的数据类型,若集合中添加的对象与指定的泛型数据类型不一致,则编译器会直接报错,这也是泛型的类型安全检测机制的实现原理。
例如,
1 | import java.util.ArrayList; |
泛型类的一些注意事项
泛型类中的静态方法和静态变量不可以使用泛型类所声明的类型参数
在泛型类中,类型参数定义的位置有三处,分别为:
- 非静态的成员属性类型
- 非静态方法(非静态成员方法和构造器)的形参类型
- 非静态方法的返回值类型
为什么强调非静态,因为泛型类中的静态方法和静态变量不可以使用泛型类所声明的类型参数
1 | public class Test<T> { |
因为,泛型类中的类型参数的确定是在泛型类对象被创建的时候(例如,在某一处使用到了
ArrayList<E>,它传入了
Integer,这时候编译器才能确定这个 ArrayList 中这个参数的类型为
Integer),也就是说,泛型类里的 <T>
是实例级别的参数,只有当你创建具体实例时,T
才会被确定。
而静态变量和静态方法在类加载时已经初始化,直接使用类名调用,因为它们属于类本身,早于任何实例的创建。所以在泛型类的类型参数未确定时,静态成员有可能被调用,因此静态成员中是不能使用泛型类中定义的类型参数的
但是静态泛型方法中可以使用自身的方法签名中新定义的泛型类型参数(即泛型方法),而不能使用泛型类中定义的类型参数。这句话意思就是,它的类型参数是在方法自己的签名里定义的,和泛型类的
<T> 完全无关。
1 |
|
- 类的
<T>:属于实例,创建实例时才确定。 - 静态方法的
<R>:属于方法本身,调用方法时才确定(编译器会自动推导)。
泛型中无法直接使用基本类型
泛型参数支持所有引用类型(int/double),但不支持基本类型,需使用包装类
1 | // 定义泛型类:T是类型参数,代表任意返回数据类型 |
为什么捏?
Java 的泛型是编译期特性,运行时会执行 类型擦除,编译器在编译后会把所有泛型参数擦除,替换为具体的类型(默认是
Object),也就是说,,在编译期间,所有的泛型信息都会被擦除,ArrayList< Integer >和ArrayList< String >类型,在编译后都会变成ArrayList< Object>类型。而 Java 中,基本类型无法适配 Object,因为所有引用类型都继承自
Object,可以赋值给Object变量;但是基本类型是独立的类型,不是Object的子类,无法直接赋值给Object变量。所以基本类型必须要包装类
不仅泛型类,泛型方法也遵循同样的规则
泛型类不只接受一个类型参数
泛型类不只接受一个类型参数,它还可以接受多个类型参数。
泛型类的类型参数可以是多个,只需要在尖括号 <>
中用逗号分隔即可。格式
class 类名<T1, T2, ..., Tn> { ... }
例如
1 | import lombok.Data; |
实例化时,只需按顺序指定多个具体类型就可以,而此时菱形运算符也会自动推导:
1 | public class TestKeyValuePair { |
泛型接口
定义泛型接口
泛型接口也差不多
1 | public interface Inter<T> { |
接口中的抽象方法可以使用 T 作为参数、返回值、甚至方法内的局部变量类型;
泛型接口主要就是让接口的方法适配多种类型,实现 一套接口,多套实现。
和泛型类一样,接口也支持多个类型参数,不细说
使用泛型接口
使用泛型接口的关键是确定类型参数的时机
实现类实现接口时,明确指定具体类型
实现类只处理一种类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23// 1. 定义泛型接口
public interface Inter<T> {
void show(T t);
}
// 2. 实现类明确指定T为String类型
public class StringInterImpl implements Inter<String> {
public void show(String t) {
System.out.println("String类型的实现:" + t);
}
}
// 3. 使用实现类
public class Test {
public static void main(String[] args) {
Inter<String> inter = new StringInterImpl();
inter.show("Hello 泛型接口"); // 输出:String类型的实现:Hello 泛型接口
// 编译报错!只能传入String类型
// inter.show(123);
}
}也就是说,实现类一旦指定类型(如 String),就只能处理该类型,实例化实现类时,无需再指定类型,因为已经在实现接口时确定了
实现类仍保留泛型参数,实例化时再指定
实现类需要处理多种类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32// 1. 定义泛型接口
public interface Inter<T> {
void show(T t);
}
// 2. 实现类保留泛型参数T,不指定具体类型
public class GenericInterImpl<T> implements Inter<T> {
public void show(T t) {
System.out.println("通用实现,类型:" + t.getClass().getName() + ",值:" + t);
}
}
// 3. 使用实现类(实例化时指定类型)
public class Test {
public static void main(String[] args) {
// 实例化时指定T为String
Inter<String> stringInter = new GenericInterImpl<>();
// 输出:字符串类型
stringInter.show("字符串类型");
// 实例化时指定T为Integer
Inter<Integer> intInter = new GenericInterImpl<>();
// 输出:Integer,值:2026
intInter.show(2026);
// 实例化时指定T为自定义User类型
Inter<User> userInter = new GenericInterImpl<>();
// 输出:类型:com.example.User,值:User(name=张三, age=25)
userInter.show(new User("张三", 25));
}
}在业务开发的时候,定义泛型 CRUD 接口,所有实体的 Service 都实现这个接口,避免重复写增删改查方法,这个场景就是对上面情况的很好反应
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27/**
* 通用CRUD接口
* @param <T> 实体类型
* @param <ID> 主键类型
*/
public interface BaseService<T, ID> {
// 新增
boolean save(T entity);
// 根据ID查询
T getById(ID id);
// 根据ID删除
boolean removeById(ID id);
// 更新
boolean update(T entity);
}
// UserService实现通用接口(指定T=User,ID=Long)
public class UserService implements BaseService<User, Long> {
}
// ArticleService实现通用接口(指定T=Article,ID=String)
public class ArticleService implements BaseService<Article, String> {
}
泛型接口的一些注意内容
泛型接口中的类型参数,在该接口被继承或者被实现时确定
和泛型类一样,泛型接口中的类型参数,在该接口被继承或者被实现时才确定
上面的两个场景的使用已经很好的体现了相关的内容了,不再细说
但是需要说一下,泛型接口被继承时确定类型参数,因为泛型接口也可以被其他接口继承,此时也能确定(或传递)类型参数,也和泛型接口被实现的情况类似,分为 “继承时指定具体类型” 和 “继承时保留泛型参数” 两种情况。
也就是,子接口是直接绑定类型,还是子接口继续泛型化
没事,下面也会继续讲
接口中的静态方法不能使用类型参数
在泛型接口中,静态成员也不能使用泛型接口定义的类型参数,这和泛型类的要求是一样的
泛型接口的类型参数(<U, R>)有严格的使用限制 —— 仅能用于非静态的方法(抽象方法、默认方法),不能用于静态成员
包括接口的属性,因为接口属性默认是静态的
1 | // 定义泛型接口,<U, R>是两个类型参数(U=输入类型,R=返回类型) |
真别忘了,Java 接口中的属性,无论是否加
static,默认都是public static final静态常量
反着想一下,如果允许U name;,接口加载时name作为静态常量,JVM
根本不知道 U
是String还是Integer,类型安全无法保证,因此编译器直接报错。
非静态方法属于 “接口的实现类实例”,只有当实现类实例化时,此时泛型参数才已确定,方法才会被调用;而方法执行时,U/R 已经是确定的具体类型,因此可以安全使用。
这和泛型类是一样的
但是接口默认方法支持使用泛型参数
1 | public interface Inter<T> { |
定义一个接口 IA 继承了 泛型接口 IUsb,在 接口 IA 定义时必须确定泛型接口 IUsb 中的类型参数。
泛型接口被其他接口继承时,其泛型类型不能
留空,必须明确指定 IUsb 的 <U, R>
是具体类型,否则编译器直接报错。
因为接口是 “契约”,继承泛型接口时必须把契约的 “类型约束” 确定下来,子接口才能成为可被实现的合法接口。
1 | // 1. 定义泛型接口IUsb<U, R> |
定义一个类 BB 实现了 泛型接口 IUsb,在 类 BB 定义时需要确定泛型接口 IUsb 中的类型参数
泛型接口被类实现时,其类需要直接指定具体类型,也就是,在
implements IUsb<U, R> 中把 U/R 替换为具体类型,此时类
BB 的所有方法都必须按该类型实现
1 | // 1. 泛型接口IUsb<U, R>(同上文) |
定义一个类 CC 实现了 泛型接口 IUsb 时,若是没有确定泛型接口 IUsb 中的类型参数,则默认为 Object
如果实现类 CC 写
implements IUsb,尖括号都省略了,编译器会默认把
IUsb 的 U/R 都替换为 Object
这是 Java 的 “原始类型” 兼容规则,本质是为了兼容泛型出现前的老代码,不推荐使用,因为会丢失类型安全
1 | // 实现泛型接口时没有确定类型参数,则默认为 Object |
此时,类 CC 是普通类,但方法的参数 / 返回值都变成
Object,可以接收任意类型;
这样,编译期无法检查类型,一般开发中禁止使用这种写法
定义一个类 DD 实现了 泛型接口 IUsb 时,若是没有确定泛型接口 IUsb 中的类型参数,也可以将 DD 类也定义为泛型类,其声明的类型参数必须要和接口 IUsb 中的类型参数相同
如果不想让实现类只处理一种类型,比如想让 DD 适配 String/Integer/User
等多种类型,可以把 DD 也定义为泛型类, 但 DD 声明的类型参数(如
<U, R>)必须和 IUsb
的类型参数数量、顺序一致,名称为了可读性推荐相同
1 | // 1. 泛型接口IUsb<U, R>(同上文) |
因为,类型参数的确定被延迟到实例化时(new DD<String, Integer>()),而非实现接口时。
泛型方法
定义泛型方法
当在一个方法签名中的返回值前面声明了一个
< T >时,该方法就被声明为一个泛型方法。
< T >表明该方法声明了一个类型参数
T,并且这个类型参数 T 只能在该方法中使用。
当然,泛型方法中也可以使用泛型类中定义的泛型参数。
1 | public <类型参数> 返回类型 方法名(类型参数 变量名) { |
如果方法需要多个类型参数,用逗号分隔即可
泛型方法独立于泛型类,即使类不是泛型的,也可以定义泛型方法。主要是在方法返回值前声明类型参数。
在泛型类中定义泛型方法可同时使用类的泛型参数和方法的泛型参数
1 | public class GenericMethodDemo { |
使用泛型方法
和泛型类,泛型接口一样,泛型方法,在调用方法的时候再确定类型参数的具体类型。
1 | public class GenericMethodUsage { |
而且泛型方法中也存在类型推断,这个类型推断和泛型类泛型接口的不太一样(因为他们的类型参数在实例化,实现或者继承时才确定)
泛型方法中的类型推断
在调用泛型方法的时候,可以显式地指定类型参数,也可以不指定
当泛型方法的形参列表中有多个类型参数时,在不指定类型参数的情况下,方法中声明的类型参数为泛型方法中的几种类型参数的共同父类的最小级,直到 Object。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16/**
* 泛型方法:接收两个同类型参数,返回它们的拼接字符串
* <T> 是方法的单个类型参数(注意:这里是“形参列表有多个参数”,不是“多个类型参数”)
*/
public <T> String merge(T a, T b) {
return a + " | " + b;
}
// 调用:不指定类型参数,实参是Integer和Double
public static void main(String[] args) {
Demo demo = new Demo();
// 实参1:Integer(父类是Number);实参2:Double(父类是Number)
// 编译器推断T = Number(Integer和Double的共同父类最小级)
String result = demo.merge(10, 3.14);
System.out.println(result); // 输出:10 | 3.14
}1
2
3
4
5
6
7
8
9
10
11public static void main(String[] args) {
Demo demo = new Demo();
// 显式指定T=Number
// 实参1:Integer(Number的子类);实参2:Double(Number的子类)→ 合法
String result1 = demo.<Number>merge(10, 3.14);
System.out.println(result1); // 正常输出
// 实参1:Long(Number的子类);实参2:Float(Number的子类)→ 合法
String result2 = demo.<Number>merge(100L, 2.5F);
System.out.println(result2); // 正常输出
}在指定了类型参数的时候,传入泛型方法中的实参的数据类型必须为指定数据类型或者其子类
1
2
3
4// 调用:实参是String和User(无除Object外的共同父类)
String result2 = demo.merge("张三", new User("李四", 25));
// 输出:张三 | User
System.out.println(result2);这也是泛型方法推断的 “底线”—— 无论如何都会至少推断到
Object。如果显式指定类型为父类,实参需要为子类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18// 自定义父类和子类
class Animal {}
class Dog extends Animal {}
class Cat extends Animal {}
// 泛型方法
public <T> String printAnimal(T a, T b) {
return a.getClass().getSimpleName() + " | " + b.getClass().getSimpleName();
}
// 调用
public static void main(String[] args) {
Demo demo = new Demo();
// 显式指定T=Animal(父类)
// 实参:Dog和Cat(都是Animal的子类)→ 合法
String result = demo.<Animal>printAnimal(new Dog(), new Cat());
System.out.println(result); // 输出:Dog | Cat
}
注意,上面也提到了,如果泛型方法声明了多个类型参数(如<K,V>),编译器会分别推断每个参数的类型,而非找共同父类
泛型方法使用的一些注意内容
只有在方法签名中声明了< T >的方法才是泛型方法,仅使用了泛型类定义的类型参数的方法并不是泛型方法
1 | public class Test<U> { |
泛型方法中可以同时声明多个类型参数
逗号隔开即可
1 | public class TestMethod<U> { |
泛型方法中也可以使用泛型类中定义的泛型参数
泛型类中定义的类型参数和泛型方法中定义的类型参数是相互独立的,所以说,字母最好不要碰上,要不然就理不开;1
1 | public class TestMethod<U> { |
将静态方法声明为泛型方法
虽然在静态成员中不能使用泛型类定义的类型参数,但我们可以将静态成员方法定义为一个泛型方法。
1 | public class Test2<T> { |
泛型通配符
在一般泛型中不能向上转型
在 Java 的多态中,我们知道可以将一个子类对象赋值给其父类的引用,这也叫向上转型。
在 Java 标准库中的集合 ArrayList< T > 类实现了
List< T >接口,其源码大致如下:
1 | public class ArrayList<T> implements List<T> {...} |
我们刚刚泛型接口的时候说了,一个类实现了一个泛型接口,若是没有确定泛型接口】中的类型参数,也可以将此类也定义为泛型类,其声明的类型参数必须要和泛型接口中的类型参数相同
在 ArrayList< T > 泛型集合中,当传入 < T > 中的数据类型相同时,所以是肯定能将一个 ArrayList< T > 对象赋值给其父类的引用 List< T >
已知 Integer 类是 Number 类的子类,那如果 ArrayList<> 泛型集合中,在 <> 之间使用向上转型,也就是将 ArrayList< Integer > 对象赋值给 List< Number > 的引用,是否被允许呢?
1 | public class GenericType { |
上面代码会报错,我们发现并不能把 ArrayList< Integer > 对象赋值给 List< Number >的引用,甚至不能把 ArrayList< Integer > 对象赋值给 ArrayList< Number >的引用
也就是说,在一般泛型中,不能向上转型
泛型通配符
泛型通配符用于解决 “泛型不协变”
的问题(在泛型中,不能认为List<String>不是List<Object>的子类,这位被了泛型的类型安全问题)
所以,需要逻辑上可以表示为 List< Integer > 和 List< Number > 这两者的父类引用类型,由此,泛型通配符便应运而生。
也就是希望泛型能够处理某一类型范围内的类型参数,比如某个泛型类和它的子类
泛型通配符有 3 种形式:
| 通配符类型 | 说明 | 示例 |
|---|---|---|
无界通配符 <?> |
表示任意类型,只读(无法添加非 null 值) | List<?> list |
上界通配符
<? extends T> |
表示 T 或 T 的子类 | List<? extends Number> |
下界通配符
<? super T> |
表示 T 或 T 的父类 | List<? super Integer> |
上面说了泛型的类型擦除的一些内容,那么是不是所有的类型参数被擦除后都以 Object 类进行替换呢?
答案是否定的,大部分情况下,类型参数 T 被擦除后都会以 Object 类进行替换,但是这种泛型通配符的情况就不是了
上界通配符 <? extends T>
上界通配符 <? extends T>:T 代表了类型参数的上界,<? extends T>表示类型参数的范围是 T 和 T 的子类。
需要注意的是, <? extends T> 也是一个数据类型实参,它也表示一种具体的数据类型,代表 “符合 T 上界的任意类型”。
1 | // 参数改 List<? extends Number> |
List<? extends Number> 表示 “存储 Number
或其子类的 List”,因此
List<Integer>/List<Double>/List<Number>
都能适配这个类型,这样就解决了泛型集合无法统一接收的问题。
<? extends T>
限定的集合只能读取,不能写入,除了
null,这是为了保证类型安全
1 | public static void testAdd(List<? extends Number> list) { |
为什么不能写入?
- 编译器只知道
list存储的是Number的子类,但不知道具体是哪一个(比如是 Integer、Double 还是 Long)。如果允许添加 Integer,而实际 list 是List<Double>,就会导致类型错误 —— 因此编译器直接禁止写入(除了 null)。 <? extends T>适合 “只读取泛型集合” 的场景
那么,通过这个通配符,就把 List<? extends Number>
和 List<Number>
等打造成了继承的关系吗,这不是又违反了泛型不能向上转型的原则
实际上,两者无继承关系,仅仅是逻辑上的父类,但这只是编译器提供的语法便利。
1 | List<Integer> intList = new ArrayList<>(); |
泛型是编译期特性,运行时会执行
“类型擦除”——List<Integer>、List<Double>、List<? extends Number>
擦除后都是 List 类型,JVM 层面没有任何继承差异;所谓的
“父类” 只是编译器在编译期的逻辑判断,而非 JVM 认可的继承关系。
下界通配符 <? super T>
<? super T>(下界通配符)的核心是:限定泛型类型为
T 本身或 T 的任意超类(直至 Object)
- 语法:
<? super T>,T是 “下界类型”; - 含义:泛型类型必须是
T本身,或T的任意父类(比如<? super Integer>包括Integer、Number、Object); - 逻辑兼容:
ArrayList<? super Integer>可接收ArrayList<Integer>/ArrayList<Number>/ArrayList<Object>的赋值(语法层面的 “向上转型”),但无实际继承关系(JVM 层面擦除后都是ArrayList)。
它是类型的实际参数,而不是一个类型的参数,核心特性与上界通配符相反
1 | // 编译错误:ArrayList<Integer> 不能接收 ArrayList<Number> 的实例 |
<? super T> 打破了 泛型集合无法跨父类赋值
的限制,让我们可以在逻辑上把 ArrayList<Number> 当做
ArrayList<? super Integer> 的 “子类” 来赋值
但是<? super T> 只能表示
“范围中的某一个”,无法确定具体类型,也就是说,不能指定
ArrayList<? super Integer> 的数据类型
编译器只知道集合的类型在 T
的超类范围内,但不知道具体是哪一个
1 | ArrayList<? super Number> list = new ArrayList<>(); |
- 因为
ArrayList<? super Number>的下界是ArrayList< Number >。因此,我们可以确定 Number 类及其子类的对象自然可以加入ArrayList<? super Number>集合中; 而 Number 类的父类对象就不能加入ArrayList<? super Number>集合中了,因为不能确定ArrayList<? super Number>集合的数据类型。 - 但编译器无法确定:Number 的父类对象(如 Object)能否存入 —— 如果
list 实际是
ArrayList<Number>,存入 Object 会导致类型错误,因此直接禁止。
也就是说,<? super T>下界限定了写入的类型,这和上界通配符
<? extends Number> 完全相反:extends 是
“只读不写”,super 是 “可写但读取不灵活”。
<? super T>
的核心价值是安全写入,适合 往集合中添加 T 子类对象
的场景
1 | public static void fillNumList(ArrayList<? super Number> list) { |
- 与带有上界通配符的集合
ArrayList<? extends T>的用法不同,带有下界通配符的集合ArrayList<? super Number>中可以添加 Number 类及其子类的对象;ArrayList<? super Number>的下界就是ArrayList<Number>集合,因此,其中必然可以往ArrayList添加 Number 类及其子类的对象;但不能添加 Number 类的父类对象
但是,假如我传入 ArrayList<Integer> 到
<? super Number> 方法,肯定是不行的
1 | // 编译错误:ArrayList<Integer> 不属于 <? super Number> 的范围 |
<? super T>的范围是 “T 及 T 的超类”,T=Number 时,范围是 Number/Object,Integer 不在此范围内,因此无法传入。
读取时候产生的向下转型风险也不要忽视
1 | public static void fillNumList(ArrayList<? super Number> list) { |
<? super Number>的集合可能是ArrayList<Object>,其元素是 Object 类型,强行转型为 Number 会导致运行时ClassCastException,因此编译器禁止;只能安全转型为 Object,这也是 “可写不可读” 的体现。
PECS 原则
这是 extends 和 super 的核心使用准则:
- PECS:Producer Extends, Consumer Super
- 如果你需要从集合中读取数据(集合是 “生产者”),用
<? extends T>; - 如果你需要往集合中写入数据(集合是 “消费者”),用
<? super T>; - 如果你既想读又想写,不要用通配符(直接用具体类型,如
List<Number>)。
- 如果你需要从集合中读取数据(集合是 “生产者”),用
1 | // 生产者(读取):用 extends |
无限定通配符 <?>
Java
的泛型还允许使用无限定通配符<?>,即只定义一个?符号
无界通配符<?>:?
代表了任何一种数据类型
需要注意的是: <?> 也是一个数据类型实参,它和
Number、String、Integer 一样都是一种实际的数据类型。
注意:Object
本身也算是一种数据类型,但却不能代表任何一种数据类型,所以
ArrayList< Object >和 ArrayList<?>
的含义是不同的,前者类型是
Object,也就是继承树的最高父类,而后者的类型完全是未知的;ArrayList<?>
是 ArrayList< Object > 逻辑上的父类。
1 | public class GenericType { |
- 上述代码是可以正常编译运行的,因为 ArrayList<?> 在逻辑上是 ArrayList< Integer > 的父类,可以安全地向上转型。
ArrayList<?>
既没有上界也没有下界,因此,它可以代表所有数据类型的某一个集合,但我们不能指定
ArrayList<?>的数据类型。
1 | public class GenericType { |
- 注意,能代表任何一种数据类型的只有 null
ArrayList<?>集合的数据类型是不确定的,因此我们只能往集合中添加 null;而我们从ArrayList<?>集合中取出的元素,也只能赋值给 Object 对象,不然会产生ClassCastException 异常(上界和下界的要求合一块了)
大多数情况下,可以用类型参数 < T > 代替 <?> 通配符。
JDK 21 中泛型的新增 / 优化
Record 类支持泛型
Record 是 JDK 16 引入的不可变类,JDK 21 中可直接定义泛型 Record
1 | // 泛型Record(JDK 21标准) |
密封类与泛型结合
JDK 17 引入的 Sealed 类,JDK 21 中可结合泛型限制子类的类型参数
1 | // 密封泛型类,仅允许StringBox和IntBox继承 |
一些面试内容
Java中的泛型是什么 ? 使用泛型的好处是什么?
- 泛型是一种参数化类型的机制。它可以使得代码适用于各种数据类型,从而编写更加通用的代码,例如集合框架。
- 泛型是一种编译时类型确认机制。它提供了代码编译期的类型安全,确保在泛型类型(通常为泛型集合)上只能使用正确类型的对象,避免了在运行时产生ClassCastException 异常。
Java的泛型是如何工作的 ? 什么是类型擦除 ?
- 泛型的正常工作是依赖编译器在编译源码的时候,先进行类型检查,然后进行类型擦除并且在类型参数出现的地方插入强制转换的相关指令实现的。
- 类型擦除:编译器在编译时擦除了代码中所有与泛型相关的信息,所以在运行时不存在任何泛型信息。例如 List< String > 类在运行时仅用一个 List 类型来表示。而为什么要进行擦除呢?这是为了避免类型膨胀。
List<? extends T> 和 List <? super T> 之间有什么区别
- 这两个 List 的声明都是限定通配符的例子,List<? extends T> 可以接受任何继承自T 的类型的 List,而 List<? super T> 可以接受任何T 的父类构成的 List。
- 例如:List<? extends Number> 可以接受 List< Integer > 或 List< Float >;List <? super Number> 可以接受 List< Object > 但不能接受 List< Integer >。
如何编写一个泛型方法,让它能接受泛型参数并返回泛型类型?
编写泛型方法并不困难,你需要用泛型类型来替代原始类型,比如使用 T,E,K,V 等被广泛认可的类型占位符。泛型方法的例子请参阅 Java 集合类框架,最简单的情况下,一个泛型方法可能会像这样:
1
2
3
4
5public class TestMethod<U> {
public <T, S> T testMethod(T t, S s) {
return null;
}
}
你可以把 List< String > 传递给一个接受 List< Object > 参数的方法吗?
- 不行, List< String > 与 List< Object > 之间没有继承关系,只是可以通过泛型的一些限定符来打造出来逻辑上的父类
Array 中可以用泛型吗?
- Array 事实上并不支持泛型,这也是为什么《 Effective Java》 一书中建议使用 List 来代替Array,因为 List 可以提供编译期的类型安全保证,而 Array 却不能。
Java 中 List< Object > 和原始类型 List 之间的区别?
原始类型和 < Object > 之间的主要区别是,在编译时编译器不会对原始类型进行类型安全检查,却会对泛型类型 < Object > 进行检查。< Object > 通过使用 Object 作为类型参数,可以告知编译器可以接收任何数据类型的对象,比如 String 或 Integer。 这道题的考察点在于对泛型中原始类型的正确理解。
它们之间的第二点区别是,你可以把任何泛型类型传递给接收原始类型 List 的方法,但却不能把 List< String > 传递给 List< Object > 的方法,因为会产生编译错误。举例如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17public class Test {
public static void main(String[] args) {
// 创建一个 ArrayList<String> 集合
List<String> list = new ArrayList();
fillNumList(list);// 编译正确
fillObjList(list);// 编译错误
}
public static void fillList(List list) {
...
}
public static void fillObjList(List<Object> list) {
...
}
}
Java 中 List<?> 和 List< Object > 之间的区别是什么?
List<?>是一个不确定的未知类型的 List,而List< Object >是一个确定的 Object 类型的 List。List<?>在逻辑上是所有 List< T > 的父类,你可以把 List< String >、 List< Integer > 等集合赋值给 List<?> 的引用;而 List< Object > 只代表了自己这个泛型集合类,只能把 List< Object > 赋值给 List< Object > 的引用,但是 List< Object > 集合中可以加入任意类型的数据,因为 Object 类是最高父类。
Java 中 List< String > 和原始类型 List 之间的区别。
该题类似于“List< Object > 和原始类型 List 之间的区别”。泛型数据类型是类型安全的,而且其类型安全是由编译器保证的,但原始类型 List 却不是类型安全的。你不能把 String 之外的任何其它类型的对象存入 List< String > 中,而你可以把任何类型的对象存入原始 List 中。
使用泛型数据类型你不需要进行类型转换,但是对于原始类型,你则需要进行显式的类型转换。举例如下:
1
2
3
4
5
6
7
8
9
10
11
12List listOfRawTypes = new ArrayList();
listOfRawTypes.add("abc");
listOfRawTypes.add(123);
String item = (String) listOfRawTypes.get(0);// 获取元素时需要显式的类型转换
// 编译器不报错,但运行时会产生 ClassCastException异常,因为 Integer不能被转换为 String
item = (String) listOfRawTypes.get(1);
List<String> listOfString = new ArrayList();
listOfString.add("abcd");
listOfString.add(1234);// 编译器直接报错
item = listOfString.get(0); // 不需要显式的类型转换,编译器会自动转换







