CategoryResourceRepost/极客时间专栏/设计模式之美/设计模式与范式:行为型/66 | 迭代器模式(中):遍历集合的同时,为什么不能增删集合元素?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

350 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="66 | 迭代器模式(中):遍历集合的同时,为什么不能增删集合元素?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/21/b4/21baae81b9e452b99b4a3b20411279b4.mp3"></audio>
上一节课中我们通过给ArrayList、LinkedList容器实现迭代器学习了迭代器模式的原理、实现和设计意图。迭代器模式主要作用是解耦容器代码和遍历代码这也印证了我们前面多次讲过的应用设计模式的主要目的是解耦。
上一节课中讲解的内容都比较基础,今天,我们来深挖一下,如果在使用迭代器遍历集合的同时增加、删除集合中的元素,会发生什么情况?应该如何应对?如何在遍历的同时安全地删除集合元素?
话不多说,让我们正式开始今天的内容吧!
## 在遍历的同时增删集合元素会发生什么?
在通过迭代器来遍历集合元素的同时,增加或者删除集合中的元素,有可能会导致某个元素被重复遍历或遍历不到。不过,并不是所有情况下都会遍历出错,有的时候也可以正常遍历,所以,这种行为称为**结果不可预期行为**或者**未决行为**,也就是说,运行结果到底是对还是错,要视情况而定。
怎么理解呢我们通过一个例子来解释一下。我们还是延续上一节课实现的ArrayList迭代器的例子。为了方便你查看我把相关的代码都重新拷贝到这里了。
```
public interface Iterator&lt;E&gt; {
boolean hasNext();
void next();
E currentItem();
}
public class ArrayIterator&lt;E&gt; implements Iterator&lt;E&gt; {
private int cursor;
private ArrayList&lt;E&gt; arrayList;
public ArrayIterator(ArrayList&lt;E&gt; arrayList) {
this.cursor = 0;
this.arrayList = arrayList;
}
@Override
public boolean hasNext() {
return cursor &lt; arrayList.size();
}
@Override
public void next() {
cursor++;
}
@Override
public E currentItem() {
if (cursor &gt;= arrayList.size()) {
throw new NoSuchElementException();
}
return arrayList.get(cursor);
}
}
public interface List&lt;E&gt; {
Iterator iterator();
}
public class ArrayList&lt;E&gt; implements List&lt;E&gt; {
//...
public Iterator iterator() {
return new ArrayIterator(this);
}
//...
}
public class Demo {
public static void main(String[] args) {
List&lt;String&gt; names = new ArrayList&lt;&gt;();
names.add(&quot;a&quot;);
names.add(&quot;b&quot;);
names.add(&quot;c&quot;);
names.add(&quot;d&quot;);
Iterator&lt;String&gt; iterator = names.iterator();
iterator.next();
names.remove(&quot;a&quot;);
}
}
```
我们知道ArrayList底层对应的是数组这种数据结构在执行完第55行代码的时候数组中存储的是a、b、c、d四个元素迭代器的游标cursor指向元素a。当执行完第56行代码的时候游标指向元素b到这里都没有问题。
为了保持数组存储数据的连续性数组的删除操作会涉及元素的搬移详细的讲解你可以去看我的另一个专栏《数据结构与算法之美》。当执行到第57行代码的时候我们从数组中将元素a删除掉b、c、d三个元素会依次往前搬移一位这就会导致游标本来指向元素b现在变成了指向元素c。原本在执行完第56行代码之后我们还可以遍历到b、c、d三个元素但在执行完第57行代码之后我们只能遍历到c、d两个元素b遍历不到了。
对于上面的描述,我画了一张图,你可以对照着理解。
<img src="https://static001.geekbang.org/resource/image/d8/e9/d86223f2b0f996ebb2b21e5abbeceae9.jpg" alt="">
不过如果第57行代码删除的不是游标前面的元素元素a以及游标所在位置的元素元素b而是游标后面的元素元素c和d这样就不会存在任何问题了不会存在某个元素遍历不到的情况了。
所以我们前面说在遍历的过程中删除集合元素结果是不可预期的有时候没问题删除元素c或d有时候就有问题删除元素a或b这个要视情况而定到底删除的是哪个位置的元素就是这个意思。
在遍历的过程中删除集合元素,有可能会导致某个元素遍历不到,那在遍历的过程中添加集合元素,会发生什么情况呢?还是结合刚刚那个例子来讲解,我们将上面的代码稍微改造一下,把删除元素改为添加元素。具体的代码如下所示:
```
public class Demo {
public static void main(String[] args) {
List&lt;String&gt; names = new ArrayList&lt;&gt;();
names.add(&quot;a&quot;);
names.add(&quot;b&quot;);
names.add(&quot;c&quot;);
names.add(&quot;d&quot;);
Iterator&lt;String&gt; iterator = names.iterator();
iterator.next();
names.add(0, &quot;x&quot;);
}
}
```
在执行完第10行代码之后数组中包含a、b、c、d四个元素游标指向b这个元素已经跳过了元素a。在执行完第11行代码之后我们将x插入到下标为0的位置a、b、c、d四个元素依次往后移动一位。这个时候游标又重新指向了元素a。元素a被游标重复指向两次也就是说元素a存在被重复遍历的情况。
跟删除情况类似,如果我们在游标的后面添加元素,就不会存在任何问题。所以,在遍历的同时添加集合元素也是一种不可预期行为。
同样,对于上面的添加元素的情况,我们也画了一张图,如下所示,你可以对照着理解。
<img src="https://static001.geekbang.org/resource/image/4c/d2/4cd27c2dcdb2be169ef30194899c19d2.jpg" alt="">
## 如何应对遍历时改变集合导致的未决行为?
当通过迭代器来遍历集合的时候增加、删除集合元素会导致不可预期的遍历结果。实际上“不可预期”比直接出错更加可怕有的时候运行正确有的时候运行错误一些隐藏很深、很难debug的bug就是这么产生的。那我们如何才能避免出现这种不可预期的运行结果呢
有两种比较干脆利索的解决方案:一种是遍历的时候不允许增删元素,另一种是增删元素之后让遍历报错。
实际上,第一种解决方案比较难实现,我们要确定遍历开始和结束的时间点。遍历开始的时间节点我们很容易获得。我们可以把创建迭代器的时间点作为遍历开始的时间点。但是,遍历结束的时间点该如何来确定呢?
你可能会说遍历到最后一个元素的时候就算结束呗。但是在实际的软件开发中每次使用迭代器来遍历元素并不一定非要把所有元素都遍历一遍。如下所示我们找到一个值为b的元素就提前结束了遍历。
```
public class Demo {
public static void main(String[] args) {
List&lt;String&gt; names = new ArrayList&lt;&gt;();
names.add(&quot;a&quot;);
names.add(&quot;b&quot;);
names.add(&quot;c&quot;);
names.add(&quot;d&quot;);
Iterator&lt;String&gt; iterator = names.iterator();
while (iterator.hasNext()) {
String name = iterator.currentItem();
if (name.equals(&quot;b&quot;)) {
break;
}
}
}
}
```
你可能还会说那我们可以在迭代器类中定义一个新的接口finishIteration(),主动告知容器迭代器使用完了,你可以增删元素了,示例代码如下所示。但是,这就要求程序员在使用完迭代器之后要主动调用这个函数,也增加了开发成本,还很容易漏掉。
```
public class Demo {
public static void main(String[] args) {
List&lt;String&gt; names = new ArrayList&lt;&gt;();
names.add(&quot;a&quot;);
names.add(&quot;b&quot;);
names.add(&quot;c&quot;);
names.add(&quot;d&quot;);
Iterator&lt;String&gt; iterator = names.iterator();
while (iterator.hasNext()) {
String name = iterator.currentItem();
if (name.equals(&quot;b&quot;)) {
iterator.finishIteration();//主动告知容器这个迭代器用完了
break;
}
}
}
}
```
实际上第二种解决方法更加合理。Java语言就是采用的这种解决方案增删元素之后让遍历报错。接下来我们具体来看一下如何实现。
怎么确定在遍历时候集合有没有增删元素呢我们在ArrayList中定义一个成员变量modCount记录集合被修改的次数集合每调用一次增加或删除元素的函数就会给modCount加1。当通过调用集合上的iterator()函数来创建迭代器的时候我们把modCount值传递给迭代器的expectedModCount成员变量之后每次调用迭代器上的hasNext()、next()、currentItem()函数我们都会检查集合上的modCount是否等于expectedModCount也就是看在创建完迭代器之后modCount是否改变过。
如果两个值不相同那就说明集合存储的元素已经改变了要么增加了元素要么删除了元素之前创建的迭代器已经不能正确运行了再继续使用就会产生不可预期的结果所以我们选择fail-fast解决方式抛出运行时异常结束掉程序让程序员尽快修复这个因为不正确使用迭代器而产生的bug。
上面的描述翻译成代码就是下面这样子。你可以结合着代码一起理解我刚才的讲解。
```
public class ArrayIterator implements Iterator {
private int cursor;
private ArrayList arrayList;
private int expectedModCount;
public ArrayIterator(ArrayList arrayList) {
this.cursor = 0;
this.arrayList = arrayList;
this.expectedModCount = arrayList.modCount;
}
@Override
public boolean hasNext() {
checkForComodification();
return cursor &lt; arrayList.size();
}
@Override
public void next() {
checkForComodification();
cursor++;
}
@Override
public Object currentItem() {
checkForComodification();
return arrayList.get(cursor);
}
private void checkForComodification() {
if (arrayList.modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
//代码示例
public class Demo {
public static void main(String[] args) {
List&lt;String&gt; names = new ArrayList&lt;&gt;();
names.add(&quot;a&quot;);
names.add(&quot;b&quot;);
names.add(&quot;c&quot;);
names.add(&quot;d&quot;);
Iterator&lt;String&gt; iterator = names.iterator();
iterator.next();
names.remove(&quot;a&quot;);
iterator.next();//抛出ConcurrentModificationException异常
}
}
```
## 如何在遍历的同时安全地删除集合元素?
像Java语言迭代器类中除了前面提到的几个最基本的方法之外还定义了一个remove()方法,能够在遍历集合的同时,安全地删除集合中的元素。不过,需要说明的是,它并没有提供添加元素的方法。毕竟迭代器的主要作用是遍历,添加元素放到迭代器里本身就不合适。
我个人觉得Java迭代器中提供的remove()方法还是比较鸡肋的作用有限。它只能删除游标指向的前一个元素而且一个next()函数之后只能跟着最多一个remove()操作多次调用remove()操作会报错。我还是通过一个例子来解释一下。
```
public class Demo {
public static void main(String[] args) {
List&lt;String&gt; names = new ArrayList&lt;&gt;();
names.add(&quot;a&quot;);
names.add(&quot;b&quot;);
names.add(&quot;c&quot;);
names.add(&quot;d&quot;);
Iterator&lt;String&gt; iterator = names.iterator();
iterator.next();
iterator.remove();
iterator.remove(); //报错抛出IllegalStateException异常
}
}
```
现在我们一块来看下为什么通过迭代器就能安全的删除集合中的元素呢源码之下无秘密。我们来看下remove()函数是如何实现的代码如下所示。稍微提醒一下在Java实现中迭代器类是容器类的内部类并且next()函数不仅将游标后移一位,还会返回当前的元素。
```
public class ArrayList&lt;E&gt; {
transient Object[] elementData;
private int size;
public Iterator&lt;E&gt; iterator() {
return new Itr();
}
private class Itr implements Iterator&lt;E&gt; {
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(&quot;unchecked&quot;)
public E next() {
checkForComodification();
int i = cursor;
if (i &gt;= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i &gt;= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
public void remove() {
if (lastRet &lt; 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()方法,能够在遍历集合的同时,安全地删除集合中的元素。
## 课堂讨论
1、基于文章中给出的Java迭代器的实现代码如果一个容器对象同时创建了两个迭代器一个迭代器调用了remove()方法删除了集合中的一个元素那另一个迭代器是否还可用或者我换个问法下面代码中的第13行的运行结果是什么
```
public class Demo {
public static void main(String[] args) {
List&lt;String&gt; names = new ArrayList&lt;&gt;();
names.add(&quot;a&quot;);
names.add(&quot;b&quot;);
names.add(&quot;c&quot;);
names.add(&quot;d&quot;);
Iterator&lt;String&gt; iterator1 = names.iterator();
Iterator&lt;String&gt; iterator2 = names.iterator();
iterator1.next();
iterator1.remove();
iterator2.next(); // 运行结果?
}
}
```
2、LinkedList底层基于链表如果在遍历的同时增加删除元素会出现哪些不可预期的行为呢
欢迎留言和我分享你的想法。如果有收获,欢迎你把这篇文章分享给你的朋友。