Java的集合类型。集合类型也是Java标准库中被使用最多的类型。
image.png


Java集合简介

什么是集合(Collection)?集合就是“由若干个确定的元素所构成的整体”。例如,5只小兔构成的集合:

  1. (\_(\ (\_/) (\_/) (\_/) (\(\
  2. ( -.-) (•.•) (>.<) (^.^) (='.')
  3. C(")_(") (")_(") (")_(") (")_(") O(_")")

在数学中,我们经常遇到集合的概念。例如:

  • 有限集合:
    • 一个班所有的同学构成的集合;
    • 一个网站所有的商品构成的集合;
  • 无限集合:
    • 全体自然数集合:1,2,3,……
    • 有理数集合;
    • 实数集合;

为什么要在计算机中引入集合呢?这是为了便于处理一组类似的数据,例如:

  • 计算所有同学的总成绩和平均成绩;
  • 列举所有的商品名称和价格;
  • ……

在Java中,如果一个Java对象可以在内部持有若干其他Java对象,并对外提供访问接口,我们把这种Java对象称为集合。很显然,Java的数组可以看作是一种集合: :::info String[] ss = new String[10]; // 可以持有10个String对象
ss[0] = “Hello”; // 可以放入String对象
String first = ss[0]; // 可以获取String对象 ::: 既然Java提供了数组这种数据类型,可以充当集合,那么,我们为什么还需要其他集合类?这是因为数组有如下限制:

  • 数组初始化后大小不可变;
  • 数组只能按索引顺序存取。

因此,我们需要各种不同类型的集合类来处理不同的数据,例如:

  • 可变大小的顺序链表;
  • 保证无重复元素的集合;
  • Collection

    Java标准库自带的java.util包提供了集合类:Collection,它是除Map外所有其他集合类的根接口。Java的java.util包主要提供了以下三种类型的集合:

  • List:一种有序列表的集合,例如,按索引排列的Student的List;

  • Set:一种保证没有重复元素的集合,例如,所有无重复名称的Student的Set;
  • Map:一种通过键值(key-value)查找的映射表集合,例如,根据Student的name查找对应Student的Map。

Java集合的设计有几个特点:一是实现了接口和实现类相分离,例如,有序表的接口是List,具体的实现类有ArrayList,LinkedList等,二是支持泛型,我们可以限制在一个集合中只能放入同一种数据类型的元素,例如: :::info List list = new ArrayList<>(); // 只能放入String类型 ::: 最后,Java访问集合总是通过统一的方式——迭代器(Iterator)来实现,它最明显的好处在于无需知道集合内部元素是按什么方式存储的。

由于Java的集合设计非常久远,中间经历过大规模改进,我们要注意到有一小部分集合类是遗留类,不应该继续使用:

  • Hashtable:一种线程安全的Map实现;
  • Vector:一种线程安全的List实现;
  • Stack:基于Vector实现的LIFO的栈。

还有一小部分接口是遗留接口,也不应该继续使用:

  • Enumeration:已被Iterator取代。

    本章小结

    Java的集合类定义在java.util包中,支持泛型,主要提供了3种集合类,包括List,Set和Map。Java集合使用统一的Iterator遍历,尽量不要使用遗留接口。

    使用List

    在集合类中,List是最基础的一种集合:它是一种有序列表。

List的行为和数组几乎完全相同:List内部按照放入元素的先后顺序存放,每个元素都可以通过索引确定自己的位置,List的索引和数组一样,从0开始。

数组和List类似,也是有序结构,如果我们使用数组,在添加和删除元素的时候,会非常不方便。例如,从一个已有的数组{‘A’, ‘B’, ‘C’, ‘D’, ‘E’}中删除索引为2的元素:

  1. ┌───┬───┬───┬───┬───┬───┐
  2. A B C D E
  3. └───┴───┴───┴───┴───┴───┘
  4. ┌───┘
  5. ┌───┘
  6. ┌───┬───┬───┬───┬───┬───┐
  7. A B D E
  8. └───┴───┴───┴───┴───┴───┘

这个“删除”操作实际上是把’C’后面的元素依次往前挪一个位置,而“添加”操作实际上是把指定位置以后的元素都依次向后挪一个位置,腾出来的位置给新加的元素。这两种操作,用数组实现非常麻烦。

因此,在实际应用中,需要增删元素的有序列表,我们使用最多的是ArrayList。实际上,ArrayList在内部使用了数组来存储所有元素。例如,一个ArrayList拥有5个元素,实际数组大小为6(即有一个空位):

  1. size=5
  2. ┌───┬───┬───┬───┬───┬───┐
  3. A B C D E
  4. └───┴───┴───┴───┴───┴───┘

当添加一个元素并指定索引到ArrayList时,ArrayList自动移动需要移动的元素:

  1. size=5
  2. ┌───┬───┬───┬───┬───┬───┐
  3. A B C D E
  4. └───┴───┴───┴───┴───┴───┘

然后,往内部指定索引的数组位置添加一个元素,然后把size加1:

  1. size=6
  2. ┌───┬───┬───┬───┬───┬───┐
  3. A B F C D E
  4. └───┴───┴───┴───┴───┴───┘

继续添加元素,但是数组已满,没有空闲位置的时候,ArrayList先创建一个更大的新数组,然后把旧数组的所有元素复制到新数组,紧接着用新数组取代旧数组:

  1. size=6
  2. ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
  3. A B F C D E
  4. └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘

现在,新数组就有了空位,可以继续添加一个元素到数组末尾,同时size加1:

  1. size=7
  2. ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
  3. A B F C D E G
  4. └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘

可见,ArrayList把添加和删除的操作封装起来,让我们操作List类似于操作数组,却不用关心内部元素如何移动。

我们考察List接口,可以看到几个主要的接口方法:

  • 在末尾添加一个元素:boolean add(E e)
  • 在指定索引添加一个元素:boolean add(int index, E e)
  • 删除指定索引的元素:E remove(int index)
  • 删除某个元素:boolean remove(Object e)
  • 获取指定索引的元素:E get(int index)
  • 获取链表大小(包含元素的个数):int size()

但是,实现List接口并非只能通过数组(即ArrayList的实现方式)来实现,另一种LinkedList通过“链表”也实现了List接口。在LinkedList中,它的内部每个元素都指向下一个元素:

  1. ┌───┬───┐ ┌───┬───┐ ┌───┬───┐ ┌───┬───┐
  2. HEAD ──>│ A ●─┼──>│ B ●─┼──>│ C ●─┼──>│ D
  3. └───┴───┘ └───┴───┘ └───┴───┘ └───┴───┘

我们来比较一下ArrayList和LinkedList:


ArrayList LinkedList
获取指定元素 速度很快 需要从头开始查找元素
添加元素到末尾 速度很快 速度很快
在指定位置添加/删除 需要移动元素 不需要移动元素
内存占用 较大

通常情况下,我们总是优先使用ArrayList。

List的特点

使用List时,我们要关注List接口的规范。List接口允许我们添加重复的元素,即List内部的元素可以重复: :::info import java.util.ArrayList;
import java.util.List; :::

  1. public class Main {
  2. public static void main(String[] args) {
  3. List<String> list = new ArrayList<>();
  4. list.add("apple"); // size=1
  5. list.add("pear"); // size=2
  6. list.add("apple"); // 允许重复添加元素,size=3
  7. System.out.println(list.size());
  8. }
  9. }

List还允许添加null: :::info import java.util.ArrayList;
import java.util.List; :::

  1. public class Main {
  2. public static void main(String[] args) {
  3. List<String> list = new ArrayList<>();
  4. list.add("apple"); // size=1
  5. list.add(null); // size=2
  6. list.add("pear"); // size=3
  7. String second = list.get(1); // null
  8. System.out.println(second);
  9. }
  10. }

创建List

除了使用ArrayList和LinkedList,我们还可以通过List接口提供的of()方法,根据给定元素快速创建List: :::info List list = List.of(1, 2, 5); ::: 但是List.of()方法不接受null值,如果传入null,会抛出NullPointerException异常。

遍历List

和数组类型,我们要遍历一个List,完全可以用for循环根据索引配合get(int)方法遍历: :::info import java.util.List; :::

  1. public class Main {
  2. public static void main(String[] args) {
  3. List<String> list = List.of("apple", "pear", "banana");
  4. for (int i=0; i<list.size(); i++) {
  5. String s = list.get(i);
  6. System.out.println(s);
  7. }
  8. }
  9. }

但这种方式并不推荐,一是代码复杂,二是因为get(int)方法只有ArrayList的实现是高效的,换成LinkedList后,索引越大,访问速度越慢。

所以我们要始终坚持使用迭代器Iterator来访问List。Iterator本身也是一个对象,但它是由List的实例调用iterator()方法的时候创建的。Iterator对象知道如何遍历一个List,并且不同的List类型,返回的Iterator对象实现也是不同的,但总是具有最高的访问效率。

Iterator对象有两个方法:boolean hasNext()判断是否有下一个元素,E next()返回下一个元素。因此,使用Iterator遍历List代码如下: :::info import java.util.Iterator;
import java.util.List; :::

  1. public class Main {
  2. public static void main(String[] args) {
  3. List<String> list = List.of("apple", "pear", "banana");
  4. for (Iterator<String> it = list.iterator(); it.hasNext(); ) {
  5. String s = it.next();
  6. System.out.println(s);
  7. }
  8. }
  9. }

有童鞋可能觉得使用Iterator访问List的代码比使用索引更复杂。但是,要记住,通过Iterator遍历List永远是最高效的方式。并且,由于Iterator遍历是如此常用,所以,Java的for each循环本身就可以帮我们使用Iterator遍历。把上面的代码再改写如下: :::info import java.util.List; :::

  1. public class mport java.util.List;Main {
  2. public static void main(String[] args) {
  3. List<String> list = List.of("apple", "pear", "banana");
  4. for (String s : list) {
  5. System.out.println(s);
  6. }
  7. }
  8. }

上述代码就是我们编写遍历List的常见代码。

实际上,只要实现了Iterable接口的集合类都可以直接用for each循环来遍历,Java编译器本身并不知道如何遍历集合对象,但它会自动把for each循环变成Iterator的调用,原因就在于Iterable接口定义了一个Iterator iterator()方法,强迫集合类必须返回一个Iterator实例。

List和Array转换

把List变为Array有三种方法,第一种是调用toArray()方法直接返回一个Object[]数组: :::info import java.util.List; :::

  1. public class Main {
  2. public static void main(String[] args) {
  3. List<String> list = List.of("apple", "pear", "banana");
  4. Object[] array = list.toArray();
  5. for (Object s : array) {
  6. System.out.println(s);
  7. }
  8. }
  9. }

这种方法会丢失类型信息,所以实际应用很少。

第二种方式是给toArray(T[])传入一个类型相同的Array,List内部自动把元素复制到传入的Array中: :::info import java.util.List; :::

  1. public class Main {
  2. public static void main(String[] args) {
  3. List<Integer> list = List.of(12, 34, 56);
  4. Integer[] array = list.toArray(new Integer[3]);
  5. for (Integer n : array) {
  6. System.out.println(n);
  7. }
  8. }
  9. }

注意到这个toArray(T[])方法的泛型参数并不是List接口定义的泛型参数,所以,我们实际上可以传入其他类型的数组,例如我们传入Number类型的数组,返回的仍然是Number类型: :::info import java.util.List; :::

  1. public class Main {
  2. public static void main(String[] args) {
  3. List<Integer> list = List.of(12, 34, 56);
  4. Number[] array = list.toArray(new Number[3]);
  5. for (Number n : array) {
  6. System.out.println(n);
  7. }
  8. }
  9. }

但是,如果我们传入类型不匹配的数组,例如,String[]类型的数组,由于List的元素是Integer,所以无法放入String数组,这个方法会抛出ArrayStoreException。

如果我们传入的数组大小和List实际的元素个数不一致怎么办?根据List接口的文档,我们可以知道:

如果传入的数组不够大,那么List内部会创建一个新的刚好够大的数组,填充后返回;如果传入的数组比List元素还要多,那么填充完元素后,剩下的数组元素一律填充null。

实际上,最常用的是传入一个“恰好”大小的数组: :::info Integer[] array = list.toArray(new Integer[list.size()]); ::: 最后一种更简洁的写法是通过List接口定义的T[] toArray(IntFunction generator)方法: :::info Integer[] array = list.toArray(Integer[]::new); ::: 这种函数式写法我们会在后续讲到。

反过来,把Array变为List就简单多了,通过List.of(T…)方法最简单: :::info Integer[] array = { 1, 2, 3 };
List list = List.of(array); ::: 对于JDK 11之前的版本,可以使用Arrays.asList(T…)方法把数组转换成List。

要注意的是,返回的List不一定就是ArrayList或者LinkedList,因为List只是一个接口,如果我们调用List.of(),它返回的是一个只读List: :::info import java.util.List; :::

  1. public class Main {
  2. public static void main(String[] args) {
  3. List<Integer> list = List.of(12, 34, 56);
  4. list.add(999); // UnsupportedOperationException
  5. }
  6. }

对只读List调用add()、remove()方法会抛出UnsupportedOperationException。

本章小结

List是按索引顺序访问的长度可变的有序表,优先使用ArrayList而不是LinkedList;
可以直接使用for each遍历List;
List可以和Array相互转换。

编写equals方法

我们知道List是一种有序链表:List内部按照放入元素的先后顺序存放,并且每个元素都可以通过索引确定自己的位置。

List还提供了boolean contains(Object o)方法来判断List是否包含某个指定元素。此外,int indexOf(Object o)方法可以返回某个元素的索引,如果元素不存在,就返回-1。

我们来看一个例子: :::info import java.util.List; :::

  1. public class Main {
  2. public static void main(String[] args) {
  3. List<String> list = List.of("A", "B", "C");
  4. System.out.println(list.contains("C")); // true
  5. System.out.println(list.contains("X")); // false
  6. System.out.println(list.indexOf("C")); // 2
  7. System.out.println(list.indexOf("X")); // -1
  8. }
  9. }

这里我们注意一个问题,我们往List中添加的”C”和调用contains(“C”)传入的”C”是不是同一个实例?

如果这两个”C”不是同一个实例,这段代码是否还能得到正确的结果?我们可以改写一下代码测试一下: :::info import java.util.List; :::

  1. public class Main {
  2. public static void main(String[] args) {
  3. List<String> list = List.of("A", "B", "C");
  4. System.out.println(list.contains(new String("C"))); // true or false?
  5. System.out.println(list.indexOf(new String("C"))); // 2 or -1?
  6. }
  7. }

因为我们传入的是new String(“C”),所以一定是不同的实例。结果仍然符合预期,这是为什么呢?

因为List内部并不是通过==判断两个元素是否相等,而是使用equals()方法判断两个元素是否相等,例如contains()方法可以实现如下: :::info public class ArrayList {
Object[] elementData;
public boolean contains(Object o) {
for (int i = 0; i < elementData.length; i++) {
if (o.equals(elementData[i])) {
return true;
}
}
return false;
}
} ::: 因此,要正确使用List的contains()、indexOf()这些方法,放入的实例必须正确覆写equals()方法,否则,放进去的实例,查找不到。我们之所以能正常放入String、Integer这些对象,是因为Java标准库定义的这些类已经正确实现了equals()方法。

我们以Person对象为例,测试一下: :::info import java.util.List; :::

  1. public class Main {
  2. public static void main(String[] args) {
  3. List<Person> list = List.of(
  4. new Person("Xiao Ming"),
  5. new Person("Xiao Hong"),
  6. new Person("Bob")
  7. );
  8. System.out.println(list.contains(new Person("Bob"))); // false
  9. }
  10. }
  11. class Person {
  12. String name;
  13. public Person(String name) {
  14. this.name = name;
  15. }
  16. }

不出意外,虽然放入了new Person(“Bob”),但是用另一个new Person(“Bob”)查询不到,原因就是Person类没有覆写equals()方法。

编写equals

如何正确编写equals()方法?equals()方法要求我们必须满足以下条件:

  • 自反性(Reflexive):对于非null的x来说,x.equals(x)必须返回true;
  • 对称性(Symmetric):对于非null的x和y来说,如果x.equals(y)为true,则y.equals(x)也必须为true;
  • 传递性(Transitive):对于非null的x、y和z来说,如果x.equals(y)为true,y.equals(z)也为true,那么x.equals(z)也必须为true;
  • 一致性(Consistent):对于非null的x和y来说,只要x和y状态不变,则x.equals(y)总是一致地返回true或者false;
  • 对null的比较:即x.equals(null)永远返回false。

上述规则看上去似乎非常复杂,但其实代码实现equals()方法是很简单的,我们以Person类为例: :::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::

:::info

:::