Java泛型大揭秘:从基础操作到实现原理,及如何规避常见陷阱与问题!

引言

作为每天跟代码打交道的我们,相信对泛型这个技术并不陌生,可是你真的熟练掌握Java泛型了吗?一部分人可能会摇摇头,尽管每天都在用,但都是在使用别人封装的类库时才接触,单论直接对泛型的使用,在日常工作里鲜有涉及。

或许是因为初学Java时的不在意,又或者是教学老师的不细心,所以导致有些小伙伴对泛型的掌握和理解并不算深刻。因此,本文将从基操深入到原理,再以日常编码中的实践举例,携手诸君重温Java泛型体系。

PS:基础扎实者可直接跳到第二、三阶段开始阅读。

一、Java泛型机制概述

泛型(Generics)是JDK5中引入的一个新特性,它允许将一个类型作为参数传递给类、接口、方法使用,这种特性被称为参数化类型。与编译时类型安全检测机制相结合,能实现在编译期检测代码中的非法类型,泛型给Java带来的好处如下:

  • 类型安全:能在编译时进行类型检查,避免了运行时类型转换出错的可能性;
  • 消除强制类型转换:泛型减少了代码中的显式强制类型转换,使代码更加清晰简洁;
  • 提高代码的重用性:可以基于泛型封装更加通用、灵活的代码,从而在不同场景下复用;
  • 更好的性能:在某些情况下,泛型可以避免基础类型自动装箱和拆箱,提高程序性能。

当然,Java在引入泛型之前,作为一门面向对象的编程语言,其中所具备的多态特性,也是泛化的一种体现,但通过多态机制去封装一个通用类库,使用起来会有些麻烦,下面来看看。

1.1、为什么需要泛型?

在泛型出现之前,如果我们封装一个通用的类库,如现在常用的List集合,该如何进行设计?如下:

这边整理了一份核心Java面试笔记包括了:Java面试、Spring、JVM、MyBatis、Redis、MySQL、并发编程、微服务、Linux、Springboot、SpringCloud、MQ、Kafka 面试专题

 需要全套面试笔记的【点击此处即可】免费获取

java

代码解读

复制代码

