浅解Java泛型
泛型(Generics)在Java中的应用,“那是相当的泛啊”
“泛型的内容很多,也很杂,笔者会尽可能的使用较短的篇幅以及较为合理的结构来介绍泛型”
1. 为什么使用泛型
集合(Collection)中有一个名为List的接口,包含有泛型接口以及普通接口。如下:
interface List<E>{ /*....*/ }
interface List{ /*...*/ }
为什么会出现两种不同的版本呢?按理说有一个普通的List接口不是应该就足够了?回答这个问题之前,先看一段程序:
示例程序
/*本段代码使用
*javac -Xlint:unchecked ListDemo.java编译后打印警告信息:
*[unchecked] 对作为原始类型List的成员的add(E)的调用未经过检查。
*/
import java.util.*;
public class ListDemo {
public static void main(String[] args) {
List list = new ArrayList();
list.add(1);
list.add('a');
Iterator it = list.iterator();
while (it.hasNext()) {
System.out.println(it.next());
}
}
}
向list中添加元素然后迭代输出,这看起来似乎是没什么问题,编译也能通过,但仔细一看,这个链表中接收了int
和 char
两种不同的元素类型,违反了存储的规范(在同一个数组下存储类型相同的元素),如果只是单纯的需要存储然后输出,影响可能并不会很大,但是如果对这个数组中的元素进行其他操作,比如说进行运算,此时就会出现编译级的错误。
Note:
如果增加对list中元素的运算,则会出现一条提示信息,提示运算符'+'的操作数原始类型错误,即对两个Object对象使用了'+'运算符。
关于为什么list中的元素为什么会从int和char转换成Object对象则涉及到类型擦除(Type Erasure),后文会仔细讲解。
想要避免因存储不同类型的元素而造成的错误,我们则可以使用泛型。
List<Integer> list = new ArrayList<Integer>();
list.add(1);
list.add('a'); //此处会报错,因为字符a不是Integer类型
经此两个示例不难发现,泛型具有如下优点:
- 编译时的强类型检查,这样做能够确保类型安全,而编译时错误要比运行时错误容易解决,所以需要尽可能的在编译时期就将错误检查出来解决掉。
- 消除类型转换,如第一个示例中如果不对list中的元素进行强制转化,则其中元素类型为Object,而不是我们所期望的int和char类型。
- 确保程序员可以写出通用性(generic)强的算法,这些算法不仅便于阅读,还能根据实际情况进行定制,而且具有泛型的类型安全这一特性。
2. 泛型的类,接口以及方法
2.1 预备知识
2.1.1 泛型的命名规范
命名规范只是为了让程序更容易理解,如果不遵守命名规范,泛型程序将变得难以理解,甚至于编写程序的人都不能区分每一种类型变量(Type Parameter)对应着什么,所以才有了命名规范。
- E - Element (used extensively by the Java Collections Framework)
- 元素,在集合中常用,如add(E e)方法
- K - Key
- V - Value
- K, V键值对,一个键对应一个值
- T - Type
- S,U,V etc. - 2nd, 3rd, 4th types
- 类型变量标识符
- N - Number
命名规范是程序员共同遵守的规范,不仅方便自己阅读代码,也可以使其他人可以快速了解泛型方法的用法。
2.1.2 类型参数,类型变量
- 类型变量(Type Parameter) ,就如T,K,V等,尚未具体化的变量。List< E>中E就是类型变量。
- 类型参数(Type Arguement),如String,Integer等将T,K,V具体化的类。List< String>中String就是类型参数。
Note:
值得一提的是,这两种概念的界限在很多的开发者中较为模糊,要注意区分。
2.1.3 通配符
在正则表达式中有一个元字符 '?' 其可以匹配任意单个字符,而在Java泛型中称 '?' 为通配符(Wildcards),代表任意一种未知类型。
Tip:
注意通配符不是一个类型变量,在代码的编写中不能作为一种类型。
2.1.4 自动装箱与拆箱
Java中int等变量类型是基本变量类型,与之对应的有Integer等包装类,且其包装类是不可变类。
有时候,比如现在所说的泛型,我们只能将类的对象作为类型参数具体化类型变量,所以当我们需要使用int作为类型参数时,我们就需要使用int的包装类Integer作为类型参数。
回顾之前的示例,我们确实是使用了Integer作为类型参数,但是我们却用了
list.add(1);
//add方法具体为add(Integer i)
这种方式向list中增加元素,1是个常数,应该是一个int型的基本变量才对,为什么能作为Integer对象传入参数表?
这就是Java中一个比较有用的特性——自动装箱,将基本类型变量封装成一个对应的包装类对象,而自动拆箱则正好相反。
list.add(Integer.valueOf(1));
//add(1)方法会自动变换成该形式
2.2 定义泛型类,接口,方法
泛型类的定义与普通类的定义相似,只是在类名后增加了一个如< T>的符号,用于表示该类使用泛型编写,而接口定义与之相似。
public class GenericClass<T> {
private T t;
public void set(T t) {
this.t = t;
}
public T get(T t) {
return t;
}
//这是泛型方法,泛型方法可以在泛型类中定义,也可以在普通类中定义
//调用一个泛型方法时,需要在方法前加<具体类型>
//如instance.<String>getLength(...)
public static <T> T getLength(T... a) {
return a.length;
}
}
public interface GenericInterface<T> { /*...*/ }
泛型类也是类,既然是类,那也就存在:
- 继承
- 接口实现
- 多态性
a) 继承(Inheritance)
在说继承之前,我们先思考一个问题:GenericClass<Number>
和 GenericClass<Integer>
有什么关系?
这两个都是具体化了同一个泛型类,参数里Integer是Number的子类,这么看来,这两个类应该是属于继承关系。
但事实是,即便是Integer和Number继承关系,这两个类除了名字相同之外便再没有其他任何的联系。
但如果出现如下情况:
public class GenericClass<E> extends List<E> { /*...*/ }
public class Generic<E, P> extends List<E> { /*...*/ }
由于是使用extends继承,故List< E>和GenericClass< E>属于父子关系,并且无论P是什么类型,Generic< E, P>都是List< E>的子类。
2.jpg
b) 接口实现(implement)
public class GenericClass<E> implements List<E> { /*...*/ }
c) 多态性(Polymorphism)
由于类型擦除会使超类中的方法与子类中的方法签名不一致,造成参数列表中参数类型的不一致,使得我们预期的方法重写成为了方法重载。
解决这个问题的方法则是编译器将生成一个合成的方法——桥方法,用以对超类中的方法进行覆盖,由于该方法是由编译器完成,实现方式对程序员隐藏,故在此不过多讨论。
2.3 泛型参数
前文提到的通配符?可以表示任意的类,即GenericClass<?>
中的?可以是任意类(这种形式有个别称,未知类型的列表(list of unknown type)),这看起来似乎和类型擦除后的GenericClass<T>
完全一样,但实际上GenericClass<Object>
与其他的类,如GenericClass<Integer>
是没有继承关系的,而使用通配符的类却可以是所有类的超类,可以引用其他类,与Object性质相似。
单使用通配符来达到可以引用多种类型的情况,我们称为无界通配符(Unbounded Wildcards),但往往我们都会想为传递的泛型参数设定界限来限制我们可以传入的参数类型,于是伴随着无界而出现了上界和下界。
2.3.1 通配符上界(Upper Bounded Wildcards)
public static double sumOfList(List<? extends Number> list) {
double s = 0.0;
for (Number n : list)
s += n.doubleValue();
return s;
}
这就是一个典型的使用通配符上界的方法,在这里只能传递类型参数为Number子类的泛型类型对象,如List<Integer>, List<Double>
等。
通过示例也能看出定义类型变量的上界只需要使用? extends SuperClass
就能做到了。
2.3.2 通配符下界(Lower Bounded Wildcards)
与上界相似相似,只能传递类型参数的超类,定义时需要使用? super SubClass
。
Note:
关于通配符界的内容不多,但却是实现泛型算法的关键。
需要注意,当使用类和接口同时定义界的时候,需要将类放在最开始。
3. 类型擦除(Type Erasure)
类型擦除听起来很复杂,但实质上就只是将类型变量用相应的类进行替换而已。
public class GenericClass<T> {
private T t;
public void set(T t) {
this.t = t;
}
public T get(T t) {
return t;
}
public static <T extends Integer> int getLength(T... a) {
return a.length;
}
public static double sumOfList
(List<? extends Number> list) {
double s = 0.0;
for (Number n : list)
s += n.doubleValue();
return s;
}
}
上述代码经过类型擦除后为:
public class GenericClass {
private Object t;
public void set(Object t) { this.t = t; }
public Object get(Object t) { return t; }
public static int getLength(Integer... a) {
return a.length;
}
public static double sumOfList
(List<Number> list) { //用通配符确定了上下界的类型变量,使用上下界替换。
double s = 0.0;
for (Number n : list)
s += n.doubleValue();
return s;
}
}
即使用具体化的类型参数T,如String来替换类中的类型变量T,如果没有具体化,则会默认使用Object替换。
当用到通配符上下界时。使用通配符的界来替换。
4. 泛型的限制
关于泛型,优点很明显,但是限制却也很多,我们需要遵守这些限制,否则写出来的程序可无法通过编译:
- 不能使用基本类型具体化泛型
- 不能创建类型参数(即泛型)的实例
- 不能声明一个类型参数的静态域,静态变量是类变量,类创建是就存在,而泛型类被创建时还没有被具体化,无法知道这个域是什么类型。
- 不可进行类型转换以及instanceof比较
- 不能创建参数化类型的数组
- 不能创建,捕捉,抛出参数化类型的对象,关于抛出具体为:不能直接或者间接继承Throwable类,不能throw,但能throws(留与读者自行解决,来自笔者的奸笑)。
- 不能重载方法,由于在正式确定参数类型之前存在类型擦除,而在类型擦除后,方法签名可能会相同(即方法设计者只是想改变传入参数的类型,但没有改变参数个数的时候发生)。
5. 总结
使用泛型最多的应该是类库的设计者,因为他们需要编写通用性强的类,接口以及算法提供给类库的使用者。
当然,这也不是说我们就不需要了解泛型,毕竟使用类库中还需要看看具体是如何使用的,不能两眼一抹黑啊,再说了,万一哪天就轮到我们自己写通用型算法了呢?
——来自一个无良作者的奸笑