Java 泛型
words: 4k views: time: 16min一般的类和方法,只能使用具体的类型,如果要编写可以应用于多种类型的代码,那么这种刻板的限制对代码的限制就会很大。
在面向对象编程语言中,多态算是一种泛化机制。可以将方法的参数类型设为基类,那么该方法就可以接受从这个基类导出的任何类作为参数。但程序还是会受到继承的限制,比如java中就是单继承体系。如果将方法的参数设为一个接口,那么限制会松很多,任何实现了该接口的类都能满足该方法,包括暂时那些还不存在的类。
但有时即便使用了接口,对程序的约束还是太强。因为一旦指明了接口,它就要求你的代码必须使用特定的接口。而我们希望能编写更通用的代码,能够应用于”某种不具体的类型”,而不是一个具体的接口或者类。
泛型实现了”参数化类型”的概念,使代码可以应用于多种类型。其最初的目的是希望类或方法能够具备最广泛的表达能力,通过解耦类或方法与所使用的类型之间的约束。 只是java中的泛型并没有这样高的追求,它的目的只是用来告诉编译器类或方法希望使用的参数类型,以便编译器能够确保编译时参数都是期望的类型。实际上在编译之后参数的类型信息都会被擦除成边界类型,这样保证在运行时不会出现类型错误。
1. 泛型类/接口
容器类是促成泛型出现的主要原因之一,泛型之于容器的意义在于可以指定容器持有何种确定类型的对象,并且由编译器来保证类型的正确性。比如这里的ArrayList<E>
,可以在使用时再指定参数的具体类型。
1 | public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable { |
不过如果打出两个实例的类型,会发现它们都是java.util.ArrayList
,这是因为在编译时参数类型被擦除了。虽然java提供了一些方法用来获取泛型参数列表,但是除了能获取类型占位符之外并没有其它什么作用。
1 | ArrayList<Integer> integerList = new ArrayList<Integer>(); |
这里ArrayList<E>
除了自身是泛型类之外,它的父类以及实现的接口也都是泛型的。其实泛型类在继承和接口实现体系中都是很常见的,子类可以决定是否将父类的泛型参数指定为具体类型,当然也可以增加自己的泛型参数,比如:
1 | public class SelfList<T> extends AbstractList<String> implements List<String>{ |
2. 泛型方法
定义泛型方法,只需要将泛型参数列表置于返回值之前即可。泛型方法可以独立于泛型类,也就是说,是否拥有泛型方法与其所在的类是否是泛型类没有关系。而且,通常应该优先考虑使用泛型方法,因为这样能让事情更加清楚明白。
1 | public static <T> List<T> asList(T... a) { |
这里的Arrays.asList
利用了参数类型推断,而且与可变参数结合起来使用。其实它是它返回的是一个内部实现类的实例,但是没有实现add或remove,如果调用的话将得到一个UnsupportedOperationException
异常。不过我们也可以利用类型推断编写一个类似的工具:
1 | public class ListUtil { |
3. 边界符
编译器会将泛型参数擦除为它的第一个边界类型,因此如果我们改变了边界就是改变了擦除后的类型,也就作用到了是运行时。
比如比较两个List是否相同:
1 | public static <T extends List> boolean eq(T t1, T t2){ |
但是这与直接定义为边界类型并没有区别:
1 | public static boolean cmp(List t1, List t2){ |
因此通常只有当你希望代码能够跨多个类工作时,使用泛型才能有所帮助。并不是说extends没有用,如果一个类的方法返回的是T的子类,那么使用泛型就可以返回一个确切的类型。
1 | class ReturnGenericType<T extends List>{ |
4. 类型擦除
采用擦除的方式实现泛型是为了支持兼容性而不得不做的一个折中,使原先没有使用泛型的类库与使用了泛型的类库能够一起运行。由于擦除丢失了在泛型代码中执行某些操作的能力,任何在运行时需要知道确切类型信息的操作都将无法工作 。
1 | class Erased<T>{ |
4.1. 类型检测
对于instanceof
,如果引入类型标签,则可以使用动态的isInstance
1 | class TypeCapture<T>{ |
4.2. 实例创建
java的解决办法是传递一个工厂对象,并使用它来创建新的实例。最便利的工厂对象便是Class对象
1 | public interface IGenerator<T> { |
但这种方式要求使用者自己清楚类的构造器,否则像这里,如果创建实例的类没有默认构造器,那么将会发生运行时异常,而且在编译期间无法捕获。
1 | IGenerator<Integer> generator = Generator.get(Integer.class); |
因此,建议使用显式的工厂,并限制其类型1
2
3
4
5
6
7
8
9
10
11
12public interface IGenerator<T> {
T create();
}
public class IntegerGenerator implements IGenerator<Integer>{
public Integer create() {
return new Integer(0);
}
}
1 | public static <G extends IGenerator<T>, T> T create(G generator){ |
两种方式都传递了工厂对象,但Class
4.3. 泛型数组
成功创建泛型数组的唯一方式就是创建一个被擦除类型的新数组,然后对其转型。数组的类型将在数组被创建时确定,因此可以在创建时给它需要的类型,然后在使用之前再将其转型。
1 | Generator<Integer>[] garr; |
考虑一个简单的泛型数组包装器:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21public class GenericArray<T> {
T[] array;
public GenericArray(int size){
array = (T[])new Object[size];
}
public void put(int index, T item){
array[index] = item;
}
public T get(int index){
return array[index];
}
public T[] rep(){
return array;
}
}
由于擦除,可以知道在运行时数组的实际类型为Object[],并且put
和get
在方法调用的边界处限制了数组元素的实际类型为参数类型,
因此可以将GenericArray
当成泛型数组来使用。但是如果试图调用rep()
来获取T[],那么只能用Object[]来接收,否则将出现运行时异常。1
2GenericArray<Integer> array = new GenericArray<>(10);
Integer[] arr = array.rep(); //ClassCastException
事实上任何想使用泛型数组的地方都可以通过ArrayList
来解决,它在内部使用Object[],然后当使用数组元素时,添加一个对参数类型的转型。
5. 通配符
通配符 ? 并不意味着可以是任何类型,而是指一个特定的类型,只是这个类型可以是任何一种。
1 | class Fruit {} |
5.1. extends
先看一个关于数组的问题:数组是协变的,它在语言中是完全定义的,内建了编译期和运行时检查,因此可以将导出类型的数组赋予基类型的数组引用。但是如果使用不当将会出现运行时异常并且编译器无法检测。
协变:如果B是A的子类,并且F(B)也是F(A)的子类,那么F即为协变;
逆变:如果B是A的子类,并且F(B)成了F(A)的父类,那么F即为逆变;
1 | public static void main(String[] args) { |
这里创建了一个 Apple 数组并赋值给一个 Fruit 数组的引用,数组对象发生了一个向上转型。那么对于编译器来说,它认为 fruit 可以存储任何 Fruit 及其子类型的实例,而运行时的数组机制知道它实际处理的应该是Apple,因此会抛出异常。
泛型可以将这种错误检测移入到编译期:
1 | List<Fruit> list = new ArrayList<Apple>(); //compile Error |
不能把一个涉及 Apple 的泛型赋给一个赋给 Fruit 的泛型,因为由于编译之后它们都被擦除为Object,编译器根本不知道它们的实际类型,因此它拒绝向上转型。如果想在两个类型之间建立某种向上转型关系,那么可以使用通配符。
1 | List<? extends Fruit> list = new ArrayList<Apple>(); |
这里List<? extends Fruit>
表示List将持有Fruit的某种具体的子类型,只是为了向上转型,并不关心它具体是哪种子类型。一旦执行这种向上转型,将丢失向其中传递任何对象的能力,甚至传递Object也不行。因为编译器已经无法知道List持有的具体类型,因此它将直接拒绝对参数列表中涉及通配符方法的调用。不过,如果是返回参数的话,比如get,那么可以确定通配符代表的至少是一个Fruit。
这就可以解释为什么上面ArrayList中的remove
、contains
、indexof
等方法的参数是Object了,只有这样这些方法的调用才能不受泛型通配符的影响。
另外,这些方法都有一个共同点,就是它们的行为只依赖equals的执行结果,而equals并不要求必须是相同的类型,每个类都可以自定义自己equals的实现,所以如果用参数类型作为方法参数反而限制了方法的语义。比如对于ArrayList
与LinkedList
,它们的equals
都在AbstractList
中实现,只要集合中的元素一样就成立,并不区分是哪种子类型。解释来自stackoverflow
As mentioned by others, the reason why get(), etc. is not generic because the key of the entry you are retrieving does not have to be
the same type as the object that you pass in to get(); the specification of the method only requires that they be equal. This follows
from how the equals() method takes in an Object as parameter, not just the same type as the object.
5.2. super
使用超类型通配符可以解决上面list的问题,即声明通配符是由某个特定类的任何基类来界定的,比如1
2
3
4
5
6List<? super Fruit> list = new ArrayList<Fruit>();
list.add(new Fruit());
list.add(new Orange());
list.add(new Apple());
Object fruit = list.get(0);
Apple apple = (Apple)list.get(1); //ClassCastException
其实相当于extends给定了 ? 的上界,而super给定了下界,对于容器来说它们的范围都是一样的,只是开口方向不一样。可以用PECS原则来总结:producer-extends, consumer-super。即extends,只能读,相当于生产者向外产出;而super,只能写,相当于消费者只能接收消费;在一些api中可以见到这种应用,比如:
1 | public static <T> void copy(List<? super T> dest, List<? extends T> src) { |
6. 自限定类型Comparable
由于泛型没有类型信息,因此不能直接继承一个泛型参数,但是可以继承一个使用泛型的基类,并且用自身作为基类的参数类型,比如Comparable1
2
3public interface Comparable<T> {
public int compareTo(T o);
}1
2
3
4
5
6
7
8public final class Integer extends Number implements Comparable<Integer> {
public int compareTo(Integer anotherInteger) {
return compare(this.value, anotherInteger.value);
}
//...
}
这样基类用子类代替其参数,相当于泛型基类变成了其子类的功能模板,并且这些功能的参数和返回值都使用的子类型本身,最终,自限定保证了参数类型必须与正在被定义的类相同。
最常见的就是对于Comparable
的使用,比如定义一个求集合中最大值的工具方法
1 | public static <T extends Comparable<T>> T max(Collection<T> collection) { |
但是上面的方法限制太强,只允许自己与自己比较,可以给定边界,允许自己与自己的子类进行比较
1 | public static <T extends Comparable<? super T>> T max(Collection<? extends T> Collection) { |
参考: