☰
迭代器模式从入门到落地:解耦遍历与数据结构
2026/10/18 4:50:18 网站建设 项目流程

每次聊到设计模式,总会有人问:迭代器模式这么基础,不就是个for循环吗,有什么好学的?说实话,我刚开始接触 23 种设计模式的时候也是这个想法。直到有一次,我在重构一个内部报表系统时,被一堆到处散落的遍历逻辑折磨得够呛,才真正意识到迭代器模式不是“会不会用”的问题,而是“用得对不对”的问题。

这一篇是行为型设计模式里的迭代器模式(Iterator Pattern),也是从零入门 23 种设计模式系列的第十六篇。我会把它拆开揉碎:先讲清楚它到底解决了什么痛点,再手写一个完整的实现,然后放到真实场景里看它怎么落地,最后把我踩过的坑和排查经验一并分享出来。不管你是刚接触设计模式的新手,还是已经写过几年业务代码的老手,这篇都能给你一些值得反复琢磨的东西。

1. 先想清楚:迭代器模式到底在解决什么问题

1.1 一个让你印象深刻的痛点场景

先想象一个非常常见的业务场景。你要做一个后台管理系统,里面有一堆数据集合:用户列表、订单列表、商品分类树、操作日志。不同的模块由不同的人开发,数据结构也不一样——有人用ArrayList,有人用LinkedList,还有人是自己封装的树结构。

现在产品经理提了个需求:所有列表页面都要支持“按创建时间倒序展示”。如果每个模块都自己实现遍历逻辑,你会看到什么?有人写for (int i = list.size() - 1; i >= 0; i--),有人写Collections.reverse(),还有人直接在 SQL 里改了排序。改一遍两遍还行,等需求变成“所有页面都支持自定义排序规则”,那就是灾难——你去每个模块里找它遍历了哪些集合,再一个个改,改到怀疑人生。

这个场景的核心矛盾是:集合的内部结构是稳定的,但遍历方式是易变的。今天要顺序遍历,明天要倒序遍历,后天可能要根据某个字段过滤后再遍历。如果把遍历逻辑硬编码在调用方,一旦遍历方式变化,所有调用方都要跟着改;更麻烦的是,调用方必须知道集合内部是怎么存储数据的,耦合得死死的。

1.2 设计模式的本质:对“变化”的封装

迭代器模式对“变化”的封装思路其实很简单:把遍历行为从集合对象中抽离出来,封装成一个独立的迭代器对象。调用方不再关心集合底层是数组还是链表,也不用关心遍历顺序是什么,只需要问迭代器“有没有下一个元素hasNext()”和“把下一个元素给我next()”。

打个生活化的比方。你逛商场,想逛遍每一层楼。商场楼层布局变了,电梯停靠楼层也变了,但作为顾客的你完全不关心这些,你只需要按照“下一层”按钮,就能一层层逛完。这里的商场就是集合,电梯就是迭代器,而“按下一层”这个统一操作就是迭代器接口。

反过来说,如果商场没有电梯,你得自己找楼梯间,还得记住每层楼的安全出口在哪,这就是调用方直接访问集合内部结构的下场。用设计模式的话说,迭代器模式实现了遍历逻辑与集合结构的解耦,让集合的“内部实现”和“外部访问”各自独立演化,互不干扰。

这套思想在实际开发中的价值,比我上面举的例子还要大得多。数据不再只是存在内存的集合里,可能来自数据库游标、来自远程接口的分页返回、来自文件的一行行读取。只要你能提供“下一个元素”的能力,调用方就能用同一套while (iterator.hasNext())去处理它。你有没有想过,为什么 Java 的for-each循环能同时支持List、Set、Queue、Map的键集合?就是因为它们都实现了Iterable接口,本质上是迭代器模式在语言层面的落地。

2. 模式结构与核心角色拆解

2.1 四个角色的职责边界

迭代器模式有四个核心角色,我直接画一张表帮你分清楚:

角色名字职责
迭代器接口Iterator定义遍历所需的操作:判断是否还有下一个、获取下一个元素
具体迭代器ConcreteIterator记录当前遍历位置,实现具体的遍历算法
集合接口Aggregate定义创建迭代器的方法(通常叫iterator())
具体集合ConcreteAggregate持有真实数据,负责创建对应的具体迭代器

