只是不愿随波逐流 ...|

lidongdongdong~

园龄:2年7个月粉丝:14关注:8

16、迭代器

内容来自王争 Java 编程之美

讲到容器,我们不得不讲一下迭代器
迭代器是遍历容器的常用方法,Java 中的迭代器是迭代模式的经典实现,虽然大部分情况下迭代器的使用都比较简单,但有些情况下也会比较复杂
比如在创建迭代器之后,增删容器中的元素,再使用迭代器遍历容器,会导致未决行为(结果不确定)
为了解决这一问题,迭代器又设计了一套复杂的保护机制,而这套机制在面试中又经常被考察,所以,本节我们就来详细讲一讲迭代器

1、容器的几种遍历方法

1.1、示例

常用的遍历容器的方法有 4 种:for 循环、for-each 循环、迭代器、forEach() 函数,关于这 4 种遍历方式如何使用,我们拿 List 容器做示例,如下所示

List<String> list = new ArrayList<>();
list.add("xiao");
list.add("zheng");
list.add("ge");
// 第一种遍历方式: for 循环
for (int i = 0; i < list.size(); ++i) {
System.out.println(list.get(i));
}
// 第二种遍历方式: for-each 循环
for (String s : list) {
System.out.println(s);
}
// 第三种遍历方式: 迭代器
Iterator<String> itr = list.iterator();
while (itr.hasNext()) {
System.out.println(itr.next());
}
// 第四种遍历方式: forEach() 函数
list.forEach(s -> System.out.println(s));

1.2、解释

实际上,并不是每种容器都支持以上 4 种遍历方式
在第 11 节中,我们将 JCF 分为 5 类:List、Stack、Queue、Set、Map
其中,因为 Stack、Queue 是操作受限的容器,只支持一端或两端操作,所以,一般不支持遍历
我们重点分析 List、Set、Map 这三类容器的遍历方式

  • 第 1 种遍历方式(for 循环遍历),只能用于 List 容器,比如 ArrayList、LinkedList
    因为这种遍历方式需要根据下标获取元素,而 Set、Map并没有下标的概念,所以不支持这种遍历方式
    尽管 LinkedList 底层使用链表实现,但实现了 List 接口,在用法上支持按照下标查找元素
    不过,对于 LinkedList 来说,使用 for 循环遍历的性能会比较差,因为在 LinkedList 上调用 get() 函数需要遍历链表
    所以,get() 函数的时间复杂度是 O(n),因此,for 循环遍历的时间复杂度是 O(n ^ 2)
  • 第 2 种遍历方式(for-each 循环)和第三种遍历方式(迭代器)是等价的
    for-each 循环遍历也叫做增强 for 循环,实际上是一种语法糖,其底层就是采用迭代器来实现的
  • 第 3 种遍历方式(迭代器遍历方式)只支持实现了 Iterable 接口的类
    在之前展示的 JCF 类图中,我们发现,List、Set 实现了 Iterable 接口,Map 没有实现 Iterable 接口,因此,List、Set 支持迭代器遍历,Map 不支持迭代器遍历
    如果要想通过迭代器遍历 Map 容器,我们需要通过间接的方式来实现
    通过 entrySet() 获取 EntrySet 对象,然后,通过 EntrySet 提供的迭代器来遍历,具体参看上一节的讲解
  • 第 4 种遍历方式(forEach() 函数)是 JDK 8 引入函数式编程时引入的,在 JDK 7 及其以前版本中不能使用
    List、Set、Map 都实现了 forEach() 函数,关于 forEach() 函数的介绍,我们留在函数式编程中讲解

对于这 4 种遍历方式,for 循环遍历非常简单,forEach() 函数稍后章节再讲,for-each 循环是迭代器的语法糖,所以,接下来,我们重点讲解迭代器遍历方式

2、迭代器存在的意义

对于简单容器来说,比如 ArrayList、LinkedList,其底层的存储结构比较简单,我们直接使用 for 循环遍历会比较简单
但是,对于复杂容器,比如 HashSet、TreeSet,其底层的存储结构比较复杂,遍历的逻辑比较复杂,如果让程序员自己去实现,势必增加了开发成本
所以,这些复杂容器便自己实现了遍历逻辑,包裹为迭代器,供程序员使用,以此降低程序员的开发成本
当然,为了统一访问方式,不管是简单容器还是复杂容器,Java 都提供了直接或间接的迭代器的遍历方式

