设计模式预计阅读 17 分钟6515 字2334 次浏览

设计模式修炼之路 ⑳ 迭代器模式——统一遍历协议

统一遍历协议、for-each 只是语法糖、手写可迭代书架、fail-fast 的哲学

目录

行为型第七站。

上一篇结尾问了个问题:你每天都在写 for (String s : list),这个冒号背后是谁在工作?为什么 ArrayList、HashSet、LinkedList 长得八竿子打不着,却都能被同一套 for-each 遍历?

答案是 GoF 二十三式里唯一一个被 JDK 语言级收编的模式——迭代器。这一篇就手写一个能被 for-each 的自定义集合,把遍历协议这件事彻底讲透。

一、迭代器模式是什么

GoF 的原始定义:

Provide a way to access the elements of an aggregate object sequentially without exposing its underlying representation. (提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露它的内部表示。)

翻译成人话:遍历这件事,集合自己最清楚。与其把内部结构摊开给外界看,不如集合派一个"导游"出去——导游只回答两个问题:还有下一个吗?带我去下一个。

四个角色,但有两个 JDK 已经替你写好了:

角色 干什么 本文对应
Iterator(抽象迭代器) 统一遍历协议:hasNext / next `java.util.Iterator`(JDK 自带)
ConcreteIterator(具体迭代器) 记住游标走到哪,具体怎么走 BookshelfIterator / 倒序迭代器
Aggregate(抽象聚合) 约定"能交出迭代器" `java.lang.Iterable`(JDK 自带)
ConcreteAggregate(具体聚合) 你自己的集合类 Bookshelf

注意 Iterable 在 java.lang 包里——和 String、Object 一个包待遇。for-each 语法糖在编译期只认它:实现了 Iterable 的类就能被 for-each,这是写进 Java 语言规范的模式,其他二十二式谁都没有这个地位。

二、优点与缺点

优点:

  1. 遍历协议统一。数组、链表、哈希表、树、数据库游标……内部结构天差地别,对外全是 hasNext/next。调用方一套遍历代码走遍所有容器。
  2. 不暴露内部表示。集合内部随便重构——数组换链表、链表换数据库——外界零感知。本文书架中途扩容了数组,遍历代码一行没改。
  3. 同一集合,多种遍历。正序一个迭代器、倒序一个迭代器、过滤版再来一个,还能同时开几个迭代器各走各的,互不干扰。
  4. 职责分离。聚合管存储,迭代器管遍历,单一职责各安其位。

缺点:

  1. 简单场景嫌绕。就一个数组,写个迭代器类纯属仪式感。
  2. 基础版只能单向单游标。不能回退、不能跳到某位——ListIterator 补了双向和索引课,但那是 List 专属。
  3. 遍历中修改集合是经典雷区。删着元素遍历着,要么漏元素要么直接炸异常(第六节专门说)。
  4. 每次 next 一层虚调用。现代 JIT 内联之后基本无感,但极端性能敏感的场景有人在意。

三、什么时候用

判断标准:你写了一个"容器",外界需要逐个访问里面的元素,但你不想(或不该)暴露内部结构。

  • 自定义集合类:自己写的树、图、跳表、环形缓冲区,想让 for-each 直接生效——本文场景
  • 非内存容器:数据库游标(ResultSet)、大文件逐行读取、网络流分帧——"元素"不在内存里堆着,只能一个一个来
  • 同一集合多种遍历序:正序、倒序、按权重、按时间
  • 屏蔽实现差异:底层今天用数组明天换 NoSQL,上层遍历代码不想跟着动

反面对照:用 JDK 集合直接遍历就好,别自己造轮子;一次性下标访问数组,直接来,不必抽象。

四、实现方式:让书架被 for-each

4.1 反面教材:内部结构裸奔

先看书架不提供迭代器时,外界怎么遍历它:

public class BadBookshelf {

    public final Book[] books = new Book[10];   // public 字段:内部结构直接裸奔
    public int count;

    public void add(Book book) {
        books[count++] = book;
    }

    public Book[] getBooks() { return books; }
    public int getCount() { return count; }
    public Book getBook(int index) { return books[index]; }
}

外界遍历只能这么写:

for (int i = 0; i < bad.count; i++) {
    System.out.println(bad.books[i]);
}

这行代码成立的前提是外界必须知道三件事:内部是数组、下标从 0 开始、还有一个 count 字段。恶果在测试输出里看得明明白白:

裸数组长 10,真实书 3 本——容量分配泄露给外界
外界 raw[0] = null 后,书架第一本书:null