public class CustomList { private Object[] elements; private int index = 0; public CustomList(int initialCapacity) { this.elements = new Object[initialCapacity]; } public void add(Object element) { // 如果集合可用容量不���,则对数组进行双倍扩容 if (index >= this.elements.length) { int length = this.elements.length; Object[] newElements = new Object[length * 2]; System.arraycopy(elements, 0, newElements, 0, length); elements = newElements; } this.elements[index++] = element; } public Object get(int index) { // 如果传入的下标不存在,则抛出下标越界异常 if (index < 0 || index >= this.elements.length) { throw new IndexOutOfBoundsException("max index : " + (this.elements.length - 1)); } return this.elements[index]; } }

自定义的CustomList类作为一个容器,有可能用来承载任何类型的对象,为了确保通用性,内部使用Object数组来存储元素。接着,为了能够正常读写容器,我们封装了add()、get()两个方法,这两个方法的内在逻辑极其简单,向容器添加元素时,会先检查下是否还有空位置,没有则扩容,然后将元素添加到数组尾部。从容器获取元素时,先对传入的下标进行范围检查,合法则根据下标从数组里读取元素返回:

 

java

代码解读

复制代码

CustomList list = new CustomList(16); list.add("竹子爱熊猫"); list.add(new Object()); String zhuZi = (String) list.get(0); System.out.println(zhuZi); // 强制转换第二个元素会报错 String obj = (String) list.get(1);

尽管目前可以通过add()、get()来对容器进行读写,可显然存在两个致命问题!首先是读取数据时,每次需要对读到的元素进行强制转换才能使用;其次是写入数据时,无法限制向容器里写入的元素类型,我们可以将任意类型的对象往容器里塞,而当从容器读取元素强制转换时,读到预期外的类型就会出现ClassCastException类型转换异常。

上面这样的结果,显然并非我们所需,我们更希望的是:在读取数据时无需手动转换元素;在写入时,如果有可能存在潜在的错误,理应在编译期就告知我们,而不是等到运行期间再抛出异常。而Java泛型的诞生,则完美的解决此问题。

1.2、Java泛型快速回顾

泛型是Java编程中不可或缺的一部分,能极大地增强Java的类型系统和表达能力,在封装通用类库、框架及实践各种设计模式时被广泛应用,我们来看个泛型的例子:

 

java

代码解读

复制代码

public class ZhuZi<T> { private T data; public void setData(T data) { this.data = data; } public T getData() { return data; } } public static void main(String[] args) { ZhuZi<String> zs = new ZhuZi<>(); ZhuZi<Integer> zi = new ZhuZi<>(); }

上述案例中,ZhuZi类定义了一个类型参数T(也被成为类型占位符),这种写法则是所谓的泛型。再看类成员,其中定义了一个类型为T的成员。在下面的main()方法里使用ZhuZi类时,在类名后面通过<>尖括号指定了不同的类型,这是啥意思?很简单,假如ZhuZi是一个箱子,里面的data就是具体要放置的东西,<>里指定的类型,就相当于一个标签,表示zs、zi这些具体的箱子究竟可以放什么物品进去。

 

java

代码解读

复制代码

zs.setData("竹子爱熊猫"); String zhuZi = zs.getData(); System.out.println(zhuZi); // 强制塞入指定类型之外的数据会报错 zs.setData(new Object());

在我们使用这些带泛型的对象时,从其中读取数据再也无须强制转换。同样,强行往其中塞入非String的数据,在编译期间就会报错。

1.3、泛型的三种作用域

上面简单回顾泛型的基本知识后,下面一起来看看泛型的三种作用域。

1.3.1、泛型类

当一个类中,某个变量或方法入/出参的类型不确定,就可以定义带有泛型的类来解决此困境,例如最常见的后端接口统一的返回结果类:

 

java

代码解读

复制代码

public class ServerResponse<T> implements Serializable { private static final long serialVersionUID = 1L; // 响应状态码 private String code; // 响应消息 private String msg; // 是否成功标识 private boolean isSuccess; // 响应的数据 private T data; // 链路追踪ID private String traceId; // 接口响应时间戳 private final long timestamp = System.currentTimeMillis(); }

上述ServerResponse类就是泛型类的写法,即类名后面定义了一个类型参数T,这个T可以是任意符合Java命名规范的名字,但通常都是对应英文的缩写,如K=Key、V=Value、T=Type、E=Element……

ServerResponse类的内部,T可以被当作字段的类型使用,也可以作为方法的形参定义、出参类型使用,不过要格外注意的是:普通类定义的类型参数,无法提供给类的静态成员使用,Why?这跟Java的类型推导机制有关,因为静态成员属于类,而泛型依赖于具体的类实例对象来推导,如果实例对象都没有创建,Java自然无法推导泛型对应的具体类型。

当然,定义了一个泛型类后,使用该类时可以不指定类型,如:

 

java

代码解读

复制代码

ServerResponse resp = new ServerResponse(); resp.setData(1); resp.setData("竹子"); resp.setData(new Object());

这是什么原理呢?很简单,如果不通过<>指定具体类型,那么泛型默认就为Object类型,因为它是Java所有类的基类,意味着你可以向resp塞入任何数据,只不过读取使用时,又需要强制转换后方能使用罢了。

1.3.2、泛型接口

泛型接口与泛型类的定义方式完全相同,只需在接口名后面跟上<x>即可:

 

java

代码解读

复制代码

public interface PandaFactory<T> { T getPanda(); }

众所周知,接口中定义的所有方法子类必须实现(静态方法与默认方法除外),而接口定义的泛型同样具备此特性,如果一个类想要实现某个泛型接口,那么该子类必须使用或继承(延续)该泛型,啥意思?如下:

 

java

代码解读

复制代码

public class BigPanda implements PandaFactory<String> {} public class BigPanda implements PandaFactory {} public class BigPanda<T> implements PandaFactory<T> {} // 错误的写法 public class BigPanda implements PandaFactory<T> {}

正如代码中所示,第一种写法代表子类实现接口时,明确将泛型指定成了String类型的实参;而第二种写法省略了泛型,则默认会使用Object;第三种方式表示继承了接口的泛型,BigPanda类成为一个泛型类;但最后一种写法会报错,因为这种写法既未使用泛型,也未继承接口泛型。

 

java

代码解读

复制代码

public class ZhuZi<A, B, C, D, E, F, G> {}

当然,不管是泛型类,还是泛型接口,都可以定义一或多个泛型参数,多个泛型之间用英文逗号分隔即可。

1.3.3、泛型方法

泛型方法允许在方法级别引入类型参数,从而编写出更加通用、灵活和可重用的代码。但是,泛型类和泛型接口的定义十分简单,而泛型方法却有些令人费解,这也是许多人未完全掌握的一种泛型方式,先来看个例子:

 

java

代码解读

复制代码

public <O> boolean equals(O obj1, O obj2) { return obj1 instanceof CharSequence && obj2 instanceof CharSequence ? obj1.equals(obj2) : obj1 == obj2; }

上述是一个比较两个对象是否相同的方法,如果传入的两个参数都为字符类型,则使用equals()来比较,否则使用==来进行比较。不过方法的内在逻辑并不重要,重点来看看这个方法的定义方式,与普通方法不同点在于:访问修饰符与方法出参类型之间,通过尖括号定义了一个泛型,而这种方法则被称之为泛型方法。

实例方法、接口方法、静态方法、抽象方法、常量方法……,Java中的任何方法都可以被声明为泛型方法,方法泛型与类泛型的区别在于:类泛型相当于全局变量,只要是当前类的实例成员(包括内部类里),都可以使用类上定义的泛型;而方法泛型等价于局部变量,只能在对应方法的形参、出参、方法体内使用

方法同样可以定义多个泛型,格式也与之前类似,如下:

 

java

代码解读

复制代码

public static <K, V> V putMap(K key, V value) { HashMap<K, V> map = new HashMap<>(); return map.putIfAbsent(key, value); }

方法定义了多个泛型时,定义的泛型可以在方法作用域的任意位置出现,但要记住,如果定义了一个泛型,直接将其作为出参类型,这时将难以返回结果,例如:

 

java

代码解读

复制代码

public <P, R> R handler(P param) { return ???; }

这个方法定义了两个泛型,P作为了方法形参类型,R作为了方法出参类型,可对于编译器来说,它无法推断出这个R的具体类型,这时只能强转或外部传入R

 

java

代码解读

复制代码

// 方式一:将方法返回结果强转成R类型 public <P, R> R handler(P param) { return (R) new Object(); } // 方式二:由外部传入R类型入参,并将该参数作为出参 public <P, R> R handler(P param, R result) { return result; }

最后,在调用泛型方法时,一般无需手动指定类型,例如前面的putMap()方法,可以直接这么调用:

 

java

代码解读

复制代码

String value = putMap("name", "竹子爱熊猫");

这种方式编译器会自动推导传入的类型,不过也可以手动给定类型:

 

java

代码解读

复制代码

ZhuZi.<String, String>putMap("name", "竹子爱熊猫");

众所周知,Java中的.代表调用方法的意思,而调用泛型方法时,只需在.后面跟上具体的泛型就行。

二、泛型通配符与类型擦除

上阶段了解Java泛型的基本知识后,不难发现一个问题,不论是类、接口,还是方法定义的泛型,在传入类型参数时,都可以传递任意类型,这虽然能够极大增强代码的复用性、灵活性和类型安全性,但在某些情况下也会存在些许问题,好比你封装了一个通用方法,但它仅适用于处理某类特定的对象,这时该怎么办?来看例子:

 

java

代码解读

复制代码

public static <A, B> int sizeSum(A a, B b) { // 求a、b两个集合的长度 }

你明确清楚这个sizeSum()方法是为了封装给集合类使用,作用是求和两个集合的元素数量,这个需求很容易对吧?直接用a.size()加上b.size()就行,可当你尝试调用a、b入参的size()方法时,就会发现根本调用不了!

无法调用

2.1、边界约束通配符

正如前面的案例所示,在有些特殊场景下,我们更希望能够限制泛型参数的类型,以确保类型安全或满足特定的功能需求。Java官方显然也想到了这点,所以提供了泛型边界约束机制来满足这些场景,而泛型的边界约束又分为上界、下界两种。

2.1.1、泛型上界(Upper Bound)

上界约束通过extends关键字来指定泛型的上界,指定后则代表对应泛型参数必须是指定类型或该类型的子类型。也就是说,泛型上界可以用来限制泛型参数的类型范围,例如你声明某个泛型的上界为ZhuZi,当你传入的类型并非ZhuZi或它的子类时,就无法通过编译校验,这进一步确保类型安全。

其次,限制泛型上界后,还可以使用指定类型的方法或属性。以刚刚的例子说明,如果明确清楚这个方法是提供给集合类使用,那么可以这么操作:

 

java

代码解读

复制代码

public static <A extends Collection<E>, B extends Collection<E>, E> int totalSize(A a, B b) { return a.size() + b.size(); }

这时大家会发现,a、b入参又能正常调用size()方法了,为啥?因为指定了A、B泛型的上限为Collection,这代表调用totalSize()方法时,传递进来的对象都是Collection自身或其子类,而size()方法由Collection定义,那么入参a、b自然可以正常使用此方法!

综上,当你想要封装一个只能处理某个类型及其子类的方法时,就可以将泛型的上界定义成特定的类,既能在编译阶段就可以阻止不规范的类型传递,还能在泛型类、泛型方法内部调用特定类的方法~

2.1.2、泛型下界(Lower Bound)

上界用于约束泛型的上限,下界约束与之相反,它用于约束泛型的下限,我们可以通过super关键字来指定泛型的下界,代表泛型参数必须是指定类型的超类型/父类型(包含自身)。不过与类型上界有所区别的是:super关键字无法直接作用在泛型定义上,这是啥意思呢?如下:

 

java

代码解读

复制代码

public class ZhuZi<T super Number> {}

如果凭借前面使用extends关键字的经验,super应该是按上述形式来使用,可当你试图写出这样的代码时,就会发现Java语法糖并不支持,那该怎么用?通常来说,super会结合Java8的函数式接口与Stream流来一起使用,好比封装一个通用的StreamUtils工具类,其中需要实现过滤方法:

 

java

代码解读

复制代码

public static <T> List<T> filter(List<T> list, Predicate<? super T> predicate){ return list.stream().filter(predicate).collect(Collectors.toList()); }

观察后不难发现,泛型下界的正确使用方式是:在使用泛型参数传递<? super T>,这里的?放在后面讲,先来寥寥这个泛型下界,来看个简单例子:

 

java

代码解读

复制代码

public static void printNumbers(List<? super Number> numbers) { numbers.forEach(System.out::println); }

这是一个打印输出集合元素的方法,入参numbers通过super限制了泛型下界为Number类型,这代表调用此方法至少要传递Number类型的List对象进来:

 

java

代码解读

复制代码

// 能正常编译的方式 List<Number> numbers = new ArrayList<>(); printNumbers(numbers); List<Object> objects = new ArrayList<>(); printNumbers(objects); // 无法通过编译的方式 List<Integer> ints = new ArrayList<>(); printNumbers(ints); List<ZhuZi> zhuZis = new ArrayList<>(); printNumbers(zhuZis);

这里给出了四种调用printNumbers()的方式,前面两种能正常编译通过,因为Number、Object都符合下界约束。反观Integer、ZhuZi类型,前者是Number的子类型,后者与Number两不相干,所以传入printNumbers()方法就无法通过编译。

好了,看到上述效果后,有些人会疑惑,方法形参限制泛型的下限有啥用?想要弄明白还得来看方法内部:

 

java

代码解读

复制代码

public static void printNumbers(List<? super Number> numbers) { // 能正常编译的代码 numbers.add(new Integer(1)); numbers.add(new Double("1.01")); // 无法通过编译的代码 numbers.add(new Object()); }

与外部调用方法时完全相反,在printNumbers()方法内部,就只能往numbers里添加Number类型及子类型的元素,当试图将Object类型的元素加到集合时,反而会提示编译出错,Why?因为对于方法内部来说,虽然外部可能会传入List<Object>类型进来,但到底传不传编译器也不能确定,所以只能按保底的Number来推测,Integer、Double都属于Number的子类,这时往List<Number>类型的集合添加自然没有任何问题!

2.1.3、无界通配符(?)

泛型边界约束提供了额外的类型安全保证,并允许在代码中更灵活的使用泛型。不过要用哪种约束得取决于具体需求,比如你希望限制的类型范围、你想从泛型参数中读取还是写入数据等。

好了,在前面的案例中出现了一个?,这是什么意思呢?未知通配符,也叫无界通配符,?代表一个不确定的未知类型,通常用于泛型方法的形参定义、泛型的调用代码处,前者是指结合extends、super关键字使用,如:

 

java

代码解读

复制代码

public static void printSize1(List<? extends Number> numbers) { System.out.println(numbers.size()); } public static void printSize2(List<? super Number> numbers) { System.out.println(numbers.size()); }

那后面说的“泛型调用代码处”是啥意思?以之前提到的ServerResponse<T>泛型类为例,该类作为接口统一返的结构体,其中的T代表响应数据,可是当返回接口出错时,并不会产生响应数据怎么办?如下:

未知类型

这样写虽然能编译通过,但IDE一堆黄色的警告难免让强迫症患者看了头疼,怎么办?这时就可以使用无界通配符来解决:

 

java

代码解读

复制代码

public static ServerResponse<?> failed(String msg) { ServerResponse<?> response = new ServerResponse<>(); response.setCode(ERROR_CODE); response.setMsg(msg); return response; }

?代表未知类型、任意类型,Java里什么能代表任意类型?有人可能会回答Object!但很遗憾,Object本身也是一种类型,只有null才能代表任意类型!为此,ServerResponse<?>这样定义,就说明response对象的data字段为空(这个场景其实ServerResponse<Void>这样也行)。

除了上述场景外,还可以基于?处理类型未知场景,如:

 

java

代码解读

复制代码

List<?> list; list = new ArrayList<String>(); list = new ArrayList<Integer>();

正常情况声明List对象,一旦将其泛型固定,对应变量就无法再重新赋值成其他泛型的集合,但使用?来声明集合的泛型,上述list变量则可以变更成任意泛型的集合对象。综上,?通配符的第二种作用,就是针对处理这种类型未知或不关心具体类型的场景

2.3、泛型擦除机制

在聊泛型擦除机制前,先来看一个案例:

 

java

代码解读

复制代码

List<String> strings = new ArrayList<>(); List<Integer> ints = new ArrayList<>(); System.out.println(strings.getClass() == ints.getClass());

这段代码是对比两个集合的Class类型,按照主观思想来说,一个为字符串集合,一个为整数集合,两者类型自然不可能相等,可实际结果很打脸,strings、ints的类型相等!为啥呢?因为Java中定义的泛型只存在于编译期间,经过编译器编译生成字节码后,代码中的泛型信息会被去掉,这种机制被称为泛型擦除

运行期间并不保留泛型信息,所以案例中的strings、ints对象。类型都为List类型,输出的结果自然为true。既然编译器抹除了泛型信息,可为啥代码还能正常运行呢?因为编译器会将泛型替换为原始类型,即替换成泛型的限定类型。而你代码中定义的任何一个泛型,都具备显式/隐式的限定类型。

2.3.1、验证类型擦除机制

刚刚说到,泛型擦除会干两件事,一是去掉定义的泛型(类型变量),二是将泛型替换为限定类型,现在来对这个结论进行验证:

 

java

代码解读

复制代码

public static void main(String[] args) { // 复用之前案例中的ServerResponse来说明 ServerResponse<String> response = new ServerResponse<>(); response.setData("竹子爱熊猫"); Class<?> clazz = response.getClass(); System.out.println("response对象的类型为: " + clazz.getName()); Field[] fields = clazz.getDeclaredFields(); for (Field field : fields) { // ServerResponse.data字段是使用泛型的字段,获取该字段的类型输出 if ("data".equals(field.getName())) { System.out.println("data字段的类型为: " + field.getType().getName()); } } String data = response.getData(); System.out.println("data字段的值为: " + data); } /* * 执行结果: * response对象的类型为: com.zhuzi.generics.ServerResponse * data字段的类型为: java.lang.Object * data字段的值为: 竹子爱熊猫 * */

这个例子中,先定义了一个泛型为Stringresponse对象,然后获取了该对象的类型,以及泛型字段data的具体类型。尽管在定义response对象时,将其声明成了ServerResponse<String>类型,但从输出结果来看,尖括号里定义的泛型却被移除了……。最后,看到data字段的类型,会发现它并不是预期中的String,而是Object类型,Why?

原因很简单,因为ServerResponse类定义的泛型为T,这里没有显式通过extends关键字来限制类型,所以,T代表无限定的类型变量,就会使用Object来替换。好了,既然data字段的类型会被替换成Object,那为什么调用getData()方法会返回String呢?来看编译后的class文件:

隐式强转

泛型擦除机制会将泛型替换成原始类型,为什么使用泛型的地方却依旧能获取到传入的类型,从上面反编译后的class代码便能得知答案,因为编译器在擦除泛型信息的同时,还会将用到泛型的地方做隐式强转

当然,前面定义的泛型T,你也可以理解成T extends Object,顺着这个思路往下捋:

 

java

代码解读

复制代码

public class ZhuZi<T extends Number> { private T data; }

请问这个例子中,ZhuZi.data字段的类型会被替换成什么?答案显而易见,由于T显式指定了限定类型为Number,所以data字段最终的类型就会是Number,感兴趣可以自己验证下。

2.3.2、反射打破泛型编译校验

从上面得知,Java泛型机制只在编译期有效,在编译期间会对使用泛型的地方进行类型校验,比如你定义了一个List<Integer>对象,往里面添加String元素就会报错。可是到了运行期间,代码里的所有泛型信息会被擦除,意味着类型也不会再校验,那就意味着可以不走寻常路来打破泛型的类型约束。

 

java

代码解读

复制代码

// 正常创建一个整数型集合,并添加一个元素 List<Integer> list = new ArrayList<>(); list.add(123); // 通过反射机制强行调用add()方法塞入字符串 Method add = list.getClass().getMethod("add", Object.class); add.invoke(list, "竹子爱熊猫"); // 遍历list集合并输出所有元素 for (int i = 0; i < list.size(); i++) { System.out.println(list.get(i)); } /* * 执行后的输出结果: * 123 * 竹子爱熊猫 * */

观察上述代码,虽然正常情况下,泛型可以限制add()方法添加的元素类型,可是当面对强大的反射机制时,会发现泛型失去了作用,我们仍然能够将字符串元素添加到list集合。通过这个例子,能进一步佐证上阶段的观点:Java泛型会在运行时被擦除。

PS:虽然反射可以打破泛型约束,但最好不要这么做,如果外部在使用forEach循环遍历该集合,就会造成ClassCastException异常,以上述案例来说,循环时预期对象是Integer类型,结果突然冒出一个String……

当然,这也是Java泛型为什么被叫做伪泛型的根本原因,因为Java泛型是在编译器层面实现的,到了运行期间并不会保留任何泛型信息。与之相对的则是C++泛型,它是一种真正意义上的泛型,C++通过模板实例化来实现泛型机制,编译器会根据每个模板参数生成对应的代码。这使得代码中的泛型信息可以完全保留,而并非局限于编译器有效。

三、泛型的细节与特殊问题

上面的内容对泛型使用及泛型擦除做了讲述,最后来讲一下使用泛型的一些细节与特殊情况,例如泛型支持可变形参:

 

java

代码解读

复制代码

public static <P> void print(P... params) { for (P param : params) { System.out.print(param); } } // 调用时可以传递任意个参数 print("竹子", 2, "熊猫");

3.1、泛型不能指定基础类型

大家学习Java的前几节课,就学到了Java的八大基础数据类型,这也是日常编码使用最多的类型,可当你把泛型指定为基础数据类型时,就会发现编译无法通过:

 

java

代码解读

复制代码

ServerResponse<int> response = new ServerResponse<>();

为啥呢?跟Java泛型的实现原理有关,先来大体捋一下整个泛型的过程:

  • 编码时在类上定义泛型参数,并在类中使用泛型声明字段属性;
  • 使用泛型类、创建类实例对象时,通过<>指定具体类型;
  • 数据写入泛型字段时,编译器会检查值与指定类型是否匹配:
  • 不匹配会提示语法错误,反之则会将给定值当作Object类型存储;
  • 当读取泛型字段时,编译器会隐式将Object强转为指定类型。

为什么泛型不支持基础数据类型,原因就在于最后两步,因为基础数据类型是原始类型,它们没有继承自Object,也没有相应的方法和字段,所以无法强行转换成Object

 

java

代码解读

复制代码

double d1 = 8.8; Object obj = (Object) d1; double d2 = (double) obj;

这是double原始类型和Object类型相互强转的过程,这段代码其实现在能正常执行,毕竟Java有自动拆装箱机制。既然如此,泛型为啥不支持原始类型呢?因为自动拆装箱和泛型都是在Java5引入的,泛型为了保持与旧版本Java代码的兼容性,自然不会允许传递原始类型,而必须要求传递原始类型的包装类型。

3.2、多泛型无法重载方法

指方法名相同、方法形参不同的情况被称为方法重载,而之前提到,泛型可以定义多个,那如果一个类定义了两个泛型,能否基于泛型实现方法重载呢?

 

java

代码解读

复制代码

public class GenericsTest<T1, T2> { public void method(T1 t1) {} public void method(T2 t2) {} }

这个类定义了T1、T2两个泛型,method()分别使用不同的泛型进行重载,但这段代码无法编译通过,因为T1、T2都没有限定类型,所以方法形参最终都会被转成Object类型。这时,尽管两个方法使用不同泛型作为形参,但类型相同就无法满足方法重载的条件,除非其中某个泛型通过extends关键字限定类型。

3.3、方法泛型高于类泛型

类和方法上都可以定义泛型,那么当类和方法的泛型名称重复时,会造成什么场景呢?

 

java

代码解读

复制代码

// 类上定义一个泛型T public class GenericsTest<T> { // 方法上又定义一个泛型T public <T> List<T> newList() { return new ArrayList<T>(); } public static void main(String[] args) { // 类泛型传入String GenericsTest<String> test = new GenericsTest<>(); // 方法泛型传入Integer List<Integer> list = test.<Integer>newList(); } }

来看上述代码,类和方法各自定义了一个泛型T,而后将T作为newList()方法的返回类型,这时看list对象的类型:List<Integer>,这代表啥意思?当泛型命名存在冲突时,方法定义的泛型(类型参数),优先级高于类定义的泛型

3.4、泛型的类型不具备继承性

 

java

代码解读

复制代码

public static void print(List<Number> numbers) { numbers.forEach(System.out::println); }

这里定义了一个打印输出数值集合元素的方法,现在来调用一下:

 

java

代码解读

复制代码

public static void main(String[] args) { List<Number> numbers = new ArrayList<>(); print(numbers); List<Integer> ints = new ArrayList<>(); List<Double> doubles = new ArrayList<>(); print(ints); print(doubles); }

上面定义了三个集合,指定的泛型分别为Number、Integer、Double,接着分别将numbers、ints、doubles分别传入print()方法,可结果很令人意外,将ints、doubles传入print()方法时编译无法通过,这是为什么?答案是为了保证数据安全,来看个例子:

 

java

代码解读

复制代码

List<Integer> ints = new ArrayList<>(); List<Number> numbers = ints; // 假设这是合法的 numbers.add(3.14); // 向List<Integer>中添加一个Double Integer x = ints.get(0); // 运行时错误,因为实际上获取的是一个Double

如果泛型指定的类型支持继承,那么就会出现上述问题,因为IntegerNumber的子类,所以ints重新赋值给numbers完全没问题,而Double也是Number的子类,往numbers里面添加double元素也合理。但要注意:Java中引用对象的赋值,并不是数据的深拷贝,而是指针的传递,为此,这里的numbers本质还是ints,最后直接从ints里获取第一个元素,就会拿到前面塞入的3.14,最终引发类型转换异常。

Java泛型为了避免上述问题,直接从源头打断了泛型指定类型的继承性。当然,虽然给定类型失去了继承性,但数据本身的继承性还在,如:

 

java

代码解读

复制代码

List<Number> numbers = new ArrayList<>(); numbers.add(1); numbers.add(1.1); numbers.add(1L);

上面定义了一个Number类型的集合,这时往其中添加int、double、long……这种数值元素都能正常编译。

3.5、泛型不能用于实例化

泛型赋予了Java语言动态性,那能否直接基于泛型去创建具体的对象呢?例如:

 

java

代码解读

复制代码

public class GenericsTest<E> { private E element; private E[] elements; public E init() { // 实例化一个泛型对象与泛型数组(编译无法通过) element = new E(); elements = new E[16]; } }

这样的想法很好,可是编译却无法通过,道理很简单,因为Java泛型只在编译期有效,而new这个实例化对象的动作恰恰发生于运行期间,但这时泛型信息已经被移除了,所以没有办法实例化具体对象(包括数组)。

除此之外,如果你想创建一个泛型对象数组(专业叫法称为参数化泛型数组),这也是不行的,即:

 

java

代码解读

复制代码

// 编译无法通过 List<Number>[] lists = new ArrayList<>[10];

为什么语法糖不允许这么写,具体原因跟3.4类似,Java数组是协变的,如果IntegerNumber的子类型,那么Integer[]也会是Number[]的子类型,一旦允许创建泛型数组,又会导致类型安全性问题出现。

3.6、无法直接获取泛型的类型

泛型的本质是将类型参数化,而作为一个类型,理论上可以直接获取到它的class,例如:

 

java

代码解读

复制代码

Class<Object> clazz = Object.class;

但由于泛型擦除机制的存在,我们并不能直接这样获取泛型的class

 

java

代码解读

复制代码

public class GenericsTest<T> { // 无法通过编译 private Class<T> clazz = T.class; }

所以,如果想要获取到使用泛型时传递的具体类型,只能基于数据去获取,或者外部显式传递进来,就像这样:

 

java

代码解读

复制代码

public class GenericsTest<T> { private T data; private Class<?> clazz; // 基于泛型数据来获取class对象 public void initClass() { if (null != this.data) { clazz = this.data.getClass(); } } // 基于构造器让使用者显式传递 public GenericsTest(Class<?> clazz) { this.clazz = clazz; } }

在实现某些逻辑需要拿到具体的类型时,只能通过这两种方式获取。同理,因为泛型信息会在运行期间移除,那判断类型时也无法判断泛型:

 

java

代码解读

复制代码

GenericsTest<String> test = new GenericsTest<>(); // 编译报错,因为运行期间没有泛型信息 if (test instanceof GenericsTest<String>) {}

如果需要判断一个泛型对象的类型,则只能使用无界通配符来描述,即test instanceof GenericsTest<?>

3.7、泛型封装通用方法实战

好了,前面讲了许多泛型的细节与特殊问题,最后来基于泛型封装一个常用、通用的方法,即Bean拷贝场景,在日常编码设计中,都会将对象分为BO、VO、DTO、DO、PO……各种模型,为了满足不同业务,数据会在这些对象之间流转。

可是挨个属性Get/Set属实麻烦,在平时大家使用较多的就是Spring提供的BeanUtils这个工具类,但这个工具类用起来还是有点繁琐,比如:

 

java

代码解读

复制代码

User user = userMapper.selectById(userId); UserVO userVO = new UserVO(); BeanUtils.copyProperties(user, userVO);

正如上述所示,每次都得手动new出目标对象才行,而且BeanUtils也没提供集合拷贝的方法,因此,我们就可以基于泛型封装两个通用方法:

 

java

代码解读

复制代码

/** * Bean拷贝工具类 */ public class BeanCopyUtil { /* * 拷贝单个Bean对象 * */ public static <T> T copy(Object source, Class<T> clazz) { if (null == source) { return null; } T target; try { target = clazz.newInstance(); } catch (InstantiationException | IllegalAccessException e) { throw new RuntimeException("bean copy exception: " + e.getMessage()); } BeanUtils.copyProperties(source, target); return target; } /* * 拷贝Bean对象集合 * */ public static <T> List<T> copyList(List<T> sourceList, Class<T> clazz) { if (null == sourceList || 0 == sourceList.size()) { return null; } List<T> targetList = new ArrayList<>(); for (T source : sourceList) { T target = copy(source, clazz); targetList.add(target); } return targetList; } }

基于这两个封装的方法,能特别方便的应对平时Bean拷贝场景,用起来也格外简单:

 

java

代码解读

复制代码

// 拷贝单个Bean对象 User user = userMapper.selectById(userId); UserVO result = BeanCopyUtil.copy(user, UserVO.class); // 拷贝Bean对象集合 List<User> users = userMapper.selectList(); List<UserVO> results = BeanCopyUtil.copyList(users, UserVO.class);

四、Java泛型机制总结

OK,前面的内容,从一开始的泛型基本知识,聊到了泛型边界约束、泛型擦除机制,以及到后面的泛型使用细节与常见问题,本篇基本对Java泛型有了全面覆盖,无论你之前是否真正掌握了泛型,相信认真读完所有内容后,一定对泛型有了更深层次的理解。

泛型作为Java语言非常重要的特性,它能极大地增强语言表达能力,也能避免封装通用时,类型强制转换带来的繁琐步骤,还能提高代码安全性、灵活性与复用性。不过本文只在最后给出了一个泛型实战案例,在日常编码中,泛型有更为广泛的使用场景,比如与Java8的新特性结合等等,而这些则需要诸位在日常工作中慢慢探索啦~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值