Java Generics——泛型
泛型,是一种方法,更是一种思想,它的目的在于“泛化”的表达,通过它可以创建出通用性更好的代码,能够用于更多的类型,并提供相应的类型安全保障,尽管与潜在类型机制还有一定的差距。
0 Java的泛型
Java泛型的核心在于:告诉编译器想使用什么类型,然后编译器帮你处理一切细节。
即在使用泛型的时候,只需要指定他们的名称以及类型参数列表即可。
Java泛型的特点:
Java泛型是通过运行时的擦除实现的,即在使用泛型时,任何具体的类型信息都会被擦除,唯一知道的是你在使用一个对象(却不知道它实际的类型)。
例如,
List<String>与List<Integer>在运行时,实际上通过擦除成为了相同的类型——List。只有在从容器中取出元素的时候,会执行类型检查。
Java泛型无法使用基本类型作为类型参数,也即Java泛型的局限性
好在Java SE5之后具备了基本类型的包装类以及自动打包和自动拆包的工作机制。
一个类不能够实现同一个泛型接口的两种变体,因为泛型擦除会使得这两种变体成为相同的接口。
|
|
- 由于类型擦除的原因,泛型方法不能以类型参数不同区分,如果被擦除的参数不能产生唯一的参数列表时,必须以明显区别的方法名作区分。
1 元组的概念
有时候需要“调用一次方法,就可以返回多个对象”的需求,但是return一次只能返回一个对象,怎么办?
这时候就需要“元组”——创建一个对象,用它来持有所需要返回的多个对象。
元组的特点:
从概念上看,元组相当于一个具有类型的容器;
元组对象允许读取其中的元素,但是不允许向其中存放新的对象,即元组中的属性域将会使用
final关键字进行修饰,因此元组也称为数据传送对象或信使;因此,若想使用不同类型元素的元组,则需要另外创建新的元组对象。
元组可以具有任意长度,且元组中的对象可以具有不同的类型;
元组隐含地保持其中元素的次序;
可以使用继承机制实现更长的元组。
|
|
PS. 后续将会介绍更通用的设计模式——适配器模式。
2 泛型实现简单的堆栈类
利用泛型实现简单的内部链式存储机制:
|
|
3 泛型接口
泛型接口将会提供类型待定、更加泛化的方法。
|
|
上述是自定义的一个泛型接口,用于生成特定类型的对象。
但是一开始,谁知道它是做什么用的呢?但是为什么不定义成特定类型的接口呢?
|
|
可以看到,普通的、具有特定类型信息的接口,只能规定特定返回类型的方法,或者只能生成特定类型的对象,而泛型接口可以根据需要,通过不同的实现生成和返回不同类型的对象。
而且,提供一个具有意义的名字的泛型接口,让具有相同操作的类来实现,也是一件规范易读且有意义的事情。
4 泛型方法
是否拥有泛型方法,与其所在的类是否是泛型没有关系
泛型方法使得方法本身能够独立于类而产生变化。
泛型类VS泛型方法:
使用泛型类,必须在创建对象的时候指定类型参数的值;使用泛型方法,则不必指明参数类型,编译器可以由类型参数推断(Type Argument Inference),通过传入方法的参数的类型,替我们找出具体的类型。
那么,什么时候使用泛型方法呢?基本的原则是:无论何时,只要能够做到,就尽量使用泛型方法。即如果使用泛型方法可以取代整个类的泛型化,那么就单纯使用泛型方法即可。
|
|
但是,类型参数推断有一个缺点:只对赋值操作有效,也即只有在赋值操作的时候才能检测参数类型,如果将一个泛型方法调用的结果作为方法的参数传递给另一个方法,那么编译器不会推断出该结果的类型。(也即,需要先赋值,再将赋值的结果作为参数传递出去)
可变参数的泛型方法:
|
|
5 构建复杂模型
类似于集装箱运输,一层一层地将货物组合起来,然后包装起来,最后装船运输,复杂模型也是从最简单的类开始,依次将某一些类,作为另一个类的类型参数,然后再作为其他类的类型参数,依次“包装”起来,形成一个更为复杂的模型。
|
|
6 泛型的擦除 边界 通配符
6.1 擦除
在泛型代码内部,无法获得任何有关泛型参数类型的信息。
擦除是有历史渊源的,即为了兼容最初未使用泛型的Java代码,因此擦除的操作降低了Java泛型的“泛化”程度。
在使用Java泛型的时候,任何具体的类型信息都被擦除,唯一知道的只是在使用一个对象(Object)。
可以使用关键字extends,在给定泛型类型使用范围的同时,告诉编译器泛型擦除的边界。
例如: <T extends Pet>
泛型类型参数将会擦除到它的第一个边界(也即,可以有多个边界),即<T extends Pet>擦除之后,相当于在类的声明里用Pet代替T一样。
由于擦除在方法体中移除了类型信息,所以运行时的关注点和常出现问题之处就在于——边界:即对象进入和离开方法的位置,因为这正是编译器在编译期执行类型检查并插入转型代码的位置——对传递进来的值进行额外的编译期检查,对传递出去的值插入相应的转型类型信息。
总之,边界是泛型中所有动作发生的地方。
6.2 边界
关键字extends在泛型中重用之后,其意义虽然与继承中的意义完全不同,但是用法上还是类似的。
例如:
|
|
extends后需先接class,后接interface;extends后只能接一个类,但是可以接多个接口,形成多边界。
6.3 通配符
Java泛型中的通配符(Wildcards)主要有三种用法:
- 上界通配符(Upper Bounds Wildcards),
<? extends T> - 下界通配符(Lower Bounds Wildcards),
<? extends T> - 无界通配符,
<?>
|
|
为什么要使用通配符?即使容器的类型参数之间具有继承关系,在程序运行的时候类型信息是会被擦除的,所以类型参数的继承关系,并不能带给容器本身,即不能将Holder<Apple>的引用传递给Holder<Fruit>,因为编译器的逻辑是:
- 苹果
IS-A水果 - 装苹果的容器
NOT-IS-A装水果的容器
所以,需要使用带有通配符的类型参数,让“装苹果的容器”与“装水果的容器”发生关系。
上界通配符
即Holder<? extends T>,表示:一个能放置类型为T以及一切类型为T的派生类的容器,所以Holder<? extends Fruit>也即表示:能放置所有水果类型的容器。
所以,Holder<? extends Fruit>是Holder<Fruit>和Holder<Apple>的基类,所以可以有以下操作:
|
|
下界通配符
即Holder<? super T>,表示:一个能放置类型为T以及一切类型为T的基类的容器,所以Holder<? super Fruit>也即表示:能放置类型为水果或者类型为水果基类的东西的容器。
如果有class Fruit extends Food {},那么有以下操作:
|
|
上界VS下界
上界<? extends T>修饰的容器,不能往里存,只能往外取
|
|
Holder<? extends Fruit>是Holder<Apple>的基类;set()方法的参数类型是? extends Fruit,所以编译器不能从这里了解具体是Fruit的哪一个类型,所以直接拒绝;根据类型擦除,
<? extends Fruit>将会被擦除成<Fruit>,所以在将容器中对象取出来的时候,类型Fruit将会被插入给对象,即取出的对象类型是Fruit,所以只能用Fruit以及Fruit的基类引用盛放。也可以这么理解,由于
<? extends Fruit>可能存放Fruit以及Fruit的派生类,为了类型安全,不能使用任何一种Fruit的派生类的引用盛放取出的对象,只能使用Fruit以及Fruit的基类引用盛放。
下界<? super T>修饰的容器,可以往里存,但是往外取只能放在Object对象中(即丢失类型信息)
|
|
Holder<? super Fruit>是Holder<Fruit>的基类,也是其下界,但是这个下界到Fruit为止,不包括Holder<Apple>,所以不能将Holder<Apple>赋值给holder3;- 由于
<? super Fruit>表示下界是Fruit,往上都是Fruit的基类,所以Fruit以及Fruit的派生类都可以利用set()方法,放心地存进该容器中; - 由于
<? super Fruit>表示的是Fruit以及Fruit的基类,往上最远可达Object类,所以为了从容器中取出对象的类型安全,只能使用Object类的引用盛放取出的对象。
PECS原则
PECS(Peoducer Extends Consumer Super)
- 频繁往外读取内容的,适合使用上界通配符;
- 经常往里插入内容的,适合使用下界通配符。
无界通配符
即<?>,意味着任何事物,使用起来的效果与使用原生类型一样,但是意义上是有区别的:List<?>表示“持有某种特定类型而非原生类型的List,只是不知道是什么类型”,而List则单纯表示“持有任何Object类型的原生List”。所以,使用List<?>更为具体,表示程序员已经经过思考才这么Coding的。
在捕获转换的情况下,特别需要使用无界通配符而不是原生类型。
捕获转换,即如果向一个使用<?>的方法传递原生类型,编译器可能会推断出实际的类型参数,使得这个方法可以回转并调用另一个使用该类型的方法。
|
|
7 潜在类型机制
也称结构化类型机制,又称鸭子类型机制——“如果它走起路来像鸭子,叫声也像鸭子,那就把它当作鸭子看待”,言下之意就是:只要你有特定的方法就行,至于你本质是什么特定类型,我不关心。
可见,潜在类型机制本质上就是一种代码组织和复用机制,追求的是代码的终极“泛化”和极致“复用”——编写一次,多次使用。
两种支持潜在类型机制的是C++(静态类型语言,编译期执行类型检查)和Python(动态类型语言,所有类型检查发生在运行时)。
Java没有潜在类型机制。
Java对潜在类型机制的补偿:
反射机制与动态代理,实现运行时的类型信息获取与类方法的动态代理调用,但反射机制将类型检查都推到了运行时,所以需要使用相应的
try语句捕获可能出现的异常;设计模式——适配器模式
通过解读潜在类型机制的含义(不关心具体类型,只需要你具有相应的方法即可),可以知道潜在类型机制本质上是创建了一个包含所需方法的隐式接口,那么只要我们基于现用的东西,再手动编写、新增相应所需的接口,那应该就可以完美的解决问题。
基于现有接口,编写代码产生所需的接口,即适配器模式。
可能当前接口中的方法或者方法的参数没有你所需的,你就需要继承/实现基类/原有接口,并在他们上面扩展成你所需的方法,然后在传参数的时候再将你编写的类的对象传递进去,这样在合适的地方就会根据对象的类型调用你自己所实现的方法。
8 总结
泛型类型机制最大的优点在于,可以使用丰富多样的容器类。
泛型使用原则:当你希望使用的类型参数比某个具体类型(以及它的所有子类)更加“泛化”的时候——即当你希望写出的代码能够跨多个类工作的时候,泛型才是正确的选择。
所以,有必要查看所有的代码,以确定它是否“足够复杂”到必须使用泛型的程度。
由于Java泛型的非天生性,存在很多的问题,但是也催生出了很多的解决方式,比如反射机制,适配器模式,这些方法是熟练使用Java泛型所必须掌握的技能。
附:
1 自限定类型
|
|
含义是:创建一个新类,其继承自一个泛型类型,该泛型类型的类型参数则是新创建的类。
类似于C++中的古怪的循环泛型(CRG):基类用导出类替代其参数,意味着泛型基类变成了一种其所有导出类的公共功能的模板。
自限定的意义在于,在继承关系中保证类型参数必须与正在被定义的类相同,即只能用于继承关系中。
自限定类型的价值在于,可以产生协变参数类型——即方法参数类型会随着子类而变化 。
2 动态类型安全
java.util.Collections中的工具可以帮助检查类型安全问题。
静态方法,例如:checkCollection()、checkList()、checkMap()、checkSet()、checkSortedMap、checkSortedSet,将希望动态检查的容器作为方法的第一个参数,希望动态检查的类型作为方法的第二个参数,之后受检查的容器将会在试图插入类型不正确的对象的时候抛出ClassCastException异常,而一般情况下只有从容器中取出对象的时候才会检测类型的不正确并抛出异常。
3 泛型用于动态异常检测
泛型的类型参数可以用在一个方法的throws子句中,从而编写出随着检查出的异常的类型而变化的动态泛型代码。