1 解决的问题

用于 解耦容器代码和遍历代码.
通常来说,将成员变量提供给外部类访问有两种方式:

  • 将此列表设置为 public 变量;
  • 添加 getData() 方法,返回此列表。

但这两种方式都有一个致命的缺点,它们无法保证外部类不修改其中的数据。外部类拿到 data 对象后,可以随意修改列表内部的元素,这会造成极大的安全隐患。

2 迭代器模式

迭代器模式(Iterator Pattern):提供一种方法访问一个容器对象中各个元素,而又不需暴露该对象的内部细节。

迭代器模式用于 解耦容器代码和遍历代码 ,很多编程语言都将迭代器作为一个基础类库,直接提供出来了。日常业务开发,很少自己实现一个迭代器。

定义很好理解,上构成该模式的四个角色:

  • Iterator (抽象迭代器类) → 定义统一的迭代器方法 hasNext()和 next(),用于判断当前集合是否还有其他对象及按顺序读取集合中的当前对象;
  • ConcreteIterator (具体迭代器) → 实现抽象迭代器声明的方法,处理具体集合对象中对对象位置的偏移及具体对象数据的传输;
  • Container (抽象容器类) → 抽象及创建迭代器类关联的方法,同时可添加其他集合类需要的方法;
  • ConcreteContainer (具体容器类) → 实现抽象容器类中声明的方法,创建对应具体的迭代器类;

3 实现代码

public interface Iterator {
  boolean hasNext();
  String next();
}

然后在 MyList 类中,每次遍历时生成一个迭代器,将 index 变量放到迭代器中。由于每个迭代器都是新生成的,所以每次遍历时的 index 自然也就被重置成 0 了。代码如下:

public class MyList {
    private List<String> data = Arrays.asList("a", "b", "c");
 
    // 每次生成一个新的迭代器,用于遍历列表
    public Iterator iterator() {
        return new Itr();
    }
 
    private class Itr implements Iterator {
        private int index = 0;
 
        @Override
        public boolean hasNext() {
            return index < data.size();
        }
 
        @Override
        public String next() {
            return data.get(index++);
        }
    }
}

客户端访问此列表的代码修改如下:

public class Client {
    @Test
    public void test() {
        MyList list = new MyList();
        // 获取迭代器,用于遍历列表
        Iterator iterator = list.iterator();
        // 输出:abc
        while (iterator.hasNext()) {
            System.out.print(iterator.next());
        }
    }
}

4 优缺点

优点

  • 满足单一职责原则和开闭原则;
  • 更好的封装性,简化客户端调用,可以用不同的变量方式来遍历一个集合;

缺点

  • 子类增加;
  • 如果你的程序只与简单的集合进行交互,应用该模式可能会矫枉过正。
  • 对于简单遍历,略显繁琐,如 ArrayList 直接用 for 循环+get()遍历即可;
  • 抽象迭代器的设计难度大,需要充分考虑到系统将来的扩展,如 JDK 内置迭代器 Iterator 就无法实现逆向遍历。如果需要实现逆向遍历,只能通过其子类 ListIterator 等来实现,而 ListIterator 迭代器无法用于操作 Set 类型的聚合对象。在自定义迭代器时,创建一个考虑全面的抽象迭代器并不是件很容易的事情

5 应用场景

  • 希望对客户端隐藏遍历算法复杂性时;
  • 需为容器 (聚合) 对象提供多种遍历方式时;

6 经典应用例子

Iterable 接口 VS iterator 接口

  • 实现前者,该类可以进行迭代
  • 实现后者,该类是一个迭代器对象

实现 Iterable 接口,就代表可以支持迭代 实现 Iterable 接口要实现返回 iterator 迭代器对象的方法 创建私有迭代器类实现迭代器接口。

我们平时常用的 for-each 循环,也是迭代器模式的一种应用。在 Java 中,只要实现了 Iterable 接口的类,都被视为可迭代访问的。Iterable 中的核心方法只有一个,也就是刚才我们在 MyList 类中实现过的用于获取迭代器的 iterator () 方法:

public interface Iterable<T> {
    Iterator<T> iterator();
}

事实上,Java 已经为我们内置了 Iterator 接口,源码中使用了泛型使得此接口更加的通用。

public interface Iterator<E> {
    boolean hasNext();
    E next();
}

并且,本例中使用的迭代器模式是仿照 ArrayList 的源码实现的,ArrayList 源码中使用迭代器模式的部分代码如下:

public class ArrayList<E> {
    public Iterator<E> iterator() {
        return new Itr();
    }
 