单纯背这四个角色没意义,关键是要理解它们之间的“约定”。

Aggregate(集合)只知道一件事:我要创建迭代器,但我不管迭代器内部怎么实现。ConcreteAggregate(具体集合)知道自己的数据是数组还是链表,所以它创建ConcreteIterator(具体迭代器)时,会把底层数据传进去——注意,这里传的是数据结构的引用,而不是把数据复制一份。ConcreteIterator则一直盯着这份数据,通过内部维护的cursor(游标)字段记住当前遍历到哪儿了。

这里有个很重要的点:具体迭代器是“有状态”的。同一个集合,你可以创建出多个迭代器,各自维护各自的游标。这意味着你可以同时开多个迭代器去遍历同一个集合,互不干扰。这个特性在嵌套遍历场景里尤其有用——比如商品多级分类下的双重遍历,如果不用迭代器,手动维护指针位置,早乱套了。

2.2 为什么需要抽象出“迭代器”这一层

可能有人会问:既然集合已经提供了get(int index)这类方法,再包装一层迭代器,是不是多此一举?

我换个角度解释。ArrayList和LinkedList都支持get(int index),但两者的性能天差地别。前者底层是数组,随机访问 O(1);后者底层是链表,每次都要从头开始遍历,O(n)。如果你在调用方写死for (int i = 0; i < list.size(); i++) list.get(i),这个代码在ArrayList上跑得很欢快,换到LinkedList上就直接性能翻车。而且更隐蔽的是,当你把LinkedList换成另一种自定义的树形结构时,它压根没有get(int index)方法,你的遍历代码直接编译不过。

一旦引入迭代器接口,调用方只依赖hasNext()和next(),不管底层是跳表、二叉树还是 Redis 的 SCAN 游标,只要集合能生产迭代器,调用方就能统一遍历。

从设计原则角度复盘一下,这个抽象至少带来了三层收益:

  • 单一职责原则:集合类只管存储和增删改查,遍历算法单独放迭代器里,每个类只干一件事,类不会越做越大。
  • 开闭原则:要给集合增加新的遍历方式(比如倒序遍历、按条件过滤遍历),不需要修改集合类,只要新写一个迭代器子类,或者改造迭代器的创建工厂即可。
  • 调用方一致性:所有集合类型对调用方暴露同一套遍历 API,业务代码的稳定性和可读性都提升了。

这一层的“抽象价值”,其实就是设计模式最常见的套路——找到变化的点,把它封装起来,然后面向接口编程。不只是迭代器模式,整个 23 种设计模式的底层逻辑都是这样的。

3. 手写实现:从零搭出迭代器模式

3.1 第一个版本:书架遍历

纸上谈兵聊再多,不如动手写一个最小可用的实现。我拿一个“书架”来举例——书架里可以放书,我们需要遍历书架上的每一本书。这是设计模式经典书籍里最经典的例子,我今天把它完整地拆一遍。

先定义迭代器接口:

public interface Iterator<E> { boolean hasNext(); // 是否还有下一个元素 E next(); // 返回下一个元素,同时游标后移 }

这里注意一个行业惯例问题。很多人习惯把next()设计成“先返回当前元素,再移动游标”,把hasNext()设计成“判断游标是否越界”。但是在实际项目中,我建议你把hasNext()的语义理解成“在调用next()之前必须确认还有元素”,否则你会经常碰到空指针、越界之类的低级错误——这个坑我后面专门讲。

接着定义集合接口:

public interface Aggregate<E> { Iterator<E> iterator(); // 生成迭代器 }

然后写具体迭代器:

public class BookShelfIterator implements Iterator<Book> { private final Book[] books; private int cursor = 0; // 当前游标 public BookShelfIterator(Book[] books) { this.books = books; } @Override public boolean hasNext() { return cursor < books.length && books[cursor] != null; } @Override public Book next() { if (!hasNext()) { throw new NoSuchElementException("书架已经没有书了"); } return books[cursor++]; } }

最后实现具体集合:

public class BookShelf implements Aggregate<Book> { private final Book[] books; private int size = 0; public BookShelf(int maxSize) { this.books = new Book[maxSize]; } public void addBook(Book book) { if (size >= books.length) { throw new IllegalStateException("书架已满"); } books[size++] = book; } @Override public Iterator<Book> iterator() { // 关键点:每次调用都新建一个迭代器 return new BookShelfIterator(books); } }

这个第一版实现里,藏着一个容易被忽略却极其重要的细节:每次调用iterator()都应该返回一个全新的迭代器对象。为什么?因为迭代器是有状态的,游标会随着遍历推进。如果你返回同一个迭代器,第一次遍历到一半,第二次再遍历就得从头开始,或者直接冲突。在 Java 的集合框架里,iterator()每次都返回新迭代器,这不是巧合,而是为了让“一次遍历、多次遍历”都能安全运行。

3.2 第二个版本:能倒着走的迭代器

上面这个实现逻辑很简单,现在咱们试着玩点花的。需求变成了:书架里的书,要支持倒序遍历。很多人的第一反应是:那我在BookShelf里加一个reverseIterator()方法不就行了?

行,但我不建议直接加在具体集合里。更好的做法是:新增一个ReverseBookShelfIterator,只改迭代器的实现,BookShelf本身不用动,这才是对开闭原则的实践。代码如下:

public class ReverseBookShelfIterator implements Iterator<Book> { private final Book[] books; private int cursor; public ReverseBookShelfIterator(Book[] books) { this.books = books; this.cursor = findLastNotNullIndex(); } private int findLastNotNullIndex() { int index = books.length - 1; while (index >= 0 && books[index] == null) { index--; } return index; } @Override public boolean hasNext() { return cursor >= 0; } @Override public Book next() { if (!hasNext()) { throw new NoSuchElementException(); } return books[cursor--]; } }

你发现没有,调用方代码完全不用改——还是那句while (it.hasNext()) { Book b = it.next(); ... },唯一的变化只是创建迭代器的方式不同。这就是迭代器模式最直观的威力:遍历算法的变化,被完美地隔离在了调用方之外。

实际上在真实开发里,倒序遍历只是小儿科。你可能还需要“跳过空书的迭代器”“只遍历前 N 本的迭代器”“把两本书合并成一对书名的迭代器”。这些统统可以做成不同的迭代器实现,而且互不影响。等到需求堆得足够多,你可以再配合工厂方法模式,用一个工厂类统一创建各种迭代器,整个体系会变得非常规整。

3.3 进阶:惰性迭代器与无限序列

迭代器还有一个被严重低估的价值:天然支持惰性计算。这意味着你不必先把所有数据都加载到内存,而是“用到的时候才计算一个”。

举一个非常经典的例子:用迭代器输出斐波那契数列的前 20 项。注意,斐波那契数列本身是无限的,你不能真的把它全部算出来,也不可能用普通集合存下“无限个元素”。但是用迭代器,可以无限地往后推:

public class FibonacciIterator implements Iterator<Integer> { private int prev = 0; private int curr = 1; @Override public boolean hasNext() { return true; // 斐波那契数列是无限的 } @Override public Integer next() { int result = curr; int nextValue = prev + curr; prev = curr; curr = nextValue; return result; } }

调用时你完全可以只取前 N 个:

Iterator<Integer> fibIt = new FibonacciIterator(); int count = 0; while (fibIt.hasNext() && count < 20) { System.out.println(fibIt.next()); count++; }

这个例子乍看像炫技,但它背后是对“数据源”这个概念的深刻理解。真实世界中,数据源不一定是一块内存,可能是一个数据库游标、一个网络流、一个传感器读数。它们的共同点是:你无法预先知道全部数据,甚至数据是无限的。迭代器模式恰恰是表达这种关系的自然语法。

很多语言已经从语法层面支持了这种思维——Python 的__next__和yield、C# 的yield return、JavaScript 的Symbol.iterator。它们本质上都是迭代器思想的语言化升华。你学会了迭代器模式,再去看这些语言的生成器、协程,会有一种“原来如此”的通透感。

4. 真实项目中的典型场景与选型思路

4.1 场景一:菜单/目录树的遍历

很多业务系统里都有菜单树,比如后台管理系统的左侧导航,父菜单下面挂着子菜单,子菜单下面还可能挂按钮权限。这种嵌套结构,最常见的遍历方式是用递归,但是递归代码写起来绕,还容易栈溢出。

我见过一个比较优雅的做法:把菜单树的遍历也封装成迭代器。核心思路是,用栈来模拟递归顺序,每次next()时把当前节点的子节点按顺序压入栈中,这样调用方就可以用平铺的while循环遍历整棵树,完全不需要递归。

关键代码如下:

public class MenuTreeIterator implements Iterator<MenuNode> { private final Deque<Iterator<MenuNode>> stack = new ArrayDeque<>(); public MenuTreeIterator(MenuNode root) { stack.push(Collections.singletonList(root).iterator()); } @Override public boolean hasNext() { while (!stack.isEmpty()) { Iterator<MenuNode> top = stack.peek(); if (top.hasNext()) { return true; } stack.pop(); // 当前层遍历完了,弹出 } return false; } @Override public MenuNode next() { if (!hasNext()) { throw new NoSuchElementException(); } Iterator<MenuNode> top = stack.peek(); MenuNode node = top.next(); if (node.getChildren() != null && !node.getChildren().isEmpty()) { stack.push(node.getChildren().iterator()); } return node; } }

这样设计的好处是:树的遍历逻辑被集中在一个类里,上层业务只需要维护一个“当前菜单节点”的游标,不管是做权限校验、做面包屑导航,还是做导出,都变得很干净。顺便一说,如果你同时想在所有树节点上做“过滤掉禁用菜单”这种需求,再包一层过滤迭代器就行,不用改动原始树结构。

4.2 场景二:数据库分页查询的游标式访问

把迭代器思想用到数据库查询上,是我个人认为这个模式最有价值的应用场景之一。尤其是报表导出,动辄几十万条数据,你不能一次性SELECT *全查出来,内存直接爆掉。

我参与过一个报表导出功能,原来代码就是一把梭查全部数据再循环生成 Excel,结果某次导出一个上百万行的数据,服务内存直接飙到 80% 以上,把其他业务都给拖垮了。后来我改成“游标式”分页迭代器:数据库每批只查 5000 条,迭代器内部游标走完一批,自动查下一页,调用方完全无感。

简化后的伪代码可以这样理解:

public class PagedDataIterator implements Iterator<ReportRow> { private final DataFetcher fetcher; private List<ReportRow> batch; private int batchIndex = 0; private boolean finished = false; private long pageOffset = 0; public PagedDataIterator(DataFetcher fetcher) { this.fetcher = fetcher; this.batch = fetcher.fetchPage(pageOffset); pageOffset += batch.size(); } @Override public boolean hasNext() { if (batchIndex < batch.size()) { return true; } if (!finished && batch != null && !batch.isEmpty()) { batch = fetcher.fetchPage(pageOffset); pageOffset += batch.size(); batchIndex = 0; finished = batch.isEmpty(); return !finished; } return false; } @Override public ReportRow next() { if (!hasNext()) { throw new NoSuchElementException(); } return batch.get(batchIndex++); } }

看到这里你应该理解了,为什么我会说“迭代器不只是集合的遍历工具”。它本质上是一种对“数据流”的抽象:数据从哪里来(数组、树、数据库、文件)、怎么取(一次性、分页、惰性生成),都不重要,重要的是你能用它统一“逐个处理数据”这个动作。

4.3 迭代器模式 vs 增强 for 循环 vs 流式 API

很多初学者会困惑:Java 有for-each,有Stream API,为什么我还要自己写迭代器?我列个表,帮你把三种方式的适用边界理清楚:

方式优点缺点适用场景
手动 for 循环按索引灵活,可控制下标依赖集合随机访问能力数组、ArrayList 等随机访问结构
增强 for 循环写法简洁,统一无法在循环中修改集合结构日常遍历、只读场景
迭代器模式支持各种底层数据结构,可自定义遍历顺序、惰性计算代码量稍大封装自定义集合、隐藏数据结构、实现复杂遍历
Stream API声明式、链式调用、支持并行学习成本高,某些场景开销大数据加工管道、过滤映射汇总

这个表不是说要让你抛弃 Stream 或 for-each,而是告诉你在什么情况下迭代器模式才值得出手。简单遍历用 for-each,数据加工用 Stream,当你需要在框架层对“各种来源的数据”提供统一遍历能力、或者需要隐藏数据源内部结构时,才真正轮到迭代器模式上场。

在实际架构里,迭代器模式往往是更上层模式(比如组合模式、备忘录模式)的底层支撑。你设计缓存框架时,可能要向外部暴露一个统一的数据遍历接口,内部走 Redis 的 SCAN 游标迭代;你写日志分析工具时,可能要把多格式日志按行解析成统一事件流。这些场景,都在迭代器模式的射程之内。

5. 常见问题与排查技巧实录

5.1 fail-fast:为什么遍历时不能改集合

Java 开发者肯定都见过这个异常:ConcurrentModificationException。经常就是你在for-each遍历ArrayList的时候,顺手在循环体里remove了一个元素,然后程序瞬间爆炸。

这里要理解底层机制,其实不复杂。ArrayList内部维护了一个modCount字段,记录结构被修改的次数(比如增加、删除元素都会让它加一)。迭代器初始化时会记录当时的modCount,每次调用next()时都会检查当前modCount是否等于初始值,如果不相等,就说明集合结构在被遍历期间被改了,立即抛出ConcurrentModificationException。

这个机制叫fail-fast(快速失败),它的设计原则是:宁可运行时尽早报错,也不要在错误的状态下继续操作导致更难排查的问题。很多踩过坑的人会抱怨这个机制太烦人,但我其实觉得它是在保护你——试想一下,如果你一边遍历一边删,迭代器的游标和集合长度已经对不上了,继续读出来的数据可能是错的,或者直接越界报错,到那个时候排查起来成本高得多。

那正确的删除姿势是什么?用迭代器自己的remove()方法,而不是集合的remove(Object)。因为迭代器的remove()会同步维护游标和modCount的一致性,这样就不会触发快速失败。

5.2 迭代器能不能删除元素?

说到Iterator.remove(),这里有几个细节一定要说透。

第一,Iterator.remove()设计上有些限制,比如你在hasNext()之后、next()之前调用remove()是不合法的,会抛IllegalStateException。因为迭代器需要记住“上一次通过next()返回的是哪个元素”,才能正确删除它。如果你连next()都没调用过,它自然不知道要删谁。

第二,很多自定义迭代器的实现根本没有实现remove(),直接抛UnsupportedOperationException。这并不算错误,反而是一种取舍——迭代器本来就不是专门用来改集合的,它的核心职责是遍历。如果你有过滤掉某些元素再处理的需求,我更推荐包装一个“过滤迭代器”,而不是在遍历时边删边处理。

第三,Java 8 开始,如果你就是想一边遍历一边删除,最优雅的方式其实是removeIf()或者Stream.filter()。迭代器模式解决的是“怎么遍历”的问题,而不是“怎么删除”的问题,别把模式用错方向。

5.3 自定义迭代器的典型坑

自己实现迭代器时,最容易踩的坑有三个,都是非常现实的:

坑一:hasNext()和next()不对称调用。很多人在next()里直接取元素,而不检查hasNext()。一旦游标越界,要么返回null,要么抛异常。正确的做法是:在next()开头就检查游标是否越界,并抛出明确的NoSuchElementException,这可以在最早期暴露问题。

坑二:迭代器复用导致游标混乱。如果iterator()方法每次都返回同一个迭代器实例,比如把迭代器存到集合类内部字段里,就会导致一个集合同时只能被一个遍历进度消费。多线程或嵌套遍历时会直接出问题。记住:迭代器是轻量级的一次性对象,随用随建,用完即弃。

坑三:底层数据为空时,hasNext()没有正确返回false。比如空数组、空链表、只有null元素的数据结构。很多人在遍历完窗口或分页数据时,最后一页逻辑处理错了,多调一次next()直接异常。我在写分页迭代器时的一贯做法是:把“是否还有更多数据”的判断收敛到hasNext()里,不要让next()去猜。

5.4 避坑清单:给你一份可以直接抄的实操笔记

经验这东西,别人踩过坑说给你听,比自己踩一遍高效得多。我把迭代器相关的实操笔记汇总一下:

  • 自定义迭代器时,next()方法第一行就判断hasNext(),不满足直接抛NoSuchElementException,宁可失败得清清楚楚,也不要返回null把问题藏起来。
  • 集合类的iterator()方法必须每次返回新对象。如果一定要缓存,请确保只有一个调用方,并且明确不会嵌套遍历,否则等着踩坑。
  • 如果数据量很大,优先考虑实现一个惰性迭代器,分批加载数据,不要一次性把全部数据塞进内存。这不只是性能优化,更是架构层面的容错设计。
  • 遍历树结构时,用栈模拟递归比直接递归更容易控制,出错时也好调试。
  • 不要在一个迭代器的next()方法里做耗时过长的操作,比如重查询数据库。如果每取一个元素就要查一次库,请考虑批量预取,否则性能会退化到你无法忍受。
  • 在多个线程中共享同一个迭代器,基本等于自找麻烦。Java 的ArrayList迭代器本身就不是线程安全的,多线程场景要么加锁,要么用并发容器专用迭代器。
  • 最后,也是我特别想强调的一点:你的业务代码只应该依赖迭代器接口,而不是具体迭代器实现。这样以后替换、扩展遍历方式时,调用方一行都不用改,模式的价值才能真正发挥出来。

6. 与其他模式的关系和扩展思考

6.1 组合模式 + 迭代器模式:树形结构遍历的黄金搭档

上一篇讲行为型模式时我提过组合模式,它擅长把“部分与整体”组织成树形结构。但组合模式本身没有规定怎么遍历这棵树,这时候迭代器模式就能补上这个空缺——两者经常联合使用。

最典型的例子是文件系统。文件夹里可以放文件,也可以再放文件夹。用组合模式可以很好地描述这种“部分-整体”关系;而想要遍历整个目录树,你可以写一个CompositeIterator,内部用栈记录每一层目录的迭代器,轻松实现深度优先遍历。我见过不少框架就是这么实现配置中心、权限树、目录树的统一遍历的,底层逻辑非常清晰。

这块的设计思路值得深入体会:“结构”负责描述数据长什么样,“迭代器”负责描述怎么读取数据。两者各司其职,又天然互补。

6.2 工厂方法在迭代器中的隐藏角色

你有没有发现,迭代器和工厂方法之间有一种隐性的协作关系?

BookShelf.iterator()方法看起来只是简单new一个迭代器对象,但其实这正是一个“工厂方法”的雏形——集合类把“创建迭代器”的逻辑收拢在一个方法里,调用方不需要知道迭代器具体是什么类型。将来你增加一个ReverseBookShelfIterator,只需修改iterator()方法内部的创建逻辑,或者增加一个reverseIterator()工厂方法就行了。

这让我想起一句老话:设计模式之间不是孤岛,而是互相借力。你理解得越深,越能看到这些模式在你写的框架代码里无处不在。

6.3 从迭代器到生成器:现代语言的进化

迭代器模式是 1994 年《设计模式》书里的经典模式,但它的思想在几十年后的今天,被现代编程语言发扬光大了。Python 里的生成器函数,只要函数体里有yield,调用它返回的就是一个迭代器;C# 里的yield return也是类似的设计;JavaScript 的Generator和Symbol.iterator则让任何对象都可以变成可迭代对象。

在这些语言里,你几乎不需要手动去写迭代器类,语法帮你做了大部分事情。但底层的思想完全一致:延迟计算、状态保存、统一遍历协议。你用 Python 写for item in generator的时候,本质上就是在消费一个迭代器。

所以,就算你没有在 Java 里手写过很多迭代器,理解这个模式的思路也依然值得——它帮你建立起来的是“数据流”和“惰性求值”的思维。这种思维一建立,你再看各种数据库游标、消息队列的消费者、大文件的分行读取,全都能一眼看穿本质。

我个人在实际项目里的体会是:迭代器模式用得好的时候,不是你写了多少抽象类,而是别人根本不需要知道你数据是怎么存的、怎么取的。你提供了一个稳定的“下一个是什么”的接口,业务方只负责消费,大家各忙各的,还互相不打扰。

最后再分享一个小技巧:如果你正在设计一个会被多人调用的内部工具包或基础组件,优先把数据访问入口设计成Iterable,而不是暴露一堆getList()、getTree()这样的具体方法。这样你的组件未来无论怎么改内部存储,调用方的代码都不用动。等哪天你接到需求说要加一种全新的遍历维度,你会庆幸当初多花的这几十分钟设计时间,真的值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询