其一,内部结构的每个细节都是负债:数组长度 10 这种内存分配细节,居然成了外界可见的事实,哪天书架改成 8 个槽,调用方"10"就错了。其二,外界拿到了就能篡改:raw[0] = null 一行,书架第一本书直接蒸发,书架毫不知情——封装被连锅端。其三,也是最致命的:哪天书架内部从数组换成链表,全世界所有调用方的循环代码集体作废。

4.2 正解:实现 Iterable,发导游不发地图

第一步,书架实现 Iterable,内部结构全部私有化,外加自动扩容:

public class Bookshelf implements Iterable<Book> {

    private Book[] books = new Book[2];   // 初始容量故意给小,演示内部扩容
    private int count;

    public void add(Book book) {
        if (count == books.length) {
            Book[] bigger = new Book[books.length * 2];
            System.arraycopy(books, 0, bigger, 0, books.length);
            books = bigger;   // 内部悄悄扩容,外界毫无感知
        }
        books[count++] = book;
    }

    @Override
    public Iterator<Book> iterator() {
        return new BookshelfIterator();
    }
    // 倒序迭代器见下文
}

第二步,迭代器做成书架的内部类:

    // 迭代器做成内部类:天然访问书架的私有结构,外界只见接口不见实现
    private class BookshelfIterator implements Iterator<Book> {

        private int cursor = 0;

        @Override
        public boolean hasNext() { return cursor < count; }

        @Override
        public Book next() {
            if (!hasNext()) throw new NoSuchElementException();
            return books[cursor++];
        }
    }

顺手再送一个倒序迭代器,展示"同一集合、多种遍历策略":

    public Iterator<Book> reverseIterator() {
        return new Iterator<>() {
            private int cursor = count - 1;

            @Override
            public boolean hasNext() { return cursor >= 0; }

            @Override
            public Book next() {
                if (!hasNext()) throw new NoSuchElementException();
                return books[cursor--];
            }
        };
    }

第三步,测试。三段遍历:手动迭代、for-each、倒序:

public class IteratorTest {

    public static void main(String[] args) {
        System.out.println("========== 反面教材:内部结构裸奔 ==========");
        BadBookshelf bad = new BadBookshelf();
        bad.add(new Book("设计模式", "GoF"));
        bad.add(new Book("重构", "Martin Fowler"));
        bad.add(new Book("Effective Java", "Joshua Bloch"));

        for (int i = 0; i < bad.count; i++) {
            System.out.println("  " + bad.books[i]);
        }

        Book[] raw = bad.getBooks();
        System.out.println("  裸数组长 " + raw.length + ",真实书 " + bad.count + " 本——容量分配泄露给外界");
        raw[0] = null;
        System.out.println("  外界 raw[0] = null 后,书架第一本书:" + bad.getBook(0));

        System.out.println();
        System.out.println("========== 正解:迭代器模式 ==========");
        Bookshelf shelf = new Bookshelf();
        shelf.add(new Book("设计模式", "GoF"));
        shelf.add(new Book("重构", "Martin Fowler"));
        shelf.add(new Book("Effective Java", "Joshua Bloch"));
        shelf.add(new Book("代码整洁之道", "Robert Martin"));

        System.out.println("-- 手动迭代:hasNext + next --");
        Iterator<Book> it = shelf.iterator();
        while (it.hasNext()) {
            System.out.println("  " + it.next());
        }

        System.out.println("-- for-each:编译器替你展开成迭代器 --");
        for (Book book : shelf) {
            System.out.println("  " + book);
        }

        System.out.println("-- 倒序迭代:同集合、另一种遍历策略 --");
        Iterator<Book> rit = shelf.reverseIterator();
        while (rit.hasNext()) {
            System.out.println("  " + rit.next());
        }
    }
}

运行输出:

========== 反面教材:内部结构裸奔 ==========
  《设计模式》-GoF
  《重构》-Martin Fowler
  《Effective Java》-Joshua Bloch
  裸数组长 10,真实书 3 本——容量分配泄露给外界
  外界 raw[0] = null 后,书架第一本书:null

========== 正解:迭代器模式 ==========
-- 手动迭代:hasNext + next --
  《设计模式》-GoF
  《重构》-Martin Fowler
  《Effective Java》-Joshua Bloch
  《代码整洁之道》-Robert Martin
-- for-each:编译器替你展开成迭代器 --
  《设计模式》-GoF
  《重构》-Martin Fowler
  《Effective Java》-Joshua Bloch
  《代码整洁之道》-Robert Martin
