孤舟蓑笠翁,独钓寒江雪

Java 基础 -- 范型

概述

所谓范型,就是允许在定义类、接口、方法时使用类型形参,这个类型形参将在声明变量、创建对象、调用方法时动态地指定(即传入实际的类型参数,也可称为类型实参),以此来扩大参数的接收范围。
范型可以减少强制类型的转换,可以规范集合的元素类型,还可以提高代码的安全性和可读性,正是因为有这些优点,自从 Java 引入范型后,项目的编码规则上便多了一条:优先使用范型。

范型的使用

类型参数

类型参数就是我们在定义泛型类或者方法是动态指定的参数。

类型参数的命名规则

类型参数名称命名为单个大写字母,比如 Collection<E>
但是这个命名规则我们一般会遵循一般的约定,以便可以在使用普通类或接口名称时能够容易地区分类型参数,增加代码的可读性。
以下是常用的类型参数名称列表:

  • E:元素 Element,主要由Java集合(Collections)框架使用。
  • K:键 Key,主要用于表示映射中的键的参数类型。
  • V:值 Value,主要用于表示映射中的值的参数类型。
  • N:数字 Number,主要用于表示数字。
  • T:类型,主要用于表示第一类通用型参数。
  • S:类型,主要用于表示第二类通用类型参数。
  • U:类型,主要用于表示第三类通用类型参数。
  • V:类型,主要用于表示第四个通用类型参数。

类型通配符

类型通配符一般是使用 ? 代替具体的类型参数,表示未知类型。例如 List<?> 在逻辑上是 List<String>List<Integer> 等所有 List<具体类型实参> 的父类。
类型通配符的形式有 <?><? extends Class><? super Class>

这里的 ? 和 我们上面介绍的 T 有什么区别的?
类型参数<T> 是个代号,用来声明泛型类或泛型方法。
无界通配符<?>是使用泛型类或泛型方法。
<?> 其实相当于 <? extends Object>

基本使用

为了表示各种范型 List 的父类,我们需要使用类型通配符,类型的通配符就是一个问号 ?,将它作为类型实参传给 List集合:List<?>,就标示未知类型元素的 List,它的元素类型可以匹配任何类型。
比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public void testGeneric() {
List<String> name = new ArrayList<String>();
List<Integer> age = new ArrayList<Integer>();
List<Shape> shape = new ArrayList<Shape>();

name.add("icon");
age.add(18);
shape.add(new Shape());

getData(name);
getData(age);
getData(shape);
}

public static void getData(List<?> data) {
Log.e("Test","data :" + data.get(0));
}

public static class Shape {
@Override
public String toString() {
return "Shape";
}
}

编译没问题,运行结果:

1
2
3
E/Test: data :icon
E/Test: data :18
E/Test: data :Shape

因为 getData() 方法的参数是 List 类型的,所以 name,age,shape 都可以作为这个方法的实参,这就是通配符的作用。

上面程序中使用的 List<?>,其实这种写法可以适用于任何支持范型声明的接口和类,比如 Set<?>Map<?,?>等。

设定类型通配符的上限

假设有下面的使用场景,我们不想使 List<?> 使任何范型 List 的父类,只想表示它是某一类范型List的父类,这时候我们就要限定通配符的上限了。
<? extends Class> 表示该通配符所代表的类型是 Class 类型本身或者它的子类。或者 <? extends T>
把上面的 getData 方法修改一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public void testGeneric() {
List<String> name = new ArrayList<String>();
List<Integer> age = new ArrayList<Integer>();
List<Shape> shape = new ArrayList<Shape>();
List<Circle> circle = new ArrayList<Circle>();

name.add("icon");
age.add(18);
shape.add(new Shape());

getData(name); // 编译报错
getData(age); // 编译报错
getData(shape); // 编译通过
getData(circle);// 编译通过
}

public static void getData(List<? extends Shape> data) {
Log.e("Test","data :" + data.get(0));
}

public static class Shape {
@Override
public String toString() {
return "Shape";
}
}

public static class Circle extends Shape {
@Override
public String toString() {
return "Circle";
}
}

前面两个用法就会报错:

1
2
Error:(74, 17) 错误: 不兼容的类型: List<String>无法转换为List<? extends Shape>
Error:(75, 17) 错误: 不兼容的类型: List<Integer>无法转换为List<? extends Shape>

设定类型通配符的下限

<? super Class> 表示该通配符所代表的类型是 Class 类本身或者它的父类。
把上面的 getData 方法再修改一下:

1
2
3
public static void getData(List<? super Shape> data) {
Log.e("Test","data :" + data.get(0));
}
1
2
3
4
getData(name);  // 编译报错
getData(age); // 编译报错
getData(shape); // 编译通过
getData(circle);// 编译报错

泛型方法

范型方法就是在声明方法时定义一个或多个类型形参,该方法在调用时可以接收不同类型的参数。根据传递给泛型方法的参数类型,编译器适当地处理每一个方法调用。
范型方法的类型作用域是整个方法。
范型方法的用法格式如下:

1
2
3
修饰符 <T, S> 返回值类型 方法名 (形参列表) {

}

下面是定义泛型方法的规则:

  • 所有泛型方法声明都有一个类型参数声明部分(由尖括号分隔),该类型参数声明部分在方法返回类型之前(在上面例子中的<T, S>)。
  • 每一个类型参数声明部分包含一个或多个类型参数,参数间用逗号隔开。一个泛型参数,也被称为一个类型变量,是用于指定一个泛型类型名称的标识符。
  • 类型参数可以用来声明方法参数。
  • 类型参数能被用来声明返回值类型。

先来看一个范型参数来声明方法参数的例子:

1
2
3
public <T> String getData(T t) {
return String.valueOf(t);
}

再来看一个范型参数来声明返回值类型的例子:

1
2
3
4
5
private Map<String, Object> mDatas = new ArrayMap<>();

public <T> T getData(String name) {
return (T) mDatas.get(name);
}

泛型类

泛型类的声明和非泛型类的声明类似,除了在类名后面添加了类型参数声明部分。
和泛型方法一样,泛型类的类型参数声明部分也包含一个或多个类型参数,参数间用逗号隔开。
一个泛型参数,也被称为一个类型变量,是用于指定一个泛型类型名称的标识符。因为他们接受一个或多个参数,这些类被称为参数化的类或参数化的类型。

1
2
3
4
5
6
7
8
9
public class Box<T> {
private T t;
public void add(T t) {
this.t = t;
}
public T get() {
return t;
}
}

限定类型参数上限

Java 范型不仅允许在使用通配符形参时设定上限,而且也可以在定义类型参数时设定上限,用于表示传给该类型形参的实际类型要么是该类型上限,要么使该类型的子类。
这种做法可以用在范型方法和范型类中。
用法:<U, T extends Class1>

1
2
3
4
5
6
7
8
9
10
public <T extends Shape & Serializable> String getData(T t) {
return String.valueOf(t);
}

public static class Shape {
@Override
public String toString() {
return "Shape";
}
}

形如 <U, T extends Class1 & Interface1> 表示 T 是继承了 Class1 的类以及实现了 Interface1,后面的接口可以有多个,因为 Java 是单继承,因此父类只能有1个。类要写在接口的前面。

自定义泛型T和类型通配符?的区别

首先他们都表示不确定的类型。
自定义泛型 T 可以在方法体内进行各种操作,比如:

1
2
T t = it.next();
System.out.println(t);

也可以方法返回值:

1
2
3
4
public  static <T> T getData(List<T> data){
Log.e("Test","data :" + data.get(0));
return data.get(0);
}

也就是说,当你仅仅想表达一种不确定类型时可以用类型通配符?,但你如果相对类型参数进行操作或者是想表达两个类型参数之间或者参数与返回值之间关系时,这时就要用自定义泛型 T。

协变和逆变

概念

逆变与协变用来描述类型转换(type transformation)后的继承关系,其定义:如果A、B表示类型,f(⋅)表示类型转换,≤表示继承关系(比如,A≤B表示A是由B派生出来的子类)
f(⋅)是逆变(contravariant)的,当A≤B时有f(B)≤f(A)成立;
f(⋅)是协变(covariant)的,当A≤B时有f(A)≤f(B)成立;
f(⋅)是不变(invariant)的,当A≤B时上述两个式子均不成立,即f(A)与f(B)相互之间没有继承关系。

数组是协变的

Java中数组是协变的,可以向子类型的数组赋予基类型的数组引用:

1
2
3
4
class Fruit {}
class Apple extends Fruit {}

Fruit[] fruit = new Apple[10];

泛型是不变的

当我们使用泛型容器来替代数组时,看看会发生什么。

1
List<Fruit> fruits = new ArrayList<Apple>(); // 编译错误

直接在编译时报错了。与数组不同,泛型没有内建的协变类型。这是因为数组在语言中是完全定义的,因此内建了编译期和运行时的检查,但是在使用泛型时,类型信息在编译期被擦除了。
那么有没有什么办法解决这个问题呢?有的,可以通过通配符引入协变和逆变。

通配符引入协变、逆变

我们可以通过 <? extends Class> 上限通配符使 Java 泛型具有协变性,通过<? super Class> 下限通配符使 Java 泛型具有逆变性。

1
2
List<? extends Fruit> fruits = new ArrayList<Apple>();
List<? super Apple> apples = new ArrayList<Fruit>();

上面的写法是正确的。
但是这里其实是有一些限制的,比如下面的代码:

1
2
3
4
5
6
7
List<? extends Fruit> fruits = new ArrayList<Apple>();
fruits.add(new Fruit()); // 报错
Fruit fruit = fruits.get(0); // 没问题

List<? super Apple> apples = new ArrayList<Fruit>();
apples.add(new Apple()); // 没问题
Apple apple = apples.get(0); // 报错

这里其实很容易理解,对于 <? extends Fruit> 虽然编译器不知道它是什么类型,但是它有个限制条件,它肯定是 Fruit 的子类,因此,fruits get 出来的对象也肯定是 Fruit 的子类,这样做一个类型转换肯定是没有问题的。但是,如果把他们的父类 Fruit 对象添加进去,类型转换时就会遇到问题。
由于这个限制,通配符 <? extends Fruit> 的 List,只能够对外提供数据被消费。
<? super Apple> 也是一样的道理。

PECS 法则

根据前面的介绍,Java 的泛型本身是不支持协变和逆变的。

  • 可以使用泛型通配符 ? extends 来使泛型支持协变,但是「只能读取不能修改」,这里的修改仅指对泛型集合添加元素,如果是 remove(int index) 以及 clear 当然是可以的。
  • 可以使用泛型通配符 ? super 来使泛型支持逆变,但是「只能修改不能读取」,这里说的不能读取是指不能按照泛型类型读取,你如果按照 Object 读出来再强转当然也是可以的。

根据前面的说法,这被称为 PECS 法则:「Producer-Extends, Consumer-Super」。

范型的特点

范型是类型擦除的

Java 的范型在编译器有效,在运行期被删除,也就是说所有的反省参数类型在编译期后都会被清除掉。
下面看一段代码:

1
2
3
4
5
6
7
public void listMethod(List<String> strings) {

}

public void listMethod(List<Integer> strings) {

}

这段代码是否能编译呢?
事实上,这段代码时无法编译的,编译时报错信息如下:

1
`listMethod(List<String>)` clashes with `listMethod(List<Integer>)`; both methods have same erasure

此错误信息是说 listMethod(List<String>) 方法在编译时擦除类型后的方法是 listMethod(List<E>),它与另一个方法相冲突。这就是 Java 范型擦除引起的问题:在编译后所有的范型类型都会做相应的转化:
转化规则如下:

  • List<String>List<Integer>List<T>擦除后的类型为List
  • List<String>[]擦除后的类型为List[]
  • List<? extends E>、List<? super E> 擦除后的类型为 List<E>
  • List<T extends Serializable & Cloneable> 擦除后为 List<Serializable>

范型的擦除还表现在当把一个具有范型信息的对象赋给另一个没有范型信息的变量时,所有再尖括号之间的类型信息都将被扔掉。
比如:一个 List<String> 类型被转换为 List,则该 List 对集合元素的类型检查变成了类型变量的上限(即 Object)。
下面用一个例子来示范这种擦除:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public void testGenericErasure() {
// 传入 Integer 作为类型形参的值
Box<Integer> box = new Box<>(8);
// box 的 get 方法返回 Integer 对象
Integer size = box.get();
// 把 box 对象赋值给b变量,此时会丢失<>里的类型信息
Box b = box;
//这个代码会引起编译错误
Integer size1 = b.get();
// b只知道size的类型是Number,但具体是Number的哪个子类就不清楚了。
//下面的用法是正确的
Number size2 = b.get();
}

public class Box<T extends Number> {

private T size;
public Box(T size) {
this.size = size;
}

public void add(T size) {
this.size = size;
}

public T get() {
return size;
}
}

类型转换:

1
2
3
4
5
6
7
8
9
10
11
public void testGenericConvert() {
List<Integer> li = new ArrayList<>();
li.add(8);
// 类型擦除
List list = li;
// 类型转换
List<String> ls = list;
// 下面的代码会引起运行时异常
// Caused by: java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String
Log.e("Test",ls.get(0));
}

明白了这些,对下面的代码就容易理解了:

1
2
3
4
List<String> stringList = new ArrayList<>();
List<Integer> integerList = new ArrayList<>();

Log.e("Test",""+stringList.getClass().equals(integerList.getClass()));

返回结果为 true。 List<String>List<Integer> 擦除后的类型都是 List,没有任何区别。
之所以设计成可擦除的,有下面两个原因:

  • 避免JVM大换血。由于范型是Java5以后才支持的,如果JVM也把范型类型延续到运行期,那么JVM就需要进行大量的重构工作了。也就是说,Java 中的泛型机制其实就是一颗语法糖,并不涉及JVM的改动。
  • 版本兼容问题。在编译器擦除可以更好地支持原生类型,在Java5或者Java6平台上,即使声明一个List这样的原生类型也是支

不能创建一个范型类型实例

如果 T 是一个类型变量,那么下面的语句是非法的:

1
T obj = new T();

T 由它的限界代替,这可能是 Object,或者是抽象类,因此对 new T() 的调用没有意义。

不能初始化范型数组

数组元素的类型不能包含类型变量或者类型形参,除非是无上限的类型通配符。但是可以声明元素类型包含类型变量或类型形参的数据。
也就是说,下面的代码是OK的:

1
List<String>[] stringList ;
1
2
3
4
public class Box<T extends Number> {

private List<T> list = new ArrayList<T>();
}

下面的代码,等号后面的代码是非法的:

1
List<String>[] stringList = new ArrayList<String>[10];

正确的写法应该是:

1
List<String>[] stringList = new ArrayList[10];

基本类型不能做类型参数

因此,List<int> 是非法的,我们必须使用包装类。

static 的语境不能引用类型变量

在一个范型类中,static 方法和 static 域均不可以引用类的类型变量,因为类型擦除后类型变量就不存在了。而且,static 域在该类的诸范型实例之间是共享的。因此,static 的语境不能引用类型变量。