3、迭代器的基本实现原理

Java 中的迭代器是迭代器设计模式的经典实现,关于迭代器模式的更多介绍,你可以阅读我的另一本书《设计模式之美》
迭代器设计模式的代码结构如下图所示
image
image

从上图中,我们得出,所有支持迭代器遍历方式的类都要实现 Iterable 接口

3.1、Iterable 接口

Iterable 接口在 JDK 8 中的定义如下所示
从代码中,我们也可以得到这样一个信息,但凡是支持迭代器遍历的容器,都支持 forEach() 函数遍历

public interface Iterable<T> {
Iterator<T> iterator();
default void forEach(Consumer<? super T> action) {
Objects.requireNonNull(action);
for (T t : this) {
action.accept(t);
}
}
// 用于配合实现 forEach() 函数式编程
default Spliterator<T> spliterator() {
return Spliterators.spliteratorUnknownSize(iterator(), 0);
}
}

3.2、ArrayList 容器

我们结合 ArrayList 容器来看下,具体如何为容器提供迭代器
从在第 11 节中展示的 JCF 类图中,ArrayList 实现了 List 接口,List 接口又继承自 Collection 接口,Collection 接口又继承自 Iterable
层层追溯,我们得出,ArrayList 实现了 Iterable 接口,ArrayList 中 iterator() 函数的代码实现如下所示

public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
// ... 省略其他属性和方法 ...
protected transient int modCount = 0;
public Iterator<E> iterator() {
return new Itr();
}
private class Itr implements Iterator<E> {
int cursor; // 光标:下一个返回元素的索引
int lastRet = -1; // 返回最后一个元素的索引,如果没有为 -1
int expectedModCount = modCount;
Itr() {
}
public boolean hasNext() {
return cursor != size;
}
@SuppressWarnings("unchecked")
public E next() {
checkForComodification();
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
}

如上代码所示,每个容器都定义了自己的迭代器,用于遍历自己的元素,并且,Iterator 接口是所有迭代器类的统一接口

3.3、Iterator 接口

Iterator 接口的定义如下所示,最基本最常用到的两个函数是 hasNext() 和 next() ,remove() 函数非必须,不常用到

public interface Iterator<E> {
boolean hasNext();
E next();
default void remove() {
throw new UnsupportedOperationException("remove");
}
}

其他容器的迭代器的实现,跟 ArrayList 容器的迭代器的实现类似,都遵从迭代器模式的代码结构

