迭代器模式从入门到落地:解耦遍历与数据结构
每次聊到设计模式总会有人问迭代器模式这么基础不就是个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 IteratorE { boolean hasNext(); // 是否还有下一个元素 E next(); // 返回下一个元素同时游标后移 }这里注意一个行业惯例问题。很多人习惯把next()设计成“先返回当前元素再移动游标”把hasNext()设计成“判断游标是否越界”。但是在实际项目中我建议你把hasNext()的语义理解成“在调用next()之前必须确认还有元素”否则你会经常碰到空指针、越界之类的低级错误——这个坑我后面专门讲。接着定义集合接口public interface AggregateE { IteratorE iterator(); // 生成迭代器 }然后写具体迭代器public class BookShelfIterator implements IteratorBook { 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 AggregateBook { 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 IteratorBook iterator() { // 关键点每次调用都新建一个迭代器 return new BookShelfIterator(books); } }这个第一版实现里藏着一个容易被忽略却极其重要的细节每次调用iterator()都应该返回一个全新的迭代器对象。为什么因为迭代器是有状态的游标会随着遍历推进。如果你返回同一个迭代器第一次遍历到一半第二次再遍历就得从头开始或者直接冲突。在 Java 的集合框架里iterator()每次都返回新迭代器这不是巧合而是为了让“一次遍历、多次遍历”都能安全运行。3.2 第二个版本能倒着走的迭代器上面这个实现逻辑很简单现在咱们试着玩点花的。需求变成了书架里的书要支持倒序遍历。很多人的第一反应是那我在BookShelf里加一个reverseIterator()方法不就行了行但我不建议直接加在具体集合里。更好的做法是新增一个ReverseBookShelfIterator只改迭代器的实现BookShelf本身不用动这才是对开闭原则的实践。代码如下public class ReverseBookShelfIterator implements IteratorBook { 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 IteratorInteger { 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 个IteratorInteger 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 IteratorMenuNode { private final DequeIteratorMenuNode stack new ArrayDeque(); public MenuTreeIterator(MenuNode root) { stack.push(Collections.singletonList(root).iterator()); } Override public boolean hasNext() { while (!stack.isEmpty()) { IteratorMenuNode top stack.peek(); if (top.hasNext()) { return true; } stack.pop(); // 当前层遍历完了弹出 } return false; } Override public MenuNode next() { if (!hasNext()) { throw new NoSuchElementException(); } IteratorMenuNode 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 IteratorReportRow { private final DataFetcher fetcher; private ListReportRow 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()这样的具体方法。这样你的组件未来无论怎么改内部存储调用方的代码都不用动。等哪天你接到需求说要加一种全新的遍历维度你会庆幸当初多花的这几十分钟设计时间真的值。