设计模式学习笔记 - 设计模式与范式 -行为型:10.迭代器模式(中):遍历集合时,为什么不能增删集合?

news2024/7/7 16:25:33

概述

上篇文章,我们通过给 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() 方法,能够在遍历集合的同时,安全地删除集合中的元素。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1583705.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

1999-2022年各省研究与试验发展人员全时当量数据/省研发人员全时当量数据/(RD)人员全时当量(无缺失)

1999-2022年各省研究与试验发展人员全时当量数据/省研发人员全时当量数据/(R&D)人员全时当量&#xff08;无缺失&#xff09; 1、时间&#xff1a;1999-2022年 2、来源&#xff1a;科技年鉴 3、指标&#xff1a;研究与试验发展人员全时当量/研发人员全时当量 4、范围&a…

代码随想录算法训练营Day50|LC123 买卖股票的最佳时机IIILC188 买卖股票的最佳时机IV

一句话总结&#xff1a;虽然是困难题&#xff0c;其实都只需要对122 买卖股票的最佳时机II稍作变形即可。 原题链接&#xff1a;123 买卖股票的最佳时机III 相较于买卖股票的最佳时机和股票II来说&#xff0c;本题加了最多操作两次的限制。那么这里实际上就可以直接用滚动数组…

用C代码实现环形缓冲区(ring buf)

用C代码实现环形缓冲区&#xff08;ring buf&#xff09; 概述环境介绍launch.json(没改&#xff09;tasks.json注意 代码ringbuf.cringbuf.hmain.c 测试说明工程代码下载 概述 因嵌入式项目需要&#xff0c;串口接收的数据有很高的周期性发送频率&#xff0c;原方式通过查询接…

小程序项目思路分享爬虫

小程序项目思路分享爬虫 具体需求&#xff1a; 有这几个就行&#xff0c;门店名称门店地址门店类型&#xff0c;再加上省、市、县/区门店名称&#xff1a;storeName 门店地址&#xff1a;storeAddress 程序运行&#xff1a; honor_spider获取经纬度信息。 经纬度——>详…

Mybatis plus 使用通用枚举

说明&#xff1a;mybatis plus 使用枚举可实现数据库存入时指定值保存&#xff0c; 读取时指定值展示&#xff08;返给前端&#xff09; 可通过继承IEnum<T>、 EnumValue实现 1、引包 <dependency><groupId>mysql</groupId><artifactId>mysql-…

esxi上给centos7虚拟机扩容硬盘

原来centos7硬盘分配的空间只有40GB 需要直接扩容到200GB 扩容前 扩容后 扩容步骤&#xff1a; 1 .在esxi平台上关机虚拟机&#xff0c;将硬盘调整为200GB&#xff0c;然后开机 2.请出chatGPT 1. 创建新分区 使用剩余的磁盘空间创建一个新的分区。你可以使用fdisk&#xf…

归档数据shell脚本

系统中数据很重要&#xff0c;为确保数据不会丢失&#xff0c;定时备份数据是一个必要的习惯。制定一个存储重要文件的数据备份计划也绝非易事&#xff0c;而shell脚本可以祝我们一臂之力。 1.创建需要备份配置文件的路径文件。(BACKUP_FILE) 2.以日期为备份文件的后缀。 3.判断…

LeetCode 热题 100 | 多维动态规划(二)

目录 1 5. 最长回文子串 2 1143. 最长公共子序列 菜鸟做题&#xff0c;语言是 C 1 5. 最长回文子串 核心思想&#xff1a;把总问题拆解为若干子问题。 总问题&#xff1a;从第 i 个字母到第 j 个字母是回文串子问题&#xff1a;从第 i 1 个字母到第 j - 1 个字母是回文…

【就近接入,智能DNS-Geo DNS ,大揭秘!】

做过后端服务或者网络加速的小伙伴&#xff0c;可能或多或少都听说过&#xff0c;智能DNS或者Geo DNS&#xff0c;就是根据用户的位置&#xff0c;返回离用户最近的服务节点&#xff0c;就近接入&#xff0c;以达到服务提速的效果。 那么大家有没想过&#xff0c;这个背后的原理…

ETLCloud结合kafka的数据集成