  • 容器实现 Iterable 接口,迭代器实现 Iterator 接口
  • 容器中定义迭代器类,并通过 iterator() 函数返回迭代器类对象

3.4、ListIterator 接口

不过,除了 Iterator之外,List 容器还补充提供了增强版的迭代器 ListIterator
除了 hasNext()、next()、remove() 函数之外,ListIterator 还提供了 hasPrevious()、previous()、set()、add() 等函数
ListIterator 接口的定义如下所示

// ListIterator 继承自 Iterator, 是 Iterator 的增强版
public interface ListIterator<E> extends Iterator<E> {
boolean hasNext();
E next();
boolean hasPrevious();
E previous();
int nextIndex();
int previousIndex();
void remove();
void set(E e);
void add(E e);
}

ListIterator 迭代器使用方法示例代码如下

List<Integer> list = new ArrayList<>();
list.addAll(Arrays.asList(0, 1, 2, 3, 4));
// 从下标为 2 的位置开始遍历
ListIterator<Integer> litr = list.listIterator(2);
while (litr.hasNext()) {
System.out.println(litr.next()); // 输出 2 3 4
}
while (litr.hasPrevious()) {
System.out.println(litr.previous()); // 输出 4 3 2 1 0
}

4、迭代器遍历存在的问题

在上述给出的 ArrayList 的迭代器类 Itr 的代码实现中,除了实现 hasNext()、next() 的必要成员变量 cursor,以及实现 remove() 的必要成员变量 lastRet 之外
还引入了一个特殊的成员变量 expectedModCount,其初始化为容器的 modCount 值
并且在调用 next() 函数时,通过调用 checkForComodification() 函数检查 expectedModCount 和 modCount 是否相等
为什么引入 expectedModCount 和 modCount 这两个成员变量呢?

因为在创建迭代器之后,增删容器中的元素,再使用迭代器遍历容器,会导致未决行为(结果不确定),所以才引入了这两个成员变量
我们先来看未决行为是如何产生的,再来看如何通过这两个成员变量来解决

我们先暂时假设,ArrayList 的迭代器 Itr 类中,未使用这两个成员变量,简化之后的 Itr 类代码如下所示

private class Itr implements Iterator<E> {
int cursor; // 光标:下一个返回元素的索引
Itr() {}
public boolean hasNext() {
return cursor != size;
}
public E next() {
int i = cursor;
if (i >= size) throw new NoSuchElementException();
cursor = i + 1;
return (E) ArrayList.this.elementData[i];
}
}

如果在使用以上简化之后的迭代器来遍历容器的同时,增加或者删除容器中的元素,有可能会出现某个元素被重复遍历或遍历不到的情况
但是,这种情况并非总是发生,所以,我们称这种情况称为未决行为或不确定行为,也就是说,运行结果到底是对还是错,要视情况而定,我们分两种情况展开讲解

4.1、示例 1

第一种情况是:在创建迭代器之后,增加集合中的元素,示例代码如下所示

List<String> list = new ArrayList<>(); // 第 1 行
list.addAll(Arrays.asList("a", "b", "c", "d")); // 第 2 行
Iterator<String> itr = list.iterator(); // 第 3 行
itr.next(); // 第 4 行
list.add(0, "x"); // 第 5 行

ArrayList 底层依赖数组这种数据结构来存储数据,当执行完第 4 行代码之后,数组中包含 "a"、 "b"、 "c"、 "d" 这四个元素,cursor 值为 1, "a" 元素已经被遍历,下一次调用 next() 函数将输出 "b",如下所图所示
在执行完第 5 行代码之后, "x" 插入到数组中下标为 0 的位置,"a"、 "b"、 "c"、 "d" 这四个元素依次往后挪移一位,此时,cursor 值不变,仍然为 1
cursor 重新指向了元素 "a",下一次调用 next() 函数将再次输出元素 "a",元素 "a" 被重复遍历了
不过,如果我们将元素 "x" 插入到 cursor 所指元素之后,便不会出现重复遍历的情况
image

4.2、示例 2

第二种情况是:在创建迭代器之后,删除集合中的元素,示例代码如下所示

List<String> list = new ArrayList<>(); // 第 1 行
list.addAll(Arrays.asList("a", "b", "c", "d")); // 第 2 行
Iterator<String> itr = list.iterator(); // 第 3 行
itr.next(); // 第 4 行
list.remove(0); // 第 5 行

对于上述代码,执行完第 4 行代码之后,数组中包含 "a"、 "b"、 "c"、 "d" 这四个元素,cursor 值为 1,下一次调用 next() 函数将输出 "b",如下所图所示
在执行完第 5 行代码之后,元素 "a" 被删除,其他三个元素会依次往前移动一位,此时,cursosr 值不变,仍然为 1
cursor 原来指向元素 "b",现在指向元素 "c",再次调用 next()将输出元素 "c",元素 "b" 无法遍历到了
不过,如果删除的元素位于 cursor 所指元素之后,便不会出现某个元素无法遍历的情况
image

5、迭代器问题的解决思路

当通过迭代器来遍历容器时,增加或删除容器中的元素,会导致不可预期的遍历结果
实际上, "不可预期" 比直接出错更加可怕,有的时候运行正确,有的时候运行错误,一些隐藏很深、很难 debug 的 bug 就是这么产生的,那么,我们如何才能避免出现这种不可预期的运行结果呢?

Java 采用这样的方式来解决的:增删元素之后,让遍历报错,怎么确定在遍历时候,容器有没有增删元素呢?
凡是支持迭代器遍历,也就是实现了 Iterable 接口的容器,都定义了 modCount 这样一个成员变量,用来记录容器被修改的次数,容器每调用一次增加或删除元素的函数,就会给 modCount 加 1
如下 ArrayList 中的 remove() 函数所示

// ArrayList 的 remove() 函数的代码实现
public E remove(int index) {
rangeCheck(index);
modCount++; // modCount + 1
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index + 1, elementData, index, numMoved);
elementData[--size] = null;
return oldValue;
}

当通过调用容器上的 iterator() 函数来创建迭代器时

  • 我们把容器上的 modCount 成员变量值传递给迭代器的 expectedModCount 成员变量
  • 之后每次调用迭代器上的 next() 函数,我们都会调用 checkForComodification() 函数
    检查容器上的 modCount 成员变量值是否等于迭代器上的 expectedModCount 成员变量值
    也就是看,在创建完迭代器之后,modCount 是否改变过
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}

