概述
上篇文章,我们通过给 ArrayList
和 LinkedList
容器实现迭代器,学习了迭代器模式的原理、实现和设计意图。迭代器模式主要主要是解耦容器代码和遍历代码。
本章,我们来深挖一下,如果在使用迭代器遍历集合的同时增加、删除集合元素,会发生什么情况呢?该如何应对?如何在遍历的同时安全地删除集合元素?
在遍历的同时增删集合元素会发生什么?
通过迭代器遍历集合元素的同时,增加或删除元素,可能会导致某个元素被重复遍历或遍历不到。不过,并不是所有情况下都会遍历出错,有时候也可以正常遍历,所以,这种行为成为结果不可预期行为或未决行为。也就是说,运行结果到底是对还是错,要视情况而定。
通过一个例子来解释一下。我们还是延续上篇文章实现的 ArrayList 迭代器的例子。相关代码重新拷贝到了这里。
public interface Iterator<E> {
boolean hasNext();
void next();
E currentItem();
}
public class ArrayIterator<E> implements Iterator<E> {
private int cursor;
private ArrayList<E> arrayList;
public ArrayIterator(ArrayList<E> arrayList) {
this.cursor = 0;
this.arrayList = arrayList;
}
@Override
public boolean hasNext() {
return cursor != arrayList.size(); // 注意这里,cursor在指向最后一个元素的时候,hasNext()仍返回true
}
@Override
public void next() {
cursor++;
}
@Override
public E currentItem() {
if (cursor >= arrayList.size()) {
throw new NoSuchElementException();
}
return arrayList.get(cursor);
}
}
public interface List<E> {
Iterator iterator();
}
public class ArrayList<E> implements List<E> {
// ...
@Override
public Iterator iterator() {
return new ArrayIterator(this);
}
// ...
}
public class Demo {
public static void main(String[] args) {
ArrayList<String> names = new ArrayList<>();
names.add("a");
names.add("b");
names.add("c");
names.add("d");
Iterator<String> iterator = names.iterator();
iterator.next();
names.remove("a");
}
}
ArrayList
的底层对应的是数组这种数据结构,在执行 Iterator<String> iterator = names.iterator()
时,数组中存储的是 a、b、c、d 四个元素,迭代器的游标 cursor
指向元素 a。当执行完 iterator.next()
时,游标指向 b,到这里没有问题。
为了保持数组存储数据的连续性,数组的删除操作会涉及元素的移动。当执行到 names.remove("a")
时,程序从数据中将元素 a 删除,b、c、d 依次往前移动一位,这就会导致游标本来指向元素 b,现在变成执行元素 c 了。原本在执行完 iterator.next()
后,我们还可以遍历 b、c、d 三个元素,但是在执行 names.remove("a")
之后,就只能遍历到 c、d 两个元素,b 遍历不到了。
上述过程如下图所示。
不过,如果删除的不是游标前面的元素(元素 a)及游标所在的位置的元素(元素 b),而是游标后面的元素(元素 c、d),这样就不会存在任何问题了,不会存在某个元素遍历不到的情况。
所以,在前面会说,在遍历的过程中删除元素,结果是不可预期的。
在遍历的过程中删除集合元素,有可能会导致某个元素遍历不到,那在遍历的过程中添加集合元素,会发生什么情况呢?
还是结合刚刚的例子,稍微改造下代码,把删除改为添加元素。
public class Demo {
public static void main(String[] args) {
ArrayList<String> names = new ArrayList<>();
names.add("a");
names.add("b");
names.add("c");
names.add("d");
Iterator<String> iterator = names.iterator();
iterator.next();
names.add(0, "x");
}
}
当执行完 iterator.next()
时,数组中包含 a、b、c、d 四个元素,游标指向 b。
在执行 names.add(0, "x")
之后,将 x 插入到下标为 0 的位置, a、b、c、d 四个元素依次向后移动一位。此时,游标又重新指向了 a。元素 a 被游标重复指向了两次,也就是说,元素 a 存在被重复遍历的情况。
跟删除类似,如果我们在游标的后面添加元素,就不会存在任何问题。所以,在遍历的同时添加元素也是一种不可预期行为。
如何应对遍历时改变集合导致的未决行为?
当通过迭代器遍历集合数据时,增加、删除集合元素会导致不可预期的遍历结果。实际上,“不可预期” 比直接出错更加可怕,有时候运行正确,有时候运行错误,一些隐藏很深、很难 debug 的 bug 就是这么产生的。那如何才能避免
出现这种不可预期的运行结果呢?
有两种解决方案:一种是遍历的时候不允许删除增删元素,另一种是增删元素之后让遍历报错。
第一种解决方式:不允许删除增删元素
实际上,第一种解决方案比较难实现,我们需要确定遍历开始和结束的时间点。遍历开始时间很容易获得。可以把迭代器的创建时间。但是遍历结束的时间点该如何确定呢?
可能你会说,遍历到最后一个元素的时候就结束。但是,在实际的开发中,每次使用迭代器来遍历元素,并不一定非要把所有元素遍历一遍。如下所示,代码找到一个值为 b 的元素就提前结束了遍历。
public class Demo {
public static void main(String[] args) {
ArrayList<String> names = new ArrayList<>();
names.add("a");
names.add("b");
names.add("c");
names.add("d");
Iterator<String> iterator = names.iterator();
while (iterator.hasNext()) {
String name = iterator.currentItem();
if (name.equals("b")) {
break;
}
}
}
}
你可能还会说,可以在迭代器中定义一个新的接口 finishIteration()
,主动告知容器迭代器使用完了,你可以增删元素了,代码如下所示。但是,这就要求程序员在使用完迭代器之后要主动调用这个函数,也增加了开发成本,还很容易漏掉。
public class Demo {
public static void main(String[] args) {
ArrayList<String> names = new ArrayList<>();
names.add("a");
names.add("b");
names.add("c");
names.add("d");
Iterator<String> iterator = names.iterator();
while (iterator.hasNext()) {
String name = iterator.currentItem();
if (name.equals("b")) {
iterator.finishIteration(); // 主动告知容器这个迭代器用完了
break;
}
}
}
}
第二种解决方式: 增删元素之后让遍历报错
实际上,第二种解决方式更加合理。在 Java 中就是采用的这种解决方案,增删元素之后,让遍历报错。接下来看下,具体如何实现。
怎么确定在遍历时,集合有没有增删元素呢?
在 ArrayList
中定义一个变量 modCount
,记录集合被修改的次数,集合每调用一次增加或删除元素,就会给 modCount
加 1.当通过调用集合上的 iterator()
函数来创建迭代器的时候,我们把 modCount
传递给迭代器的 expectedModCount
成员变量,之后每次调用迭代器上的 hasNext()
、next()
、currentItem()
函数,都会检查集合上的 modCount
是否等于 expectedModCount
,也就是看,在创建完迭代器之后,modCount
是否被改变过。
如果两个值不同,那就说明集合存储的元素被修改了,之前创建的迭代器就不能正确运行了,再继续使用就会产生不可预期结果,所以我们选择 fail-fast 解决方式,抛出运行时异常。
上面描述的代码实现如下所示。
public class ArrayIterator<E> implements Iterator<E> {
private int cursor;
private ArrayList<E> arrayList;
private int expectedModCount;
public ArrayIterator(ArrayList<E> arrayList) {
this.cursor = 0;
this.arrayList = arrayList;
this.expectedModCount = arrayList.modCount;
}
@Override
public boolean hasNext() {
checkForComodification();
return cursor != arrayList.size(); // 注意这里,cursor在指向最后一个元素的时候,hasNext()仍返回true
}
@Override
public void next() {
checkForComodification();
cursor++;
}
@Override
public E currentItem() {
checkForComodification();
if (cursor >= arrayList.size()) {
throw new NoSuchElementException();
}
return arrayList.get(cursor);
}
private void checkForComodification() {
if (arrayList.modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}
}
public class Demo {
public static void main(String[] args) {
ArrayList<String> names = new ArrayList<>();
names.add("a");
names.add("b");
names.add("c");
names.add("d");
Iterator<String> iterator = names.iterator();
iterator.next();
names.remove("a");
iterator.next(); // 抛出ConcurrentModificationException异常
}
}
如何在遍历的同时安全地删除集合元素?
像 Java 语言,迭代器类中除了前面提到的几个最基本的方法外,还定义了一个 remove()
方法,能够在遍历集合的同时,安全地删除集合中的元素。不过,需要说明的是,Java 并没有提供安全地添加元素的方法。毕竟迭代器的主要作用是遍历,添加元素放到迭代器里本身就不合适。
个人觉得,Java 迭代器中提供的 remove()
方法还是比较鸡肋的。它只能删除游标指向的前一个元素,而且 next()
函数之后,只能跟着最多一个 remove()
操作,多次调用 remove()
操作会报错。
public class Demo {
public static void main(String[] args) {
ArrayList<String> names = new ArrayList<>();
names.add("a");
names.add("b");
names.add("c");
names.add("d");
Iterator<String> iterator = names.iterator();
iterator.next();
iterator.remove();
iterator.remove(); // 抛出IllegalStateException异常
}
}
现在,我们来看下,为什么通过迭代器就能安全地删除集合中的元素呢?我们看下 remove()
函数是如何实现的,代码如下所示。
提醒下,Java 的实现中,迭代器类是容器的内部类,并且
next()
函数不仅将游标后移一位,还会返回当前的元素。
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
// ...
transient Object[] elementData;
private int size;
// ...
public Iterator<E> iterator() {
return new Itr();
}
private class Itr implements Iterator<E> {
int cursor; // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
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();
}
}
// ...
}
// ...
}
上面的迭代器类新增了一个 lastRet
成员变量,用来记录游标指向的前一个元素。通过迭代器去删除这个元素的时候,可以通过更新迭代器的游标和 lastRet
值,来保证不会因为删除元素而导致某个元素遍历不到。如果通过容器来删除元素,并且希望更新迭代器中的游标值来保证遍历不出错,那我们就要维护这个容器都创建了哪些迭代器了,每个迭代器是否还在使用信息,代码实现就比较复杂了。
总结
在通过迭代器来遍历集合元素的同时增删元素,可能会导致某个元素被重复遍历或遍历不到。不过,并不是所有的情况下都会遍历出错,有时候也能正常遍历,所以,这种行为成为不可预期行为或未决行为。实际上,“不可预期” 比直接出错更加可怕,一些隐藏很深、很难 debug 的 bug 就是这么产生的。
有两种解决方案,来避免出现这种不可预期的运行结果。
- 一种是遍历时,不允许增删元素。
- 另一种是增删元素之后,遍历报错。
第一种实现方式比较难实现,因为很难确定迭代器使用结束的时间点。第二种解决方案更加合理。
Java 语言中的迭代器就是采用的第二种方案。增删元素之后,选择 fail-fast 解决方式,让遍历直接抛出运行时异常。
Java 语言,迭代器还提供了一个 remove()
方法,能够在遍历集合的同时,安全地删除集合中的元素。