Java 在 JDK5 中引入了 泛型 这一特性,泛型提供了编译时类型安全检测机制,该机制允许程序员在编译时检测到非法的类型。
泛型的必要性
让我们想象一个场景,我们想在 Java 中创建一个列表来存储Integer。
我们可能会尝试编写以下代码:
List list = new LinkedList();
list.add(new Integer(1));
Integer i = list.iterator().next();
上面的代码看似能满足我们的业务需要,实际上编译器是不认的。编译器会迷惑最后一行,它不知道返回什么数据类型。
为了要让编译器知道我们需要什么样的数据,我们需要做强制类型转换:
Integer i = (Integer) list.iterator.next();
此时,没有任何人能保证列表的返回类型是 Integer,list 列表可以包含任何对象,虽然通过阅读代码上下文,我们能确定此刻的类型转换是正确的,但是如果开发者在程序的编写过程中没有注意到类型转换的问题,那么错误只能等到运行时才会暴露出来。
让我们修改前面代码片段的第一行:
List<Integer> list = new LinkedList<>();
通过添加包含该类型的菱形运算符 <>
,我们将该列表的范围缩小为仅整数类型。也就是说,我们指定了列表中保存的类型,编译器可以在编译时强制执行该类型。
在大型应用程序中,这可以显着增加稳健性并使程序更易于阅读。
泛型
Oracle 建议使用大写字母来表示泛型类型,并选择更具描述性的字母来表示正式类型。
标记符 | 说明 | 示例 |
---|---|---|
E | Element 一般用来表示集合中的元素 | |
T | Type 一般用来表示 Java 类 | |
K | Key 一般表示Map 的 key | |
V | Value 一般表示Map 的 Value | |
N | Number 一般表示数值 | |
? | 未知 一般表示不确定的 Java 类型 |
泛型方法
我们用一个方法声明编写泛型方法,我们可以用不同类型的参数调用它们。编译器将确保我们使用的任何类型的正确性。
这些是泛型方法的一些特点:
- 泛型方法在方法声明的返回类型之前有一个类型参数(包围类型的菱形运算符)。
- 类型参数可以是有界的(我们将在本文后面解释边界)。
- 泛型方法可以在方法签名中有不同的类型参数,用逗号分隔。
- 泛型方法的方法体就像普通方法一样。
下面的示例展示,如何将数组转换为列表:
public <T> List<T> fromArrayToList(T[] a) {
return Arrays.stream(a).collect(Collectors.toList());
}
方法签名中的 <T>
意味着该方法将处理泛型类型 T。即使方法返回 void,这也是必需的。
如果一个方法可以处理不止一种泛型类型,我们必须将所有泛型类型添加到方法签名中。
public static <T, G> List<G> fromArrayToList(T[] a, Function<T, G> mapperFunction) {
return Arrays.stream(a)
.map(mapperFunction)
.collect(Collectors.toList());
}
上面的方法中传递一个函数 Function,该函数能够将 T 类型元素的数组转换为 G 类型元素的列表。
有界泛型 Bounded Generics
类型参数是有界的。Bounded 表示“受限”,我们可以限制方法接受的类型。
例如,我们可以指定一个方法接受一个类型及其所有子类(上限)或一个类型及其所有超类(下限)。
要声明一个上限类型,我们在类型之后使用关键字extends,然后是我们要使用的上限:
public <T extends Number> List<T> fromArrayToList(T[] a) {
...
}
一个类型也可以有多个上限:
<T extends Number & Comparable>
如果由 T 继承的类型之一是类(例如Number),我们必须将它放在边界列表的首位。否则,将导致编译时错误。
类型擦除
将泛型添加到 Java 中是为了确保类型安全,并且为了确保泛型不会在运行时产生开销,编译器在编译时会对泛型进行擦除。
类型擦除删除所有类型参数并用它们的边界替换它们,如果类型参数是无界的,则用Object替换它们。这样编译后的字节码只包含普通的类、接口和方法,保证不会产生新的类型。
public <T> List<T> genericMethod(List<T> list) {
return list.stream().collect(Collectors.toList());
}
上面这种无界的泛型,会被解析成下面的代码:
public List<Object> withErasure(List<Object> list) {
return list.stream().collect(Collectors.toList());
}
上面的代码,对于编码器来说,相当于是:
public List withErasure(List list) {
return list.stream().collect(Collectors.toList());
}
如果类型是有界的,则该类型将在编译时被边界替换:
public <T extends Animal> void genericMethod(T t) {
...
}
上面这种有界的泛型,会被解析成下面的代码:
public void genericMethod(Animal t) {
...
}
泛型和原始数据类型
Java 中泛型的一个限制是类型参数不能是原始类型。
也许你期待下面的代码能正确运行,实际上它根本无法编译。
List<int> list = new ArrayList<>();
list.add(22);
为了理解为什么原始数据类型不起作用,让我们记住泛型是一种编译时特性,这意味着类型参数被删除,所有泛型类型都实现为Object类型。
List<Integer> list = new ArrayList<>();
list.add(12);
上面的代码会被编译器认为 add() 方法是如下签名:
boolean add(E e);
也就是:
boolean add(Object e);
因此,类型参数必须可转换为Object。由于原始类型不是继承自 Object,我们不能将它们用作类型参数。
然而,Java 为原始基本类型提供了装箱类型,以及自动装箱和拆箱来处理它们:
List<Integer> list = new ArrayList<>();
list.add(315);
int first = list.get(0);
上面的代码相当于:
List list = new ArrayList<>();
list.add(Integer.valueOf(111));
int first = ((Integer) list.get(0)).intValue();
Java 泛型是对 Java 语言的强大补充,因为它使程序员的工作更轻松且不易出错。
泛型在编译时强制执行类型的正确性,最重要的是,这一切不会对我们的应用程序造成任何额外开销。