一、ETLCloud中实时数据集成的使用 在ETLCloud中数据集成有两种方式&#xff0c;一种是离线数据集成&#xff0c;另一种便是我们今天所要介绍的实时数据集成了&#xff0c;两者的区别从名字便可以得知&#xff0c;前者处理的数据是离线的没有时效性的&#xff0c;后者的数据是…

【Keil5-Boot和APP配置】

Keil5-Boot和App配置 ■ Keil5-Boot和APP配置■ 一&#xff1a;sct文件 sct文件配置■ 二&#xff1a;发布版本不需要在 C/C&#xff0c;Asm&#xff0c;Linker&#xff0c;中添加 CMDDEBUG 宏定义。■ 三&#xff1a;Debug版本需要在Linker添加 --pd"-DCMDDEBUG" 才…

4.进程相关 2

8.内存映射 8.1 内存映射相关定义 创建一个文件&#xff0c;将保存在磁盘中的文件映射到内存中&#xff0c;后期两个进程之间对内存中的数据进行操作&#xff0c;大大减少了访问磁盘的时间&#xff0c;也是一种最快的 IPC &#xff0c;因为进程之间可以直接对内存进行存取 8.…

视觉大模型--DeformableDETR

原理大家可以参考这篇文章&#xff0c;我这边主要介绍几个公式和整体源码理解。 提出了多尺度可变形注意力(Multi-scale Deformable Attention, MSDA).基于此设计了 DETR 特有的利用多尺度特征检测的流程&#xff0c;对之后的很多工作有指导意义。提出了两阶段 DETR 的思路&…

OpenHarmony应用开发引入开源C/C++库---之Har包里的NDK

Har 包 HAR&#xff08;Harmony Archive&#xff09;是静态共享包&#xff0c;可以包含代码、C 库、资源和配置文件。通过 HAR 可以实现多个模块或多个工程共享 ArkUI 组件、资源等相关代码。HAR 不同于 HAP&#xff0c;不能独立安装运行在设备上&#xff0c;只能作为应用模块…

pandas常用的一些操作

EXCLE操作 读取Excel data1 pd.read_excel(excle_dir) 读Excel取跳过前几行&#xff1a; data1 pd.read_excel(excle_dir,skiprows1) 获取总行数 data1.shape[0] 获取总列数 data1.shape[1] 指定某列数据类型 data1 pd.read_excel("C:数据导入.xlsx",dtype…

设计模式——装饰器模式09

装饰器模式&#xff1a;是在原有基础上进行装饰&#xff08;无修改原来代码&#xff09;&#xff0c;来添加新的功能。 例如下面对普通耳机进行装饰。 设计模式&#xff0c;一定要敲代码理解 修饰对象&#xff08;抽象&#xff09; /*** author ggbond* date 2024年04月07日…

Unity核心学习

目录 认识模型的制作流程模型的制作过程 2D相关图片导入设置图片导入概述纹理类型设置纹理形状设置纹理高级设置纹理平铺拉伸设置纹理平台打包相关设置 SpriteSprite Editor——Single图片编辑Sprite Editor——Multiple图片编辑Sprite Editor——Polygon图片编辑SpriteRendere…

【汇编语言实战】统计个数(创新版)

内存中有10个分布在0至100内的正整数&#xff0c; 求小于60的数的个数num1&#xff0c;大于或等于60且小于80的数的个数num2&#xff0c;大于或等于80且小于100的数的个数num3 C语言描述该程序流程&#xff1a; #include <stdio.h> int main() {int a[]{1, 20, 95, 32,…

Python | 超前滞后分析

Nino SST Indices (Nino 12, 3, 3.4, 4; ONI and TNI) 有几个指标用于监测热带太平洋&#xff0c;所有这些指标都是基于海表温度(SST)异常在一个给定的区域的平均值。通常&#xff0c;异常是相对于30年的周期来计算的。厄尔尼诺3.4指数(Nio 3.4 index)和海洋厄尔尼诺指数(Ocea…

【Ubuntu】update-alternatives 命令详解

1、查看所有候选项 ​​​​​​​sudo update-alternatives --list java 2、​​​​​​​更换候选项 sudo update-alternatives --config java 3、自动选择优先级最高的作为默认项 sudo update-alternatives --auto java 4、删除候选项 sudo update-alternatives --rem…