本章概要
- 泛型和类型安全的集合
- 基本概念
如果一个程序只包含固定数量的对象且对象的生命周期都是已知的,那么这是一个非常简单的程序。
通常,程序总是根据运行时才知道的某些条件去创建新的对象。在此之前,无法知道所需对象的数量甚至确切类型。为了解决这个普遍的编程问题,需要在任意时刻和任意位置创建任意数量的对象。因此,不能依靠创建命名的引用来持有每一个对象:
MyType aReference;
因为从来不会知道实际需要多少个这样的引用。
大多数编程语言都提供了某种方法来解决这个基本问题。Java有多种方式保存对象(确切地说,是对象的引用)。例如前边曾经学习过的数组,它是编译器支持的类型。数组是保存一组对象的最有效的方式,如果想要保存一组基本类型数据,也推荐使用数组。但是数组具有固定的大小尺寸,而且在更一般的情况下,在写程序的时候并不知道将需要多少个对象,或者是否需要更复杂的方式来存储对象,因此数组尺寸固定这一限制就显得太过受限了。
java.util 库提供了一套相当完整的_集合类_(collection classes)来解决这个问题,其中基本的类型有 List 、 Set 、 Queue 和 Map。这些类型也被称作_容器类_(container classes),但我将使用Java类库使用的术语。集合提供了完善的方法来保存对象,可以使用这些工具来解决大量的问题。
集合还有一些其它特性。例如, Set 对于每个值都只保存一个对象, Map 是一个关联数组,允许将某些对象与其他对象关联起来。Java集合类都可以自动地调整自己的大小。因此,与数组不同,在编程时,可以将任意数量的对象放置在集合中,而不用关心集合应该有多大。
尽管在 Java 中没有直接的关键字支持,但集合类仍然是可以显著增强编程能力的基本工具。在本章中,将介绍 Java 集合类库的基本知识,并重点介绍一些典型用法。这里将专注于在日常编程中使用的集合。稍后,在附录:集合主题中,还将学习到其余的那些集合和相关功能,以及如何使用它们的更多详细信息。
泛型和类型安全的集合
使用 Java 5 之前的集合的一个主要问题是编译器允许你向集合中插入不正确的类型。例如,考虑一个 Apple 对象的集合,这里使用最基本最可靠的 ArrayList 。现在,可以把 ArrayList 看作“可以自动扩充自身尺寸的数组”来看待。使用 ArrayList 相当简单:创建一个实例,用 add()
插入对象;然后用 get()
来访问这些对象,此时需要使用索引,就像数组那样,但是不需要方括号。 ArrayList 还有一个 size()
方法,来说明集合中包含了多少个元素,所以不会不小心因数组越界而引发错误。
在本例中, Apple 和 Orange 都被放到了集合中,然后将它们取出。正常情况下,Java编译器会给出警告,因为这个示例没有使用泛型。在这里,使用特定的注解来抑制警告信息。注解以“@”符号开头,可以带参数。这里的 @SuppressWarning
注解及其参数表示只抑制“unchecked”类型的警告:
import java.util.*;
class Apple {
private static long counter;
private final long id = counter++;
public long id() {
return id;
}
}
class Orange {
}
public class ApplesAndOrangesWithoutGenerics {
@SuppressWarnings("unchecked")
public static void main(String[] args) {
ArrayList apples = new ArrayList();
for (int i = 0; i < 3; i++) {
apples.add(new Apple());
}
// No problem adding an Orange to apples:
apples.add(new Orange());
for (Object apple : apples) {
((Apple) apple).id();
// Orange is detected only at run time
}
}
}
Apple 和 Orange 是截然不同的,它们除了都是 Object 之外没有任何共同点(如果一个类没有显式地声明继承自哪个类,那么它就自动继承自 Object)。因为 ArrayList 保存的是 Object ,所以不仅可以通过 ArrayList 的 add()
方法将 Apple 对象放入这个集合,而且可以放入 Orange 对象,这无论在编译期还是运行时都不会有问题。当使用 ArrayList 的 get()
方法来取出你认为是 Apple 的对象时,得到的只是 Object 引用,必须将其转型为 Apple。然后需要将整个表达式用括号括起来,以便在调用 Apple 的 id()
方法之前,强制执行转型。否则,将会产生语法错误。
在运行时,当尝试将 Orange 对象转为 Apple 时,会出现输出中显示的错误。
在泛型章节中,你将了解到使用 Java 泛型来创建类可能很复杂。但是,使用预先定义的泛型类却相当简单。例如,要定义一个用于保存 Apple 对象的 ArrayList ,只需要使用 ArrayList 来代替 ArrayList 。尖括号括起来的是_类型参数_(可能会有多个),它指定了这个集合实例可以保存的类型。
通过使用泛型,就可以在编译期防止将错误类型的对象放置到集合中。下面还是这个示例,但是使用了泛型:
// collections/ApplesAndOrangesWithGenerics.java
import java.util.*;
public class ApplesAndOrangesWithGenerics {
public static void main(String[] args) {
ArrayList<Apple> apples = new ArrayList<>();
for (int i = 0; i < 3; i++) {
apples.add(new Apple());
}
// Compile-time error:
// apples.add(new Orange());
for (Apple apple : apples) {
System.out.println(apple.id());
}
}
}
0
1
2
在 apples 定义的右侧,可以看到 new ArrayList<>()
。这有时被称为“菱形语法”(diamond syntax)。在 Java 7 之前,必须要在两端都进行类型声明,如下所示:
ArrayList<Apple> apples = new ArrayList<Apple>();
随着类型变得越来越复杂,这种重复产生的代码非常混乱且难以阅读。程序员发现所有类型信息都可以从左侧获得,因此,编译器没有理由强迫右侧再重复这些。虽然_类型推断_(type inference)只是个很小的请求,Java 语言团队仍然欣然接受并进行了改进。
有了 ArrayList 声明中的类型指定,编译器会阻止将 Orange 放入 apples ,因此,这会成为一个编译期错误而不是运行时错误。
使用泛型,从 List 中获取元素不需要强制类型转换。因为 List 知道它持有什么类型,因此当调用 get()
时,它会替你执行转型。因此,使用泛型,你不仅知道编译器将检查放入集合的对象类型,而且在使用集合中的对象时也可以获得更清晰的语法。
当指定了某个类型为泛型参数时,并不仅限于只能将确切类型的对象放入集合中。向上转型也可以像作用于其他类型一样作用于泛型:
// collections/GenericsAndUpcasting.java
import java.util.*;
class GrannySmith extends Apple {
}
class Gala extends Apple {
}
class Fuji extends Apple {
}
class Braeburn extends Apple {
}
public class GenericsAndUpcasting {
public static void main(String[] args) {
ArrayList<Apple> apples = new ArrayList<>();
apples.add(new GrannySmith());
apples.add(new Gala());
apples.add(new Fuji());
apples.add(new Braeburn());
for (Apple apple : apples) {
System.out.println(apple);
}
}
}
因此,可以将 Apple 的子类型添加到被指定为保存 Apple 对象的集合中。
程序的输出是从 Object 默认的 toString()
方法产生的,该方法打印类名,后边跟着对象的散列码的无符号十六进制表示(这个散列码是通过 hashCode()
方法产生的)。
基本概念
Java集合类库采用“持有对象”(holding objects)的思想,并将其分为两个不同的概念,表示为类库的基本接口:
- 集合(Collection) :一个独立元素的序列,这些元素都服从一条或多条规则。List 必须以插入的顺序保存元素, Set 不能包含重复元素, Queue 按照_排队规则_来确定对象产生的顺序(通常与它们被插入的顺序相同)。
- 映射(Map) : 一组成对的“键值对”对象,允许使用键来查找值。 ArrayList 使用数字来查找对象,因此在某种意义上讲,它是将数字和对象关联在一起。 map 允许我们使用一个对象来查找另一个对象,它也被称作_关联数组_(associative array),因为它将对象和其它对象关联在一起;或者称作_字典_(dictionary),因为可以使用一个键对象来查找值对象,就像在字典中使用单词查找定义一样。 Map 是强大的编程工具。
尽管并非总是可行,但在理想情况下,你编写的大部分代码都在与这些接口打交道,并且唯一需要指定所使用的精确类型的地方就是在创建的时候。因此,可以像下面这样创建一个 List :
List<Apple> apples = new ArrayList<>();
请注意, ArrayList 已经被向上转型为了 List ,这与之前示例中的处理方式正好相反。使用接口的目的是,如果想要改变具体实现,只需在创建时修改它就行了,就像下面这样:
List<Apple> apples = new LinkedList<>();
因此,应该创建一个具体类的对象,将其向上转型为对应的接口,然后在其余代码中都是用这个接口。
这种方式并非总是有效的,因为某些具体类有额外的功能。例如, LinkedList 具有 List 接口中未包含的额外方法,而 TreeMap 也具有在 Map 接口中未包含的方法。如果需要使用这些方法,就不能将它们向上转型为更通用的接口。
Collection 接口概括了_序列_的概念——一种存放一组对象的方式。下面是个简单的示例,用 Integer 对象填充了一个 Collection (这里用 ArrayList 表示),然后打印集合中的每个元素:
// collections/SimpleCollection.java
import java.util.*;
public class SimpleCollection {
public static void main(String[] args) {
Collection<Integer> c = new ArrayList<>();
for (int i = 0; i < 10; i++) {
c.add(i); // Autoboxing
}
for (Integer i : c) {
System.out.print(i + ", ");
}
}
}
0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
这个例子仅使用了 Collection 中的方法(即 add()
),所以使用任何继承自 Collection 的类的对象都可以正常工作。但是 ArrayList 是最基本的序列类型。
add()
方法的名称就表明它是在 Collection 中添加一个新元素。但是,文档中非常详细地叙述到 add()
“要确保这个 Collection 包含指定的元素。”这是因为考虑到了 Set 的含义,因为在 Set中,只有当元素不存在时才会添加元素。在使用 ArrayList ,或任何其他类型的 List 时,add()
总是表示“把它放进去”,因为 List 不关心是否存在重复元素。
可以使用 for-in 语法来遍历所有的 Collection ,就像这里所展示的那样。在本章的后续部分,还将学习到一个更灵活的概念,迭代器。