Java 基础之泛型

Java平台在JDK 5中引入了一个重要的特性 —— 泛型(generics),允许在定义类和接口的时候使用 句法表示的类型参数(type parameter),在使用相应的类和接口的时候用具体的类来替换类型参数,因此泛型又被称作参数化类型(parameterized type)。泛型的最广泛应用是在Java集合库中。

为什么引入泛型?

泛型引入的目的是为了解决容器(数据结构)的类型安全性,使得编译器在编译时就能发现明显的类型错误,从而避免运行时的转型错误。下面举一个具体的例子:

在引入泛型以前:

1
2
3
4
5
6
7
8
List list = new ArrayList();
list.add("this is a string.");
list.add(123);

//不知道list中存放什么类型的对象,必须校正
String str = (String)list.get(0);
//运行时错误
String str = (String)list.get(1);

上述代码是合理的,但是为了取出有效的对象,必须进行强制的类型转换,因为List并不知道其中的对象是什么类型,取出是都是Object对象;可以把不同类型的对象放入同一个容器中,但如果做了不合法的转型,会导致运行时错误。(java.lang.ClassCastException)

在引入了泛型后,编译器可以在编译时了解容器中具体存放的对象的类型,确保其它类型的对象无法存入容器中,从而在编译时就发现大多数的类型错误,避免运行时错误:

1
2
3
4
5
6
7
List<String> list = new ArrayList<String>();
list.add("this is a string.");
//编译时错误
list.add(123);

//无需校正
String str = list.get(0);

如何使用泛型

泛型可以用于类和接口中,其通用的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
interface List<E> extends Collection<E> {
boolean add(E e);
E get(int index);
}

class ArrayList<E> implement List<E> {
boolean add(E e) {
//...
}
E get(int index) {
//...
}
}

在定义有参数的类型时,要使用一种不对类型参数作任何假设的方式指定具体的值。上面用类型参数 E 作为占位符,在具体使用的时候由其它的类型(负载类型)替换掉。

在创建泛型实例时,赋值语句的右侧会重复类型参数的值。一般情况下,编译器能够推导出类型参数的值,可以使用菱形句法省略重复的类型值。

1
List<String> list = new ArrayList<>();

在非泛型的类中也可以使用泛型方法,结构如下:

1
2
3
4
5
public class Utils {
public static <T> T add(T a, T b) {
//
}
}

上述方法在调用时,可以显示指定类型参数的具体值,如:

1
Utils.<Integer>add(1,2);

也可以不指定,让编译器自己推断,

1
Utils.add(1,2);

编译器不仅可以从方法接收的参数类型来推断,也可以从方法赋值的目标参数来推断。类型推导在lambda表达式中也经常使用。

类型擦除

Java 泛型中的类型参数只在编译时可见,javac 会去掉类型参数,这个被称为类型擦除(type erasure)。在生成的Java字节代码中是不包含泛型中的类型信息的,但会保留泛型的一些踪迹,在运行时通过反射可以看到(from Java 技术手册)。类型擦除也是Java的泛型实现方式与C++模板机制实现方式之间的重要区别。

Java 5中添加的泛型是一个新增加的语言特性,而类型擦除正是解决后向兼容型的保证。旧的非泛型集合类和新的泛型集合类是可以兼容的:

1
2
List someList = getSomeThings();
List<String> stringList = (List<String>)someList;

这是因为在经过了编译器的类型擦除后,对 JVM 来说看到的都是List。非泛型的List一般成为原始类型。类型擦除机制会导致一些看上去比较奇怪的现象:

1
2
3
4
5
6
//无法编译
interface OrderCounter {
int totalOrders(Map<String, List<String>> orders);

int totalOrders(Map<String, Integer> orders);
}

上述代码乍看上去是合法的,但实际上并不能进行编译。因为在类型擦除以后,两个方法的签名都是:

1
int totalOrders(Map);

运行时无法通过签名区分这两个方法,因而Java规范把这种句法列为非法的。

关于类型擦除需要记住的几点是:

  1. 泛型类并没有自己独有的Class类对象。比如并不存在List<String>.class或是List<Integer>.class,而只有List.class。在经过类型擦除后剩下的只有原始类型,无论是List<String>或是List<Integer>,对JVM来说都看作List。

  2. 静态变量是被泛型类的所有实例所共享的。对于声明为MyClass<T>的类,访问其中的静态变量的方法仍然是 MyClass.myStaticVar。不管是通过new MyClass<String>还是new MyClass<Integer>创建的对象,都是共享一个静态变量。

  3. 泛型的类型参数不能用在Java异常处理的catch语句中。因为异常处理是由JVM在运行时刻来进行的。由于类型信息被擦除,JVM是无法区分两个异常类型MyException<String>MyException<Integer>的。对于JVM来说,它们都是 MyException类型的。也就无法执行与异常对应的catch语句。

通配符与上下界

在开始接触泛型的时候,最让人困惑的问题之一是,List<String>List<Object> 的子类吗?”。即这样的代码是合理的吗:

1
List<Object> objects = new ArrayList<String>(); //合法吗?

乍看起来,似乎是正确的,因为String是Object的子类,因而集合中任何一个String类型的元素都是有效的Object对象。但是

1
2
List<Object> objects = new ArrayList<String>(); //合法吗?
objects.add(new Object()); //如果合法,那么这句呢?

既然objects的类型声明为List<Object>,那么就能将Object实例存入其中;但是,这个实例保存的元素都是字符串,尝试存入的Object对象与其并不兼容。因而这种写法是不合理的,也就是说,List<String> 并不是 List<Object> 的子类” 如果想让容器的类型具有父子关系,需要使用未知类型:

1
List<?> objects = new ArrayList<String>();

在Java中,有“未知类型”这一明确的概念,使用 <?> 表示。这是一种最简单的Java通配符类型 (wildcard type)。ArrayList<?>ArrayList<T> 是不一样的,前者是变量可以使用的完整的类型,即便它的负载类型是不确定的;ArrayList<?>ArrayList<Object> 也是不一样的,ArrayList<Object>实际上确定了List中包含的是Object及其子类,在使用的时候都可以通过Object来进行引用,而ArrayList<?>中所包含的元素类型是不确定。正因为ArrayList<?>包含的元素类型是不确定的,就不能通过new ArrayList<?>()的方法来创建一个新的ArrayList对象。在Java语言的规范中,是禁止实例化负载为未知类型的容器的。

虽然对编译器来说,ArrayList<?>的元素类型是什么,但还是可以通过Object来引用其中的元素(get()返回的类型是Object),因为虽然类型未知,但肯定是Object或者它的子类。

考虑下面的代码:

1
2
3
public void wildcard(List<?> list) {
list.add(1);//编译错误
}

试图对一个未知类型的泛型类进行操作的时候,总是会出现编译错误。其原因在于通配符所表示的类型是未知的。

其实,Java的通配符类型并不只有未知类型一种,还存在受限通配符(bounded wildcard)这一概念,也叫做类型参数约束条件,即使用上下界来约束类型参数可以使用哪些类型。如List<? extends Number>说明List中可能包含的元素类型是Number及其子类。而List<? super Number>则说明List中包含的是Number及其父类。当引入了上界之后,在使用类型的时候就可以使用上界类中定义的方法。

  1. ? extends T - 这里的?表示类型T的任意子类型,包括类型T本身。
  2. ? super T - 这里的?表示类型T的任意父类型,包括类型T本身。

这两者的使用通常让人感到非常迷惑,比如说,由于 ? extends T 表示类型T和它的任意子类型,那么我们可以说List<? extends Number> 实例可以添加任意为Number子类的元素吗?

1
2
3
List<? extends Number> intList = new ArrayList<>();
intList.add(1); //complier error
intList.add(3.14); //compiler error

关于 ? extends T 和 ? super T 的使用,有一个简单的原则可以参考,

当你需要从一个数据结构中获取数据时(get),那么就使用 ? extends T;如果你需要存储数据(put)到一个数据结构时,那么就使用 ? super T; 如果你又想存储数据,又想获取数据,那么就不要使用通配符 ? ,即直接使用具体泛型T。

1
2
3
4
5
List<? super Number> intList = new ArrayList<>();
intList.add(1); //it works
intList.add(3.14); //it works

intList.get(0) //compiler error

泛型的类型系统

面向对象的语言中,一个重要的规则是Liskov替换原则, 即可以使用子类替换父类,即在需要父类引用的地方可以传入子类的引用;但反之,如果要将父类引用替换子类引用,就需要使用强制类型转换,虽然编译器并不能保证运行时刻这种转换一定是合法的。

但引入泛型之后,对Java的类型系统带来了一些令人困扰的问题,比如说ArrayList和ArrayList的关系,List和Collection的关系等,在Java深度历险(五)——Java泛型一文中的描述很好:

引入泛型之后的类型系统增加了两个维度:一个是类型参数自身的继承体系结构,另外一个是泛型类或接口自身的继承体系结构。第一个指的是对于List<String>List<Object>这样的情况,类型参数String是继承自Object的。而第二种指的是List接口继承自Collection接口。对于这个类型系统,有如下的一些规则:

  1. 相同类型参数的泛型类的关系取决于泛型类自身的继承体系结构。即List<String>Collection<String> 的子类型,List<String>可以替换Collection<String>。这种情况也适用于带有上下界的类型声明。
  2. 当泛型类的类型声明中使用了通配符的时候,其子类型可以在两个维度上分别展开。如对Collection<? extends Number>来说,其子类型可以在Collection这个维度上展开,即List<? extends Number>Set<? extends Number>等;也可以在Number这个层次上展开,即Collection<Double>Collection<Integer>等。如此循环下去,ArrayList<Long>HashSet<Double>等也都算是Collection<? extends Number>的子类型。
  3. 如果泛型类中包含多个类型参数,则对于每个类型参数分别应用上面的规则。

一个小坑

创建泛型数组在Java中是不被允许的。需要适用类型转换来创建泛型数组。

1
T[] a = (T[])new Object[10];

-EOF-

参考:
Java深度历险(五)——Java泛型
浅谈Java泛型
Java技术手册