如果两个值不相同,那就说明容器要么增加了元素,要么删除了元素,之前创建的迭代器已经不能正确运行了,再继续使用就会产生不可预期的结果
所以我们选择 fail-fast 解决方式,抛出运行时异常 ConcurrentModificationException,结束掉程序,让程序员尽快修复这个因为不正确使用迭代器而产生的 bug

上述处理逻辑可以对照查看 ArrayList 的迭代器类 Itr 的代码实现

6、利用迭代器安全删除元素

Iterator 接口中除了最基本的 hasNext()、next() 方法之外,还定义了一个 remove()方法,ArrayList 的迭代器 Itr 中的 remove() 方法的代码实现如下所示

public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}

6.1、示例

使用 remove() 方法,我们能够在遍历容器的同时,安全地删除容器中的元素,示例代码如下所示

List<String> list = new ArrayList<>(); // 第 1 行
list.addAll(Arrays.asList("a", "b", "c", "d")); // 第 2 行
Iterator<String> itr = list.iterator(); // 第 3 行
System.out.println(itr.next()); // 第 4 行 "a"
System.out.println(itr.next()); // 第 5 行 "b"
itr.remove(); // 第 6 行 "删除 b"
System.out.println(itr.next()); // 第 7 行 "c"

上述代码的打印结果,如上述代码中的注释所示,remove() 函数会删除 cursor 所指的前一个元素,也就是 lastRet 成员变量的值作为下标对应的元素

执行前两个 next() 函数之后,cursor 等于 2,lastRet 等于 1,此时 cursor 指向元素 "c"
调用 remove() 函数:会删除元素 "b",cursor 重新赋值为 lastRet,也就是等于 1,lastRet 被赋值为 -1,数组中的元素 "c" 和 "d" 往前移动一位
一番操作之后,cursor 仍然指向元素 "c",如下图所示
image

6.2、不足

不过,Java 迭代器中提供的 remove() 方法还是比较鸡肋的,作用有限
如 ArrayList 的迭代器 Itr 中的 remove() 函数的实现所示,它只能删除 cursor 指向的前一个元素
而且调用一个 next() 函数之后,只能跟着最多一个 remove() 操作,多次调用 remove() 操作会报错

这是因为调用完 remove() 函数之后,lastRet 便变为了 -1,而调用 remove() 函数时,会检查 lastRet 是否小于 0,如果小于 0,则报 IllegalStateException 异常,示例代码如下所示

List<String> list = new ArrayList<>();
list.addAll(Arrays.asList("a", "b", "c", "d"));
Iterator<String> itr = list.iterator();
System.out.println(itr.next()); // "a"
System.out.println(itr.next()); // "b"
itr.remove(); // 删除 "b"
itr.remove(); // 报 IllegalStateException 异常

按理来说,lastRet = cursor - 1,我们没必要记录 lastRet
而且,在调用完 remove() 函数之后,我们也没必要将 lastRet 值设置为 -1
我们可以将 lastRet 值设置为 lastRet - 1,这样就可以实现连续调用 remove() 函数多次

  • ArrayList 底层采用数组来实现,刚刚的分析一点问题都没有
  • 实际上,LinkedList 问题也不大,因为其基于双向链表来实现,也很容易追溯到前一个节点
  • 但对于哈希表实现的 Set 来说,我们用 lastRet 记录上一次输出的元素
    在调用 remove() 函数之后,我们将 cursor 设置为 lastRet
    但是 lastRet 无法指向上上次输出的元素了,因此我们就没法再次调用 remove() 函数了

为了统一各个容器的迭代器的 remove() 函数的表现,我们限制在调用 next() 函数之后只能调用 remove() 函数一次

7、课后思考题

本节中讲到,List 容器还支持增强版迭代器 ListIterator,ListIterator 中包含 add() 函数,支持通过迭代器添加元素,请研究一下源码,分析如下代码的运行结果

List<String> list = new ArrayList<>();
list.addAll(Arrays.asList("a", "b", "c", "d"));
ListIterator<String> litr = list.listIterator();
System.out.println(litr.next());
litr.add("x");
litr.add("y");
System.out.println(litr.next());
posted @   lidongdongdong~  阅读(55)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开