泛型
泛型是许多程序设计语言都会有的一种风格或范式。它允许在代码被编写出来以后的某些时刻才指定具体的类型。
泛型的作用?
- 传统的类和方法,只能使用具体的类型;要么就是自定义类,要么就是基本类型,如果要编写同时应用于不同类型的代码,这样的写法对代码段的束缚就会很大。因此,正如其名字一样,就是为了写出“泛化“的代码而出现了泛型。
- 在强类型语言中,泛型的主要目的还有加强类型安全和减少类型转换的操作次数等等。
在调用泛型类或者方法时,要求传入泛型(参数类型),编译器会对泛型进行检查,要求传入的泛型要和已经规定的泛型一致。
Java中泛型允许在编译期检查是否存在类型转换风险,以及规避掉令人讨厌的ClassCastException
。
看到这里,泛型的概念应该已经明了了,泛型就是“可以适用于许多不同类型的类型”。但是实际上,Java中的泛型存在有许多的局限性,而且泛型也不像刚开始提到的那样子适用范围并不十分广泛。
简单泛型
容器
泛型最简单也是最实际的应用恐怕就是用于指定类的持有对象类型了,也就是用于构建容器,容器需要存放大量的对象。如果没有泛型,那么构建一个可以适用于不同类型的容器类,恐怕就只能把所有持有类型都当作是Object
来处理了。一旦将所有类型当作Object
来处理,那么客户端在创建这个容器类的时候,将无法约束容器对象中存放的元素类型,就像是”大杂烩”,什么类型都能放入其中,如果这时要对取出的类型作具体类型操作的时候,就难免要发生诸如ClassCastException
之类的异常了。
正因如此,我们才需要引入泛型,Java
中,泛型的由一对尖括号定义,像这样:
1 | public class Holder<T>{} |
其中T就是类型参数。也就是我们所说的泛型。类型参数这个名字就是指”T”,顾明思议,就是在我们需要用到这个类的时候需要传入具体类型的一个参数,例如:
1 | Holder holder<Integer> = new Holder<>(); |
像这样,便指定了类型参数。
元组
元组是一个在集合论中出现过的概念,它的大概含义是指有限个元素所组成的序列。其作用是用于指出特定的实体,例如在数据库的关系模型中,也曾经出现过元组的概念。简单概括起来,数据库表中的“一行”也算是一个元组。需要注意的是,在计算机里,元组中的元素由字段名和元素值一一对应、识别,而不是通过位置。在书中,利用泛型来展示了一个构建元组的实例:
1 | public class TwoTuple<A,B>{ |
值得注意的是,元组中两个变量都定义成了final,这意味着一旦元素被创建之后将不能修改,这种写法让代码具有安全性的同时也更加优雅。
泛型接口
泛型可以用于接口,意味着在实现接口的时候就可以进行类型参数的传入了。
1 | public interface Generator<T>{ |
上面描述了一个泛型生成器接口,实际上这可以应用于工厂方法,而且不用显式地给工厂方法传入参数,写法也更加优雅一些。
泛型方法
除了可以在类和接口上实现泛型,同时还可以在类中包含泛型化方法,例如:
1 | public class GenericMethods{ |
这和方法展示了传入不同类型的参数一样可以获取不同类型的类型标签进而获取他们的标签名。
虽然GenericMethods
没有参数化,但是内部方法f()
变成了参数化方法,可以向方法传不同的参数,就好像方法被重载过一样。
同时,可变参数和泛型方法也可以很好地共存:
1 | public class GenericVarargs{ |
这个方法展示了将多个参数遍历放入同一个List容器中,和类库中的Arrays.asList()
同样的功能。
下面展示了一种用于Generator的泛型方法,用于用生成器填充容器。
1 | public class Generators{ |
还有一种构造生成器的方法是传入类型标签,利用类型标签构造方法,前提是类型标签所表示的类要有无参的构造方法。像这样:
1 | public BasicGenerator<T> (Class<T> type){ |
除了上述的一些使用方式,泛型还可以用于内部类和匿名内部类中,而且在内部类中可以还使用外部类的类型参数。同时泛型有助于我们构建一些复杂的模型,像是为多个ArrayList封装这样子。
擦除
通过上面的一些例子,相信已经对泛型的基本用法有了一个大概的了解,我们先看一个例子。
1 | public class ErasedTypeEquivalence{ |
可以看到,上面的例子中两个ArrayList中获取到的类型信息时一样的,并且,在声明时我们可以声明ArrayList.calss
,但是却不能声明ArrayList<Integer>.class
。
这显得很奇怪,明明我们声明的是一个持有Integer
的List
对象,为什么不能获取到一个声明”我持有Integer
“的List
类型标签呢?这都是因为Java中泛型在运行时会被jvm擦除掉,换言之就是: 在泛型代码内部,无法获得任何有关泛型参数类型的信息。正因如此,在运行时ArrayList
内部,仅仅存在Object
类型的对象,所有类型信息都被擦除掉了。(后面我们会看到,其实可以不仅仅被擦除到Object。)
为什么会存在擦除这种特性
为什么要在运行时把泛型信息擦除掉呢?原因在于Java是在Java 5才引入泛型,在Java 5.0之前的类库是没有用到泛型的,那么在5.0以后程序作者如果想使自己的代码泛化,并且迁移到泛型的使用上,就会出现许多兼容问题。因此擦除是Java使用泛型的一种折中的方法,即在编译时遵循类型安全,但是运行时类型参数将不复存在。
擦除的问题
擦除最最主要的问题就是,像这样的代码:
1 | class Foo<T>{ |
创建实例时Foo<Cat> f = new Foo<Cat>();
看上去似乎类型T
被替换了,但是实际上用的时候却把Cat
的属性给全部擦除掉了,在Foo
中持有的属性只是一个Object
。
擦除的补偿
由于擦除的存在,任何在运行时需要知道确切类型信息的操作都无法工作:
1 | public class Erased<T>{ |
虽然擦除带来了许多不便,但为了更好地利用泛型这一特性,Java提供了许多弥补泛型功能缺失的补偿。
其中一个最重要的就是利用类型标签,虽然类型被擦除了,但是可以在编译时就规定好传入的类型要与泛型一致。例如:
1 | public class ClassTypeCapture<T> { |
虽然不能用new T()
直接创建泛型实例对象,但是可以在传入类型标签后,调用newInstance()
进行创建,最简单的就是传入一个工厂对象,这个工厂对象就是Class对象,
例如:
1 | class ClassAsFactory<T>{ |
上面的程序还有一个缺点,会因为创建例如Character
,Integer
这样的包装类而失败,因为包装类没有构造方法,因此需要一个工厂接口。
1 | interface FactoryI<T> { |
在生成Foo2
时调用传入对应的工厂,这其实是传入了一种Class<T>
的变体,因为Class
对象自带内建工厂对象,而FactoryI
则是定义一个显式的工厂。
还有一种方法就是利用模板模式来创建对象。模板模式的典型实现是定义一个抽象类作为模板,并且自带一个模板方法,模板方法中会调用其他几个抽象方法来进行固定的算法操作。而抽象方法的实现就放到子类中,这样使各个不同的实现的抽象方法可以按照一套骨架来运行。
1 | abstract class GenericWithCreate<T>{ |
继承这个类并实现抽象方法可以简单地创建出需要的子类对象,个人认为这种比上述的显式工厂来得方便一些。
泛型数组
由于擦除的存在,不能声明T[] array = new T[size]
,但是我们可以先创建Object类型,然后再将其转型T[] array = (T[]) new Object[size];
。但是这样会收到一个警告。并且如果你想通过另外一个方法来获取这个数组的话,将会收到ClassCastException
异常。例如:
1 | public static T[] array(){ |
这表示,即使你在创建数组时将其转型为了Integer
,但是它在内部表示仍然是Object
,因此在被取出时依然会发生异常,那么我们让转型换个位置如何:
1 | public static T[] array(){ |
这依然会报出转型异常的警告,因为在运行时代码内部,数组的类型信息已经丢失,只是一个Object,那么就没有补救的办法了吗?
想要从擦除中恢复,还是需要一个类型标记Class
1 | public GenericArrayWithTypeToken(Class<T> type,int size){ |
虽然如此,但其实ArrayList的源码中也充满了各种各样直接从Object数组转型为参数化数组的代码,例如,一个从Collection中复制ArrayList 的构造器(经过简化):
1 | public ArrayList (Collection c){ |
总结
1.泛型是一种通用的编程范式,它为我们提供了编译期检查类型安全的特性以及使我们的转型更加方便。
2.Java中的泛型功能并没有c++等设计语言中的泛型那么强大,它具有许多限制。最典型的就是Java中的类型参数在运行时会被擦除。
3.擦除就是无法获取到关于泛型的运行时信息,泛型代码中的类型参数会被擦除到边界。
4.对于擦除后泛型的特性缺失,由指定类型标志Class<T>
从而在运行时获取类型信息。