1、什么是泛型?
泛型,也即参数化类型,"泛"的是"许多许多"的意思,泛型就是用来表示许多许多类型,意图在定义时不指定具体类型,而是在使用时指定。也可以理解为类型是个参数,使用的时候指定具体的类型。此外,泛型的主要目的之一是为了创造容器类,所谓容器,就是存放要使用的对象的地方,这个容器可以是保存单个对象的,也可以是保存多个对象的。泛型可以有效避免类型安全问题的出现以及可以方便我们写出更加通用的类或者方法,将类或方法与具体的使用进行解耦。
2、泛型类:
众所周知,Java是一种强类型语言,每个对象都有一个明确的类型,且都继承自Object。Object作为顶层父类,在使用过程中,任何类型都可以自动向上转换为Object类型。也即自动类型提升。但是不能自动向下转型,需要做强制转换,也即强制向下转型。可以将Object对象强制转换成需要的类型,但是如果Object对象的真实类型不是该类型,那么这种强制转换在编译期虽然不会报错,但是在运行时就会发生类型转换异常。
例如:实例化一个Object对象,并将之强制转换为String类型。
java
public class Generics {
public static void main(String[] args) {
Object o = new Object();
String s = (String)o;
System.out.println(s);
}
}
上述代码在编译期不会报错,但是在运行期就会报类型转换异常,也即出现了类型安全问题。如图:
正确写法是已知Object的真实类型,那么可以做强制类型转换。
typescript
public class Generics {
public static void main(String[] args) {
Object o = new String("a");
String s = (String) o;
System.out.println(s);
}
}
上述代码,String类型自动提升为Object类型,然后强制向下再转换为String类型,输出正确结果,如图:
在不使用泛型时,当不确定使用的具体是什么类型时,可以指定为Object类型,达到一定的代码通用能力。例如:存在一个Message类,在开发初期,它的消息内容如下:
java
public class Message {
private String id;
private String date;
private BodyA body;
}
java
public class BodyA {
// 省略消息体内的字段
}
在某个时期,Message类需要支持BodyB类型的消息体,那么为了通用,就将BodyA改为Object,在实际使用时进行强制转换,如下:
java
public class Message {
private String id;
private String date;
private Object body;
}
思考,此时Message类变得可持有任何对象了,看似通用,实则在使用时容易变得很迷惑,此处到底填什么。我们更希望的是可以持有指定的类型,于是第二步改进,基于Java的继承与多态,定义一个基类Body,使BodyA与BodyB继承Body类。改进后的Message类:
java
public class Message {
private String id;
private String date;
private Body body;
}
相比于前两步的改进,使用Body基类明显使得后续编码变得更加清晰,也不容易造成误解。但是,依然存在问题,使用时不仅需要做强制转换,且如果希望它支持更多种类的类型,那么只能添加一个子类,约束性太强。此时使用泛型去解决这个问题,使用类型参数,用尖括号括住,放在类名后面:
java
public class Message<T> {
private String id;
private String date;
private T body;
public T getBody() {
return body;
}
}
在Message后使用<T>
表示使用泛型,<>是语法,T是类型参数,这个T可以随意指定,也可以是多个,之间使用,
进行分隔,例如<R,T>
常用的有T、R、U、K、V等,(这些类型参数常见的使用场景可以参考Java的集合类)。此时Message变得很通用,可以任意指定需要的明确的类型,例如:
java
public class Message<A> {
private String id;
private String date;
private A body;
public A getBody() {
return body;
}
public static void main(String[] args) {
Message<BodyA> messageA = new Message<>();
BodyA bodyA = messageA.getBody();
Message<BodyB> messageB = new Message<>();
BodyB bodyB = messageB.getBody();
}
}
此外,泛型类在不指定参数类型时也可以使用,例如:
java
public class Message<T> {
private String id;
private String date;
private T body;
public T getBody() {
return body;
}
public static void main(String[] args) {
Message message = new Message();
Object body = message.getBody();
System.out.println(body);
}
}
此时,getBody()方法返回Object类型的对象。
3、泛型方法:
泛型类和泛型方法并没有直接的关系。泛型方法所在的类可以是泛型类也可以不是泛型类。泛型方法使得该方法能够独立于类而发生变化,在任何时候,如果泛型方法的存在可以避免整个类变成泛型类,那么应该使用泛型方法。泛型方法的定义只需要在方法的返回值前使用<>
配合类型参数(T、R等)来声明泛型类型即可。
3.1、泛型方法在普通类中的应用:
在使用泛型方法的场景中比较常见的是写一些工具类,例如,使用MyBatis-plus做分页查询时,需要给前端返回一个结果集,从数据库查询出结果后,再手动封装一个结果集的对象给前端,包含当前页、记录数、数据集、每页条数等。如果每一个分页查询都需要做这样的操作,重复性比较高,于是使用泛型方法写一个工具类,减少重复劳动:
java
public class PageResultUtil {
/**
* 由实体分页结果集构建出响应VO分页结果集
*
* @param responseRecords 查询结果记录集
* @param page 分页对象
* @param <T> 数据实体
* @param <R> 响应VO
* @return 返回响应VO分页结果集
*/
public static <T, R> IPage<R> buildPageResult(List<R> responseRecords, IPage<T> page) {
IPage<R> pageResult = new Page<>();
if (Objects.isNull(page)) {
return pageResult;
}
pageResult.setRecords(responseRecords);
pageResult.setCurrent(page.getCurrent());
pageResult.setSize(page.getSize());
pageResult.setTotal(page.getTotal());
return pageResult;
}
}
3.2、泛型方法在泛型类中的应用:
首先,再次强调一点,泛型类中的方法,不一定是泛型方法,例如:
java
public class Generics<T> {
public void test1(T t) {
}
}
其中test1方法并不是一个泛型方法,它只是泛型类中的一个普通方法。泛型类中也可以写泛型方法,例如:
java
public class Generics<T> {
public <R> R test2(T t, R r) {
System.out.println(t);
return r;
}
}
另外,在泛型类中对于一个static方法来讲,无法访问泛型类的类型参数。 例如: 当想在static方法中使用泛型类型时,像普通方法一样使用是不允许的,此时要么去掉static关键字,要么将这个方法变成泛型方法。
4、泛型的通配符以及泛型的边界:
泛型的通配符能够更加灵活的表示一些泛型,一般结合泛型的边界进行使用,通配符使用?
表示。我们希望通过泛型来限制在操作代码时的具体类型,有些时候会为泛型指定一些边界条件,这里的边界又分为下界和上界,上界使用extends关键字,指定泛型类型的最大类型就是extends关键字后的类型。下界使用super关键字,指定泛型类型的最小类型就是super关键字后的类型。
以常用的List接口为例,List接口本身就是一个泛型容器类,开发中也是一个常用的集合框架。有一个常见的说法叫做PECS,意思就是说,如果你的List容器是作为生产者(Provider)使用的,也就是它是提供数据的,可以调用get方法从list获取数据然后使用,那么使用extends;如果你的List容器是作为消费者(Consumer)使用的,也就是它是消费数据的,调用add方法将数据添加到list中,那么使用super关键字。
java
public void testExtends(List<? extends Apple> list ) {
Apple apple = list.get(0);
}
public void testSuper(List<? super Apple> list ) {
list.add(new Apple());
}
以上两个方法,其中testExtends方法表示list作为生产者,可以提供数据,调用get方法就能获取对应的数据;testSuper方法表示list作为消费者,调用add方法可以消费掉数据。使用上界通配符extends则list只能调用get方法,调用add方法则编译报错;使用下界通配符则只能调用add方法(调用get方法会自动向上转换成Object类型,导致类型安全问题),理解时把握一条规则即可:Java中类型转换只能自动向上转换。
? extends Apple
表示List中存储的元素是最大是Apple类型,调用get方法会自动提升类型到Apple,如果使用add方法,即使添加的是Apple类,List中无法确定保存的是否是Apple的子类,如果是Apple的子类,那么由于父类不能自动向下转型,所以无法存入,故而报错。? super Apple
表示list中存储的元素最小是Apple类型,调用get方法由于无法确认真实的类型是Apple的哪个父类,故而统一向上转换为Object类型,调用add方法则只能存放Apple及其子类,如果存放Apple的某个父类则会因为无法确定list中存放的类型是Apple的哪个父类而报错,如果存入Apple及其子类,则会自动向上转换为Apple类型。
5、泛型类型擦除:
(摘自chatgpt,这部分不好理解,除非动手反编译)
Java中的泛型是在编译时期的一种机制,可以让程序员在编写代码时指定一种类型规范,以增强代码的类型安全性。然而,Java中的泛型并不是像C++模板那样的真正意义上的泛型,而是通过类型擦除来实现的。类型擦除是Java编译器在编译期间处理泛型的一种技术。
类型擦除的主要原则包括:
-
类型擦除处理:编译器在编译阶段会将泛型类型擦除为其上界类型(对于无界类型擦除为Object)。这意味着在运行时,泛型类型参数信息将丢失,所有泛型类型实例将被视为Object类型处理。
-
类型参数的转换:类型擦除将泛型类型参数转换为其边界类型,对于泛型类会被转换为Object,对于泛型方法会被转换为其上界类型。
-
类型安全检查:类型擦除会将泛型类型的操作限制为其边界类型的操作。这样可以确保在运行时不会出现类型不匹配的错误。
尽管类型擦除会导致泛型类型参数的信息丢失,但它仍然能够在编译期间提供类型安全性,而且可以保持与旧版本Java代码的向后兼容性。此外,类型擦除还可以减少运行时的开销和内存占用。但有时也会导致一些不直观的行为,例如在使用反射时可能无法获取准确的泛型类型信息。
6、总结:
泛型的出现可以使代码变得更加通用,许多Java的框架中大量应用了泛型的特性,边学边用才能更加深入的理解泛型的特性和好处。