泛型是Java中重要的知识点,本文就是对泛型相关内容的整理。
泛型解决的问题
泛型并不是从一开始就有的功能。在没有泛型的时候如果要使用集合类需要按下面的方式来使用:
1 | // 定义一个List,add()可以存放Object及其子类实例 |
在没有泛型的时候,List可以存放Object及其子类,相当于可以存放任何对象。在执行add
方法的时候当然没有任何问题,但是在取出数据进行强制类型转换的时候就很容易出现转换失败的异常。由于这种异常在编译期无法被检查出来,因此对程序造成了不小的隐患。
为什么会出现这种问题呢?
- 集合本身无法对其存放的对象类型进行限定,可以涵盖Java中的所有类型。缺口太大,导致各种蛇、蚁、虫、鼠通通都可以进来。
- 由于我们要使用实际存放的类型,所以不可避免地要进行类型转换。小对象转大对象很容易,大对象转小对象则有很大的风险,因为在编译时,我们无法得知对象真正的类型。
泛型就是为了解决这类问题而诞生的。
泛型的定义和使用
泛型,即参数化类型,是在JDK1.5之后才开始引入的。所谓参数化类型,是指所操作的数据类型在定义时被指定为一个参数,然后在使用时传入具体的类型。这种参数类型可以用在类、接口和方法的创建中,分别被称为泛型类、泛型接口和泛型方法。
泛型类
一个泛型类(generic class)就是具有一个或多个类型变量的类。泛型类的定义类似下面的代码:
1 | public class ClassName<T1, T2> { |
注意,在Java编码规范中,类型变量通常使用较短的大写字母,并且最好与其作用相匹配。譬如:List中的变量使用E,对应单词Element,Map中的K, V变量对应单词Key和Value。当然这些都是约定性质的东西,其实类型变量的命名规则与Java中的普通变量命名规则是一致的。
泛型类的使用如下所示:
1 | ClassName<Integer, String> className = new ClassName<>(); |
泛型接口
接口本质上就是一种特殊的类,所以泛型接口的定义和使用与泛型类型相差无几。下面的代码是泛型接口的定义和使用:
1 | public interface InterfaceName<T1, T2> { |
从上面的例子可以看出,如果实现一个泛型接口,可以在定义时直接传入具体的类型(如T1传入String),也可以继续传入一个类型,待使用时再确认具体的类型。
泛型方法
泛型类和泛型接口的类型变量都是定义在类型级别的,其作用域可覆盖成员变量和成员方法。泛型方法的类型参数定义在方法签名中,一个典型的泛型方法定义如下:
1 | public <T> T getObject(Class<T> clz) throws Exception { |
上面代码中的<T>
表示这是一个泛型方法,T
是仅作用于getObject
方法上的类型变量。在调用这个方法时,传入具体的类型。
1 | String newStr = generic.getObject(String.class); |
泛型的实现原理
泛型思想最早在C++语言的模板(Templates
)中产生,Java后来也借用了这种思想。虽然思想一致,但是他们存在着本质的不同。C++中的模板是真正意义上的泛型,在编译时就将不同模板类型参数编译成对应不同的目标代码,ClassName<String>
和ClassName<Integer>
是两种不同的类型,这种泛型被称为真正的泛型。这种泛型实现方式,会导致类型膨胀,因为要为不同具体参数生成不同的类。
Java中的ClassName<String>
和ClassName<Integer>
虽然在源代码中属于不同的类,但是编译后的字节码中,他们都被替换成原始类型ClassName
,而两者的原始类型是一样的,所以在运行环境中,ClassName<String>
和ClassName<Integer>
就是同一个类。Java中的泛型是一种特殊的语法糖,通过类型擦除实现,这种泛型称为伪泛型。由于Java中有这么一个障眼法,如果没有深入研究,就会产生莫名其妙的问题。
类型擦除与自动类型转换
Java中的泛型是通过类型擦除来实现的。所谓类型擦除,是指通过类型参数合并,将泛型类型实例关联到同一份字节码上。编译器只为泛型类型生成一份字节码,并将其实例关联到这份字节码上。类型擦除的关键在于从泛型类型中清除类型参数的相关信息,并且在必要的时候添加类型检查和类型转换的方法。
我们直接通过字节码来看看Java是如何处理类型擦除以及自动类型转换的。
代码如下:
1 | public class GenericTest { |
字节码如下所示:
1 | public class generic.GenericTest { |
从14和22行的代码我们看到,List
的add
和get
方法参数都是Object
,而不是我们在代码中定义的Integer
,这就是所谓的泛型擦除。
从27行我们看到,调用get
方法从List
中获取数据之后调用了checkcast
来将Object
类型的对象强制转换为Integer
对象。这也就是为什么我们可以写Integer test = list.get(0);
这样的语句都不需要手动将Object
对象转换为Integer
对象。
上面代码的效果和下面代码的效果是一模一样的:
1 | List list = new ArrayList(); |
因此我们知道,泛型并不会提供运行时效率,不过可以大大降低编程时的出错概率。
泛型与继承
在使用泛型时,引用的参数类型与实际对象的参数类型要保持一致(通配符除外),就算两个参数类型是继承关系也是不允许的。看看下面两行代码,它们均不能通过编译。
1 | ArrayList<String> arraylist1 = new ArrayList<Object>(); |
下面来探讨一下为什么不能这么做:
第一种情况。如果这种代码可以通过编译,那么调用get()
方法返回的对象应该是String
,但它实际上可以存放任意Object
类型的对象,这样在调用类型转换指令时会抛出ClassCastException
。这样可能不是那么明显,来看看下面的代码。arrayList1
中实际存放的Object对象,所以在进行类型转换时会抛出异常。这原本就是泛型想要极力避免的问题,所以Java不允许这种写法。
1 | ArrayList<Object> arrayList1 = new ArrayList<>(); |
第二种情况。虽然String
类型的对象转换为Object
不会有任何问题,但是这有什么意义呢?我们原本想要用String
对象的方法,但最终将其赋予了一个Object
类型的引用。如果需要使用String
中的某些方法,必须将Object
强制转换为String
。这样不会抛出异常,但是却违背了泛型设计的初衷。
泛型与多态
普通类型的多态是通过继承并重写父类的方法来实现的,泛型也不例外,下面是一个泛型多态实例:
1 | public class Father<T> { |
上面定义了一个泛型父类和一个实际参数为String
类型的子类,并”重写”了set(T)
和get()
方法。Son
类中的@Override
注解也清楚地显示这是一个重写方法,最终执行的结果也与想象中的结果完全一致。
1 | I am father, t = hello world |
虽然表面上看,泛型多态与普通类的多态并无二致,但是其内部的原理却大相径庭。
我们知道,泛型类Father
在编译后会擦除泛型信息,所有的泛型参数都会用Object
类替代。实际上编译后的字节码与下面的代码完全一致:
1 | public class Father { |
Father
和Son
类的set()
方法的参数类型不一样,所以,这并不是方法重写,而是方法重载!但是,如果是重载,那么Son
类就应该会继承Father
类的set(Object)
方法,也就是Son
会同时包含set(String)
和set(Object)
,下面来测试一下:
1 | Son son = new Son(); |
当set一个Object
对象时,编译无法通过。说明实际情况和我们分析的有些出入,我们直接从Son
编译后的字节码中来寻找答案:
1 | public generic.extend.Son(); |
从字节码中我们可以看到,除了void set(String)
和String get()
两个方法以外,还出现了void set(Object)
和Object get()
两个方法,这两个方法在Son
源代码里并不存在,这是编译器为了解决泛型的多态问题而自动生成的方法,称为”桥方法”。这两个方法的签名与Father
类中的两个方法的签名完全一致,这才是真正的方法重写。也就是说,子类真正重写的是我们看不到的桥方法。@Override
注解只是假象,让人误认为他们真的是重写方法。
泛型通配
所谓泛型通配,是指在声明泛型类型变量时,可以不必直接指定具体的泛型,而可以使用通配符来表示一系列类型。通配符有2中:
- 无边界通配符,用
<?>
表示 - 有边界通配符,用
<? extends Object>
或者<? super extends Object>
来表示。(Object仅仅是一个示例)
为什么需要通配呢?先看下面的一个示例。
1 | public class WhyNeedWildcard { |
我们的本意是想创建一个能够打印所有集合对象中的元素的方法,但是泛型参数不支持继承,所以编译不通过。解决这个问题的办法就是使用通配符。
1 | public class WhyNeedWildcard { |
无边界
无边界的通配符用<?>
表示,上面的例子就是使用的无边界通配符。下面看看哪些是合法的,哪些是不合法的。
1 | List<?> list1 = new ArrayList<String>(); // 合法 |
对于带有通配符的引用变量,是不能调用具有与泛型参数有关的方法的。
1 | List<?> list1 = new ArrayList<String>(); |
总结起来,无边界通配符主要用作引用,可以调用与泛型参数无关的方法,不能调用参数中包含泛型参数的方法。
有边界
有边界的通配符会对引用的泛型类型进行限定,包括:上边界通配和下边界通配。
上边界通配,用<? extends 类型>
表示。其语法为:
1 | List<? extends 类型1> x = new ArrayList<类型2>(); |
其中,类型2就只能是类型1或者是类型1的子类。下面代码验证合法性:
1 | List<? extends Number> x = new ArrayList<Integer>(); // 由于Integer是Number的子类,这是合法的 |
下边界通配,用<? super 类型>
表示。其语法为:
1 | List<? super 类型1> x = new ArrayList<类型2>(); |
其中,类型2就只能是类型1或者是类型1的超类。下面代码验证合法性:
1 | List<? super Integer> x = new ArrayList<Number>(); // 由于Number是Integer的超类,这是合法的 |
那么到底什么时候使用下边界通配,什么时候使用上边界通配呢?首先考虑一下怎样才能保证不会发生运行时异常,这是泛型要解决的首要问题,通过前面的内容可以看到,任何可能导致类型转换异常的操作都无法编译通过。
上边界通配:可以保证存放的实际对象至多是上边界指定的类型,那么在读取对象时,我们总是可以放心地将对象赋予上边界类型的引用。
1 | List<Integer> list1 = new ArrayList<>(); |
下边界通配:可以保证存放的实际对象至少是下边界指定的类型,那么在存入对象时,我们总是可以放心地将下边界类型的对象存入泛型对象中。
1 | List<? super Integer> list3 = new ArrayList<Number>(); |
总结起来就是:
- 如果你想从一个数据类型里获取数据,使用
? extends
通配符 - 如果你想把对象写入一个数据结构里,使用
? super
通配符 - 如果你既想存,又想取,那就别用通配符
这就是《Effective Java》书中所说的PECS法则(Producer Extends, Consumer Super),Collections
工具类中的copy
方法就完美地诠释了这个法则。
1 | public static <T> void copy(List<? super T> dest, List<? extends T> src) {} |
这个方法的作用是将src
列表完整地拷贝到dest
列表中,src
是原始列表,我们需要读取其中的元素,所以它是生产者,需要使用extends
通配;dest
是目标列表,需要将读取出来的元素存入这个列表中,所以他是消费者,使用super
通配。
http://hinylover.space/2016/06/25/relearn-java-generic-1/
http://hinylover.space/2016/07/03/relearn-java-generic-2/