-- 倒序迭代:同集合、另一种遍历策略 --
  《代码整洁之道》-Robert Martin
  《Effective Java》-Joshua Bloch
  《重构》-Martin Fowler
  《设计模式》-GoF

4.3 三处细节咂摸一下

细节一:for-each 只是迭代器的语法糖。for (Book book : shelf) 编译器会展开成:

for (Iterator<Book> it = shelf.iterator(); it.hasNext(); ) {
    Book book = it.next();
    ...
}

javap 反编译 class 文件可以亲眼验证。所以"实现 Iterable 就能被 for-each"不是玄学,是编译器的一行展开——iterator() 返回的对象,就是冒号背后工作的那位。

**细节二:迭代器是有状态的,状态就是游标。**BookshelfIterator 的 cursor 字段记着"我走到第几本了"。这也是为什么同一个书架能同时开两个迭代器各走各的——每个迭代器持有自己的游标,互不干扰。回头看 ⑱ 命令模式说"命令有状态",迭代器是同款道理:无状态的对象是工具,有状态的对象是"一段进行中的事"。

**细节三:迭代器做成内部类,是 JDK 的同款选择。**java.util.ArrayList 里的 Itr 就是一个私有内部类——内部类天然访问外部类的私有字段(books、count),外界只见 Iterator 接口,不见任何实现细节。"聚合藏结构、迭代器做内应",封装滴水不漏。

五、在框架里找它

JDK 集合框架——整个大厦的地基。Collection extends Iterable,所以 List、Set、Queue、Deque 全员可迭代;Map 不在体系里,于是它提供了 entrySet().iterator() 的入口。你每天写的每个 for-each,都在走这条协议。

Scanner——一个会读输入的迭代器。new Scanner(System.in) 实现的就是 Iterator<String>:hasNext 是"还有输入吗",next 是"读下一个"。读文件、读网络流、分词,全是同一套协议换数据源。

JDBC ResultSet——数据库游标的对象化。rs.next() 把游标往前推一行,返回 false 表示扫完。一张几千万行的表,迭代器协议让你"像遍历数组一样"扫完它,而不需要它整个装进内存——迭代器天然适合"元素不能一次性到齐"的场景。

Stream 与 Spliterator——迭代器的 Java 8 进化版。list.parallelStream() 背后是 Spliterator(可拆分迭代器):把集合切成几段分给多线程并行处理。老 Iterator 只能一个人顺序走,Spliterator 能"分身"——这是遍历协议对多核时代的回应。

一句话总结这一节:凡是要"一个一个拿出来"的地方,协议全是 hasNext/next,迭代器是数据容器世界的普通话。

六、扩展:fail-fast——遍历中修改集合怎么办

基本版迭代器有一个绕不开的坑:**遍历到一半,集合被改了怎么办?**游标指着第 3 本,有人把第 3 本删了——轻则漏元素、重则死循环。

JDK 集合的答案是 fail-fast(快速失败):每个集合维护一个版本号 modCount,迭代器创建时记住当时的版本,每次 next 检查版本是否还一致——不一致立刻抛 ConcurrentModificationException。

for (Book book : shelf) {
    shelf.add(new Book("新书", "谁"));   // modCount 变了
    System.out.println(book);            // 下一次 next() 直接抛异常
}

设计哲学值得咂摸:**遍历中的修改无法安全处理时,宁可立刻炸给你看,也不悄悄给出错误结果。**快速失败是诚实,静默出错才是灾难。(顺带一提,删除自己刚访问的元素是唯一合法操作:it.remove(),迭代器会同步修正游标。)

也有反着来的 fail-safe 策略:CopyOnWriteArrayList(⑯ 观察者篇提过它的兄弟思路)遍历时拿的是当时的快照,遍历期间的修改不影响本次遍历——用"数据可能略旧"换"绝不抛异常"。没有银弹,只有取舍,两个流派各自服侍各自的场景。

写在最后

迭代器模式一句话总结:聚合藏起内部结构,只派发导游;hasNext/next 两个问题统一天下遍历,for-each 不过是它的语法糖。

下一篇 ㉑ 备忘录模式——⑱ 命令模式讲撤销时,我们靠"逆操作"轻装回滚。但有些对象没法描述逆操作:游戏角色的血蓝金币要存档、IDE 要恢复整个工作区状态——难道把所有字段全公开出去让外界抄一份?不破坏封装的前提下,把内部状态存下来、再原样恢复——命令模式管"怎么撤销",备忘录管"怎么存档"。备忘录模式,下一篇见。

花

花开空白

开发者 · 西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论