“泛型”这个术语的意思是:"适用于许多许多的类型”。如何做到这一点呢,正是通过解耦类或方法与所使用的类型之间的约束。
1.与C++的比较
Java中的泛型就需要与C++进行一番比较,理由有二:首先,了解C++模板的某些方面,有助于你理解泛型的基础。同时,非常重要的一点是,你可以了解Java泛型的局限是什么,以及为什么会有这些限制。最终的目的是帮助你理解,Java泛型的边界在哪里。根据我的经验,理解了边界所在,你才能成为程序高手。因为只有知道了某个技术不能做到什么,你才能更好地做到所能做的(部分原因是,不必浪费时间在死胡同里乱转)。
第二个原因是,在Java社区中,人们普遍对C++模板有一种误解,而这种误解可能会误导你,令你在理解泛型的意图时产生偏差。
2.简单泛型
有许多原因促成了泛型的出现,而最引人注目的一个原因,就是为了创造容器类。
泛型的主要目的之一就是用来指定容器要持有什么类型的对象,而且由编译器来保证类型的正确性。
2.1 一个元组类库
元组(tuple), 它是将一组对象直接打包存储于其中的一个单一对象。这个容器对象允许读取其中元素, 但是不允许向其中存放新的对象。
2.2 一个栈类
2.3 RandomList
3.泛型接口
泛型也可以应用于接口。例如生成器(generator),这是一种专门负责创建对象的类。实际上,这是工厂方法设计模式的一种应用。不过,当使用生成器创建新的对象时,它不需要任何参数,而工厂方法一般需要参数。也就是说,生成器无需额外的信息就知道如何创建新对象。
Java泛型的一个局限性:基本类型无法作为类型参数。不过,Java SE5具备了自动打包和自动拆包的功能,可以很方便地在基本类型和其相应的包装器类型之间进行转换。
4.泛型方法
同样可以在类中包含参数化方法, 而这个方法所在的类可以是泛型类,也可以不是泛型类。也就是说,是否拥有泛型方法,与其所在的类是否是泛型没有关系。
一个基本的指导原则:无论何时,只要你能做到,你就应该尽址使用泛型方法。也就是说,如果使用泛型方法可以取代将整个类泛型化,那么就应该只使用泛型方法,因为它可以使事情更清楚明白。另外,对于一个static的方法而言,无法访间泛型类的类型参数,所以,如果static方法需要使用泛型能力,就必须使其成为泛型方法。
注意,当使用泛型类时,必须在创建对象的时候指定类型参数的值,而使用泛型方法的时候,通常不必指明参数类型,因为编译器会为我们找出具体的类型。这称为类型参数推断(type argument inference)。因此,我们可以像调用普通方法一样调用f(),而且就好像是们被无限次地重载过。
如果调用f()时传入基本类型,自动打包机制就会介入其中,将基本类型的值包装为对应的对象。事实上,泛型方法与自动打包避免了许多以前我们不得不自己编写出来的代码。
4.1 杠杆利用类型参数推断
类型推断只对赋值操作有效,其他时候并不起作用。
显式的类型说明
要显式地指明类型,必须在点操作符与方法名之间插入尖括号,然后把类型置于尖括号内。如果是在定义该方法的类的内部,必须在点操作符之前使用this关键字,如果是使用static的方法,必须在点操作符之前加上类名。
当然,这种语法抵消了New类为我们带来的好处(即省去了大证的类型说明),不过,只有在编写非赋值语旬时,我们才需要这样的额外说明。
4.2 可变参数与泛型方法
4.3 用于Generator的泛型方法
利用生成器, 我们可以很方便地填充一个Collection。
4.4 一个通用的Generator
这个类提供了个基本实现,用以生成某个类的对象。这个类必需具备两个特点:
- (1)它必须声明为public。(因为BasicGenerator与要处理的类在不同的包中,所以该类必须声明为 public, 并且不只具有包内访问权限。)
- (2)它必须具备默认的构造器(无参数的构造器)。要创建这样的BasicGenerator对象,只需调用create()方法,并传人想要生成的类型。
4.5 简化元组的使用
有了类型参数推断, 再加上static方法, 我们可以重新编写之前看到的元组工具, 使其成为更通用的工具类库。
4.6 一个Set实用工具
用Set来表达数学中的关系式。通过使用泛型方法,可以很方便地做到这一点,而且可以应用于多种类型。
5.匿名内部类
泛型还可以应用于内部类以及匿名内部类。
6.构建复杂模型
泛型的一个重要好处是能够简单而安全地创建复杂的模型。
7.擦除的神秘之处
当你开始更深入地钻研泛型时, 会发现有大景的东西初看起来是没有意义的。 例如, 尽管 可以声明ArrayList.class, 但是不能声明ArrayList<lnteger>.class。
在泛型代码内部, 无法获得任何有关泛型未数类型的信息。
Java泛型是使用擦除来实现的, 这意味若当你在使用泛型时, 任何具体的类型信息都被擦除了, 你唯一知道的就是你在使用一个对象。 因此List<String>和List<lnteger>在运行时事实上 是相同的类型。 这两种形式都被擦除成它们的 “原生” 类型, 即List。理解擦除以及应该如何处理它, 是你在学习Java泛型时面临的最大障碍。
7.1 C++的方式
泛型类型参数将擦除到它的第一个边界。
不能因此而认为<T extends HasF>形式的任何东西而都是有缺陷的。例如,如果某个类有一个 返回T的方法,那么泛型就有所帮助,因为它们之后将返回确切的类型。
7.2 迁移兼容性
为了减少潜在的关于擦除的混淆,你必须清楚地认识到这不是一个语言特性。它是Java的 泛型实现中的一种折中,因为泛型不是Java语言出现时就有的组成部分,所以这种折中是必需的。
泛型在Java中仍旧是有用的,只是不如它们本来设想的那么有用,而原因就是擦除。
在基于擦除的实现中,泛型类型被当作第二类类型处理,即不能在某些重要的上下文环境中使用的类型。泛型类型只有在静态类型检查期间才出现,在此之后,程序中的所有泛型类型都将被擦除,替换为它们的非泛型上界。例如,诸如List<T>这样的类型注解将被擦除为List,而普通的类型变量在未指定边界的情况下将被擦除为Object。
擦除的核心动机是它使得泛化的客户端可以用非泛化的类库来使用,反之亦然,这经常被称为 迁移兼容性 。通过允许非泛型代码与泛型代码共存,擦除使得这种向着泛型的迁移成为可能。
7.3 擦除的问题
擦除主要的正当理由是从非泛化代码到泛化代码的转变过程,以及在不破坏现有类库的情况下,将泛型融人Java语言。
擦除的代价是显著的。泛型不能用于显式地引用运行时类型的操作之中,例如转型instanceof操作和new表达式。因为所有关于参数的类型信息都丢失了,无论何时,当你在编写 泛型代码时,必须时刻提醒自己,你只是看起来好像拥有有关参数的类型信息而已。
7.4 边界处的动作
注意, 对于在泛型中创建数组, 使用Array.newInstance()是推荐的方式。
即使擦除在方法或类内部移除了 有关实际类型的信息, 编译器仍旧可以确保在方法或类中使用的类型的内部一致性。
因为擦除在方法体中移除了类型信息, 所以在运行时的问题就是边界:即对象进人和离开方法的地点。 这些正是编译器在编译期执行类型检查并插人转型代码的地点。
8.擦除的补偿
正如我们看到的,擦除丢失了在泛型代码中执行某些操作的能力。任何在运行时需要知道确切类型信息的操作都将无法工作。
偶尔可以绕过这些问题来编程,但是有时必须通过引人类型标签来对擦除进行补偿。这意味着你蒂要显式地传递你的类型的Class对象,以便你可以在类型表达式中使用它。
8.1 创建类型实例
在Erased.java中对创建一个newT()的尝试将无法实现,部分原因是因为擦除,而另一部分原因是因为编译器不能验证T具有默认(无参)构造器。但是在C++中, 这种操作很自然、很直观,井且很安全(它是在编译期受到检查的)。
Java中的解决方案是传递一个工厂对象,井使用它来创建新的实例。 最便利的工广对象就是 Class对象, 因此如果使用类型标签, 那么你就可以使用newInstance()来创建这个类型的新对象。
8.2 泛型数组
一般的解决方案是在任何想要创建泛型 数组的地方都使用ArrayList。
数组将跟踪它们的实际类型, 而这个类型是在数组被创建时确定的, 因此,即使gia已经被转型为Generic<lnteger>[], 但是这个信息只存在千编译期(并且如果没有@Suppress Warnings注解, 你将得到有关这个转型的警告)。 在运行时, 它仍旧是Objec数组, 而这将引发问题。
成功创建泛型数组的唯一方式就是创建一个被擦除类型的新数组, 然后对其转型。
最好是在集合内部使用Object[],然后当你使用数组元素时,添加一个对T的转型。
没有任何方式可以推翻底层的数组类型,它只能是Object[]。在内部将array当作Object[] 而不是T[]处理的优势是:我们不太可能忘记这个数组的运行时类型,从而意外地引人缺陷。
类型标记Class<T>被传递到构造器中,以便从擦除中恢复,使得我们可以创建需要的实际类型的数组,尽管从转型中产生的警告必须用@SuppressWarnings压制住。一旦我们获得了实际类型,就可以返回它,并获得想要的结果,就像在main()中看到的那样。该数组的运行时类型是确切类型T[]。
Neal Gafter (Java SES的领导开发者之一)在他的博客中指出,在重写Java类库时,他十 分懒散,而我们不应该像他那样。Neal还指出,在不破坏现有接口的情况下,他将无法修改某些Java类库代码。因此,即使在Java类库源代码中出现了某些惯用法,也不能表示这就是正确的解决之道。当查看类库代码时,你不能认为它就是应该在自己的代码中遵循的示例。
9.边界
边界使得你可以在用于泛型的参数类型上设置限制条件。尽管这使得你可以强制规定泛型可以应用的类型,但是其潜在的一个更重要的效果是你可以按照自己的边界类型来调用方法。
为了执行这种限制, Java泛型重用了extends关键字。 对你来说有一点很重要,即要理解extends关键字在泛型边界上下文环境中和在普通情况下所具有的意义是完全不同的。
10.通配符
数组对象可以保留有关它们包含的对象类型的规则。 就好像数组对它们持有的对象是有意识的,因此在编译期检查和运行时检查之间,你不能滥用它们。
泛型的主要目标之一是将这种错误检测移入到编译期。
不是向上转型一Apple的List不是Fruit的List。 Apple的List将持有Apple和Apple的子类型,而Fruit的Lis氓持有任何类型的Fruit, 诚然,这包括Apple在内,但是它不是 一个Apple的List, 它仍旧是Fruit的List。 Apple的List在类型上不等价于Fruit的List, 即使Apple是一种Fruit类型。
真正的问题是我们在谈论容器的类型,而不是容器持有的类型。与数组不同,泛型没有内建的协变类型。这是因为数组在语言中是完全定义的,因此可以内建了编译期和运行时的检查,但是在使用泛型时,编译器和运行时系统都不知道你想用类型做些什么,以及应该采用什么样的规则。
有时你想要在两个类型之间建立某种类型的向上转型关系,这正是通配符所允许的。
10.1 编译器有多聪明
编译器只关注传递进来和要返回的对象类型, 它并不会分析代码,以查看是否执行了任何实际的写入和读取操作。
10.2 逆变
还可以走另外一条路,即使用超类型通配符。 这里,可以声明通配符是由某个特定类的任何基类来界定的,方法是指定<? super MyClass>, 甚至或者使用类型参数: <? super T> (尽管你不能对泛型参数给出一个超类型边界,即不能声明<T super MyCiass>) 。 这使得你可以安全地传递一个类型对象到泛型类型中。
超类型边界放松了在可以向方法传递的参数上所作的限制。
10.3 无界通配符
无界通配符<?>看起来意味着“任何事物”,因此使用无界通配符好像等价于使用原生类型。
实际上,它是在声明:“我是想用Java的泛型来编写这段代码,我在这里并不是要用原生类型,但是在当前这种情况下,泛型参数可以持有任何类型。”
编译器何时才会关注原生类型和涉及无界通配符的类型之间的差异呢?
原生Holder将持有任何类型的组合,而Holder<?>将持有具有某种具体类型的同构集合。
使用确切类型来替代通配符类型的好处是,可以用泛型参数来做更多的事,但是使用通配符使得你必须接受范围更宽的参数化类型作为参数。 因此,必须逐个情况地权衡利弊, 找到更适合你的需求的方法。
10.4 捕获转换
有一种情况特别需要使用<?>而不是原生类型。 如果向一个使用<?>的方法传递原生类型,那么对编译器来说,可能会推断出实际的类型参数,使得这个方法可以回转并调用另一个使用这个确切类型的方法。
捕获转换,因为未指定的通配符类型被捕获,并被转换为确切类型。
11.问题
11.1 任何基本类型都不能作为类型参数
解决之道是使用基本类型的包装器类以及Java SE5的自动包装机制。
如果性能成为了问题, 就需要使用专门适配基本类型的容器版本。
自动包装机制不能应用于数组。
11.2 实现参数化接口
一个类不能实现同一个泛型接口的两种变体, 由于擦除的原因, 这两个变体会成为相同的接口。
11.3 转型和警告
使用带有泛型类型参数的转型或instanceof不会有任何效果。
11.4 重载
与此不同的是,当被擦除的参数不能产生唯一的参数列表时,必须提供明显有区别的方法名。
11.5 基类劫持了接口
12.自限定的类型
class SetfBounded<T extends SetfBounded<T>>{
12.1 古怪的循环泛型
class GenericType<T> {}
public class CuriouslyRecurringGeneric
extends GenericType<CuriouslyRecurringGeneric> {}
Java中的泛型关乎参数和返回类型,因此它能够产生使用导出类作为其参数和返回类型的基类。 它还能将导出类型用作其域类型, 甚至那些将被擦除为Object的类型。
CRG(古怪的循环泛型)的本质:基类用导出类替代其参数。这意味着泛型基类变成了一种其所有导出类的公共功能的模版,但是这些功能对于其所有参数和返回值,将使用 导出类型。也就是说,在所产生的类中将使用确切类型而不是基类型。
12.2 自限定
自限定将采取额外的步骤, 强制泛型当作其自己的边界参数来使用。
自限定的参数有何意义呢?它可以保证类型参数必须与正在被定义的类相同。
自限定限制只能强制作用于继承关系。 如果使用自限定,就应该了解这个类所用的类型参数将与使用这个参数的类具有相同的基类型。 这会强制要求使用这个类的每个人都要遵循这种形式。
12.3 参数协变
自限定类型的价值在于它们可以产生协变参数类型——方法参数类型会随子类而变化。
自限定泛型事实上将产生确切的导出类型作为其返回值。
在非泛型代码中参数类型不能随子类型发生变化:set(derived)和set(base)都是合法的,因此DerivedSetter.set()没有覆盖OrdinarySetter.set()而是重载了这个方法。
13.动态类型安全
受检查的容器在你试图插人类型不正确的对象时抛出ClassCastExceptioil,这与泛型之前的(原生)容器形成了对比,对于后者来说,当你将对象从容器中取出时,才会通知你出现了问题。
14.异常
由于擦除的原因, 将泛型应用于异常是非常受限的。 catch语句不能捕获泛型类型的异常, 因为在编译期和运行时都必须知道异常的确切类型。 泛型类也不能直接或间接继承自Throwable (这将进一步咀止你去定义不能捕获的泛型异常)。
但是, 类型参数可能会在一个方法的throws子句中用到。 这使得你可以编写随检查型异常的类型而发生变化的泛型代码。
15.混型(Mixins)
其最基本的概念是混合多个类的能力, 以产生一个可以表示混型中所有类型的类。
混型的价值之一是它们可以将特性和行为一致地应用于多个类之上。混型有一点面向方面编程(AOP)的味道。
15.1 C++中的混型
在C++中,使用多重继承的最大理由,就是为了使用混型。但是,对于混型来说,更有趣更优雅的方式是使用参数化类型,因为混型就是继承自其类型参数的类。在C++中,可以很容易的创建混型,因为C++能够记住其模版参数的类型。
Java泛型不允许这样。擦除会忘记基类类型,因此泛型类不能直接继承自一个泛型参数。
15.2 与接口混合
Mixin类基本上是在使用代理,因此每个混人类型都要求在Mixin中有一个相应的域,而你必须在Mixin中编写所有必需的方法,将方法调用转发给恰当的对象。这个示例使用了非常简单的类,但是当使用更复杂的混型时,代码数量会急速增加。
15.3 使用装饰器模式
当你观察混型的使用方式时,就会发现混型概念好像与装饰器设计模式关系很近。
装饰器是通过使用组合和形式化结构(可装饰物I装饰器层次结构)来实现的,而混型是基于继承的。因此可以将基于参数化类型的混型当作是一种泛型装饰器机制,这种机制不需要装饰器设计模式的继承结构。
15.4 与动态代理混合
可以使用动态代理来创建一种比装饰器更贴近混型模型的机制。通过使用动态代理,所产生的类的动态类型将会是已经混入的组合类型。
16.潜在类型机制(latent typing)
要编写能够尽可能广泛地应用的代码。为了实现这一 点,我们需要各种途径来放松对我们的代码将要作用的类型所作的限制,同时不丢失静态类型检查的好处。
Java泛型看起来是向这一方向迈进了一步。
当要在泛型类型上执行操作(即调用Object方法之前的操作)时, 就会产生问题,因为擦除要求指定可能会用到的泛型类型的边界,以安全地调用代码中的泛型 对象上的具体方法。这是对“泛化”概念的一种明显的限制,因为必须限制你的泛型类型,使它们继承自特定的类,或者实现特定的接口。在某些情况下,你最终可能会使用普通类或普通接口,因为限定边界的泛型可能会和指定类或接口没有任何区别。
一种解决方案称为潜在类型机制或结构化类型机制(structural typing),而更古怪的术语称为鸭子类型机制(duck typing),即“如果它走起来像鸭子,并且叫起来也像鸭子,那么你就可以将它当作 鸭子对待。“
具有潜在类型机制的语言只要求实现某个方法子集,而不是某个特定类或接口,从而放松了这种限制(并且可以产生更加泛化的代码)。
潜在类型机制是一种代码组织和复用机制。有了它编写出的代码相对千没有它编写出的代码,能够更容易地复用。代码组织和复用是所有计算机编程的基本手段:编写一次,多次使用,并在一个位置保存代码。
两种支持潜在类型机制的语言实例是Python和C++。
初看起来, Java的泛型机制比支持潜在类型机制的语言更 “缺乏泛化性”。
17.对缺乏潜在类型机制的补偿
尽管Java不支持潜在类型机制,但是这并不意味若有界泛型代码不能在不同的类型层次结构之间应用。也就是说,我们仍旧可以创建真正的泛型代码,但是这需要付出一些额外的努力。
17.1 反射
17.2 将一个方法应用于序列
反射提供了一些有趣的可能性,但是它将所有的类型检查都转移到了运行时,因此在许多情况下并不是我们所希望的。如果能够实现编译期类型检查,这通常会更符合要求。
17.3 当你并未碰巧拥有正确的接口时
17.4 用适配器仿真潜在类型机制
潜在类型机制将在这里实现什么?它意味着你可以编写代码声明: 我不关心我在这里使用的类型, 只要它具有这些方法即可。
从我们拥有的接口中编写代码来产生我们需要的接口,这是适配器设计模式的一个典型示例。 我们可以使用适配器来适配已有的接口,以产生想要的接口。
用像这样的适配器看起来是对缺乏潜在类型机制的一种补偿,因此允许编写出真正的泛化代码。 但是,这是一个额外的步骤,并且是类库的创建者和消费者都必须理解的事物, 而缺乏经验的程序员可能还没有能够掌握这个概念。 潜在类型机制通过移除这个额外的步骤,使得泛化代码更容易应用,这就是它的价值所在。
18.将函数对象用作策略
策略设计模式,这种设计模式可以产生更优雅的代码,因为它将 “变化的事物” 完全隔离到了一个函数对象中。
函数对象的价值就在于,与普通方法不同,它们可以传递出去,并且还可以拥有在多个调用之间持久化的状态。 当然,可以用类中的任何方法来实现与此相似的操作,但是(与使用任何设计模式一样)函数对象主要是由其目的来区别的。 这里的目的就是要创建某种事物,使它的行为就像是一个可以传递出去的单个方法一样,这样,它就和策略设计模式紧耦合了,有时甚至无法区分。
在C++中,潜在类型机制将在你调用函数时负责协调各个操作,但是在Java中,我们需要编写函数对象来将泛型方法适配为我们特定的需求。
19.总结:转型真的如此之糟吗?
使用泛型类型机制的最吸引人的地方,就是在使用容器类的地方。
在Java SE5之前,当你将一个对象放置到容器中时,这个对象就会被向上转型为Object, 因此你会丢失类型倌息。当你想要将这个对象从容器中取回,用它去执行某些操作时,必须将其向下转型回正确的类型。如果没有Java SE5的泛型版本的容器,你放到容器里的和从容器中取回的,都是Object。因此,我们很可能会将一个Dog放置到Cat的List中。
但是,泛型出现之前的Java并不会让你误用放入到容器中的对象。如果将一个Dog扔到Cat 的容器中,井且试图将这个容器中的所有东西都当作Cat处理,那么当你从这个Cat容器中取回那个Dog引用,并试图将其转型为Cat时,就会得到一个RuntlmcExceptlon。你仍旧可以发现问 题,但是是在运行时而非编译期发现它的。
类型安全的容器是能够创建更通用代码这一能力所带来的副作用。
泛型正如其名称所暗示的:它是一种方法,通过它可以编写出更 “泛化” 的代码,这些代码对于它们能 够作用的类型具有更少的限制,因此单个的代码段可以应用到更多的类型上。