    private class Itr implements Iterator<E> {
        protected int limit = ArrayList.this.size;
        int cursor;
 
        public boolean hasNext() {
            return cursor < limit;
        }
 
        public E next() {
            ...
        }
    }
}

只要我们将 MyList 类修改为继承此接口,便可以使用 for-each 来迭代访问其中的数据了:

public class MyList implements Iterable<String> {
    private List<String> data = Arrays.asList("a", "b", "c");
 
    @NonNull
    @Override
    public Iterator<String> iterator() {
        // 每次生成一个新的迭代器,用于遍历列表
        return new Itr();
    }
 
    private class Itr implements Iterator<String> {
        private int index = 0;
 
        @Override
        public boolean hasNext() {
            return index < data.size();
        }
 
        @Override
        public String next() {
            return data.get(index++);
        }
    }
}

7 Fail-first 快速机制

当遍历的同时增删集合元素会怎么样?可能会导致重复遍历或遍历不到某个元素。
原列表长度为 5,迭代的时候插入了一个元素,但迭代器 length 还是之前的 5,会漏掉新插入的元素;又比如迭代时删掉了最后一个元素,但迭代器 length 还是之前的 5,会引发数组越界。

如何改变预防这种情况发生?

  • 遍历时不允许增删元素
  • 遍历时增删元素直接报错

Java 语言中采用的方法二,如 ArrayList 中定义了一个成员变量 modCount,记录集合被修改的次数,调用增删函数都会加 1。

创建迭代器的时候传入,然后每次调用迭代器的 next()、hasNext()函数时都检查集合中的 modCount 是否等于一开始传入的 modCount,不等说明集合存储的元素已经发生改变,之前创建的迭代器已不能正确运行,直接抛出运行时异常,结束程序。

在单线程情况下,ArrayList 使用迭代器进行迭代,通过迭代器增删元素,不会引发异常,原理是:

内部类 Itr 实现 Iterator 接口,定义了两个变量cursor (下一个元素下标) 和 lastRet (上一个元素下标) 当发生元素增删时,更新迭代器中的游标及这两个值,保证遍历不出错。

而对于多线程的情况,除了在 iterator 使用处加锁外,还可以用 并发容器

原理是:采用的是 fail-safe(安全失败) 机制:迭代时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。所以遍历期间原集合发生的修改迭代器是不知道的,原迭代器也不能访问修改后的内容。Java 的并发容器放在 java.util.concurrent 包中,如使用 CopyOnWriteArrayList 来代替 ArrayList。

8 实现一个支持快照功能的迭代器

所谓的 “快照” 就是创建迭代器时相当于给容器拍了张快照 (Snapshot),之后增删容器元素,快照中的元素都不会发生改变,即迭代器遍历的对象是快照而非容器。

第一种解法:迭代器类中定义一个存储快照的成员变量,构造迭代器时复制原集合引用进行初始化,后续遍历都基于持有的快照进行。(我上面定义的 OrderTimeIterator 就是这种)。

当然,缺点明显,每次创建迭代器,都要拷贝一份数据到快照中,增加内存消耗,当有多个迭代器在遍历元素,还会导致重复存储多份。不过,好在 Java 中的拷贝属于浅拷贝,所以只是拷贝了对象的引用而已。

第二种解法:容器中为每个元素保存两个时间戳,添加时间删除时间 (初始化为最大长整型值),添加时将添加时间设置为当前时间,删除时将时间设置为当前时间,记住只是 标记删除,并非真的从容器中将其删除

然后每个迭代器保存一个 创建时间,即快照创建时间戳,当使用迭代器遍历容器时,只有满足:

添加时间 < 创建时间 < 删除时间

的元素才属于这个迭代器的快照:

  • 添加时间 > 创建时间 → 说明元素在创建迭代器后才加入,不属于这个迭代器的快照;
  • 删除时间 < 创建事件 → 说明元素在创建迭代器前就被删除了,同样不属于这个迭代器的快照;

在不拷贝容器的情况下,在容器本身借助时间戳实现快照功能,妙啊!

这种方式解决了一个问题,又引入了一个问题:

ArrayList 底层依赖数组这种存储结构,原本支持快速随机访问,在 O(1)时间复杂度内获取下标为 i 的元素。但现在删除元素并没有真正删除,这就导致无法支持按照下标快速随机访问了。

解法:

在 ArrayList 中存储两个数组,一个支持标记删除,用来实现快照遍历,一个不支持标记删除(删除数据直接从数组中删除),用来支持随机访问。