迭代器模式实战:从手动遍历到自动导航的优雅解耦

发布时间:2026/10/20 14:16:28
迭代器模式实战:从手动遍历到自动导航的优雅解耦 不知道你有没有这样的体验写业务代码的时候遇到一个列表顺手就for (int i 0; i list.size(); i)靠着下标一格一格数过去。这种写法看着很基础但业务一复杂、数据结构一调整代码就开始四处开裂。今天想聊的迭代器模式本质上就是给数据遍历装一套自动导航你告诉组件你要拿数据具体怎么定位、怎么移动、怎么判断结束全部交给迭代器处理。这篇文章会从痛点出发拆解迭代器模式的原理手写一套自定义迭代器再结合日常开发里的集合、数据库、文件流这些场景聊聊它到底解决了什么问题。适合正在学设计模式、或者写了不少遍历代码但总觉得别扭的开发者。1. 别再数 i 了手动遍历的痛点到底在哪1.1 索引遍历看似简单其实暗藏耦合先拆开看看for i到底做了什么。最典型的遍历代码是三层结构初始化一个下标用来标记当前位置每次循环判断下标是否越界通过下标从容器中取出当前元素。这三步里下标成了业务代码和数据容器之间的中间人。一旦容器从数组变成链表从链表变成 Map甚至从单机内存里的集合变成远程返回的结果集这段遍历代码就必须跟着改。我见过一个很典型的场景某个模块最开始用数组存固定数量的配置遍历时直接取下标。后来需求扩张配置变多了数组被换成列表旧的遍历代码就硬生生改了一轮。如果继续往深走把列表再换成某种按需计算的序列这段代码大概率又要重构。问题的根源不是数据在变而是遍历逻辑被写死在业务里了。下标不仅承担了“取第几个元素”的职能还承担了“这个容器到底怎么组织”的知识这两种知识不该绑在一起。从另一个角度看索引遍历还容易分散注意力。你在写业务逻辑时本意是“把每个元素处理一遍”但代码上却要关心初始值、边界条件、步长。这些细节一旦出错结果可能完全错位。它们不是你的业务核心却占用了你的大脑缓存。很多线上问题最后追查下来都是循环边界写错了而不是业务逻辑本身有错。1.2 遍历其实分两件事怎么取与取什么我们之所以习惯写成for i是因为编程入门第一课就是这么教的。但仔细想一下遍历这个动作可以拆成两层第一层是“遍历机制”负责定义从哪里开始、怎么前进、到哪里结束第二层是“业务动作”负责对遍历到的每一个元素做什么。写业务代码时大部分人都只关心第二层。比如遍历一个用户列表想做的是“把每个用户的名字拼成字符串”而不是“每次下标加一再取第 i 个”。如果能把第一层单独抽出来让第二层保持稳定那么不管底层是数组、链表、还是各种动态计算出来的序列业务代码都能用同一套方式处理。这就是迭代器模式的初心把遍历机制从业务逻辑中解耦出来交给一个专门的组件来管。这样的抽象也能让代码读起来更像人话。比如某些语言提供 foreach 语法之后你根本不需要再看到i、length、get(i)代码表达的意图变成了“对集合里的每个元素做某件事”可读性完全不一样。迭代器模式说到底就是让“每个元素怎么到达”这件事自动化业务代码只需要关注“到了以后干什么”。2. 迭代器模式本质拆解遍历逻辑如何被独立出来2.1 三个角色与一份协议迭代器模式通常涉及三个角色迭代器接口定义统一的遍历行为最核心的两个方法一般是“是否有下一个元素”和“获取下一个元素”。具体迭代器真正保存游标、维护遍历状态的实现类。不同数据结构会给出不同的动作。聚合对象/集合提供一个方法返回自己的迭代器。这样调用方拿到的是迭代器而不是直接操作内部结构。我贴一个最普通的接口形式public interface IteratorE { boolean hasNext(); E next(); }有人会问为什么只有两个方法就够了其实两个方法已经完整定义了一次遍历hasNext判断是否到底next取出当前元素并前进。相比传统的索引遍历调用方仍然可以做完整的循环控制只是不需要知道“当前在第几个”。需要注意的是next同时做了“取”和“移动”两件事。第一次调用next返回第一个元素第二次返回第二个元素。你可以把迭代器理解为一个带指针的导航器指针初始位置在起点之前每次next都取走当前位置的元素同时把指针向前推一格。hasNext的作用是告诉你前方还有没有元素避免越界。如果还需要支持删除可以在接口上加一个remove方法。不过实际工程里大多数语言已经把删除操作弱化了因为删除会改变集合结构容易引发问题。后面我会专门讲并发修改的坑。2.2 集合侧的统一入口光有迭代器接口还不够聚合对象也得给出访问入口。最典型的结构是让集合实现一个Iterable接口然后由集合返回自己的迭代器。比如public interface IterableT { IteratorT iterator(); }这样写的好处是调用方只依赖接口不依赖具体结构。你手里有一个对象只要它实现了Iterable就可以用统一的 foreach 语法遍历。至于它是数组包装、链表、哈希表还是自定义的数据结构调用方根本不关心。这种设计也带来了一个隐蔽的好处迭代器可以在不同的遍历策略之间自由切换。同一个集合你可以返回一个正序遍历迭代器也可以返回一个倒序遍历迭代器或者每隔一个元素取一个的迭代器。对调用方来说拿到的东西都是同一个Iterator接口不需要额外感知策略差异。这种灵活性很像给同一份数据装上了多个不同路线规划的导航。2.3 外部迭代与内部迭代的差别顺着上文的思路你可能会想遍历的控制权到底该放在谁手里这里有两个流派。外部迭代是指客户端主动控制整个循环每走一步都从迭代器接口取数据。经典的两个方法hasNext和next就是外部迭代的典型形态。客户端可以在循环里随时 break、跳过、甚至拿到元素后做各种判断控制非常灵活。内部迭代则是把循环控制权交给集合本身。客户端只需要告诉集合“每个元素要做什么”剩下的由集合内部完成。很多语言里的forEach方法就是这么做的。这种形态代码更简洁但控制力弱中途想退出循环往往要额外抛异常或借助特殊机制。大多数语言内置迭代器模式都是外部迭代因为这个模式最早的核心目标是统一访问方式而不是封装流程。到了函数式思想流行以后内部迭代被 Stream 这类 API 重新发扬光大了。这两者的选择其实没有绝对好坏灵活性和简洁性往往要权衡。我个人的习惯是在代码意图追求“业务就是业务结构就是结构”的场景用内部迭代更舒服在需要精细控制遍历过程的场景外部迭代更顺手。3. 手写一套自定义迭代器给遍历路径装“导航”3.1 一个具体场景过滤和排序发生在哪里光理解接口定义还不够我带你手动实现一个真实可用的迭代器。假设我们在做一个报表系统里面有一组事件对象每个对象有发生时间和事件级别。业务希望遍历时只挑出级别为“重要”的事件而且按照时间从早到晚输出。普通做法是在业务代码里写嵌套循环和条件判断但每次都这样写重复代码就会变多。更好的做法是让这个事件列表实现Iterable并返回一个“过滤排序”的迭代器。先说排序这件事。事件对象可能有几千条如果每次遍历都重新全量排序成本太高了。我选择在迭代器构造时做一次排序快照之后遍历只是依次取出结果。代码如下import java.util.*; public class EventList implements IterableEvent { private final ListEvent events; public EventList(ListEvent events) { this.events events; } Override public IteratorEvent iterator() { ListEvent snapshot new ArrayList(events); snapshot.sort(Comparator.comparing(Event::getTime)); snapshot.removeIf(e - !e.isImportant()); return snapshot.iterator(); } }注意这里的实现思路iterator()的返回值完全可以是一个普通 ArrayList 的迭代器因为我们已经把“过滤排序”的规则封装在集合侧了。调用方拿着这个迭代器根本不需要知道规则的存在只要遍历就能拿到想要的数据。这是迭代器模式一个非常值得用的点它可以把数据准备过程藏起来。原本散落在业务里的规则收拢到一起业务代码只负责消费结果。如果你有多个地方都要用同样规则遍历事件那这波抽象的收益就很明显了。3.2 自定义迭代器的状态维护与边界处理接着我们试着不依赖现成集合而是自己实现一个迭代器。假设我们要遍历一个链表的节点链表定义如下class Node { int value; Node next; Node(int value) { this.value value; } }手写的迭代器需要自己保存当前节点引用每次next往前走一步。代码大致是class NodeIterator implements IteratorNode { private Node current; NodeIterator(Node head) { this.current head; } Override public boolean hasNext() { return current ! null; } Override public Node next() { if (current null) { throw new NoSuchElementException(); } Node result current; current current.next; return result; } }这段代码虽然短但藏着三个要点。第一游标状态的保存。迭代器内部必须维护一个状态变量记录“现在走到哪了”而且这个状态不能暴露给外部。如果你把current改成 public那调用方就可以随意修改迭代过程就会被破坏。第二边界判断必须靠hasNext。很多新手写迭代器时会主动在next里判断 current 是否为空这个判断是对的但必须依赖hasNext先做检查。如果调用方不检查直接调用next就得抛出NoSuchElementException而不是返回 null 或者让程序静默出错。我给项目做代码评审时至少见过三次因为迭代器返回 null 导致 NPE 被吞掉的情况。第三next必须返回“移动前”的元素同时把当前位置推进一格。这个顺序一错要么首元素丢失要么末尾多出一个空值。写的时候要格外小心。3.3 惰性求值的意义与实现验证自定义迭代器最大的好处之一是可以做到惰性求值。什么意思就是说数据不是一开始就全部准备好而是每次next时才真正计算。比如我们要遍历斐波那契数列的前 N 项如果全量计算然后存数组N 稍大一点就浪费内存如果用一个带状态的迭代器来一项算一项内存占用就是常数级。用迭代器表达这种序列非常自然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 next prev curr; prev curr; curr next; return result; } }hasNext永远返回 true因为斐波那契数列理论上没有终点。实际使用时靠调用方控制取多少个。这种“无限序列”在普通集合场景下无法建模但迭代器可以因为它的每一步都是按需推进的。我测试这种迭代器时通常会在外面写死循环并加计数器取到目标数量就 break验证遍历结果正确且内存稳定。惰性求值在数据量大的场景下很重要后面讲文件流时还会遇到。4. 从集合到数据库再到文件流迭代器模式的实际应用现场4.1 集合框架里的语法糖原理现在几乎所有主流语言都内置了 foreach 语法。以 Java 为例下面的代码for (String name : names) { System.out.println(name); }编译器在底层其实会把 names 转成迭代器然后反复调用hasNext和next。这就是迭代器模式无缝融入日常代码的体现。你甚至不需要感知模式的存在已经天天在用。这种语法糖带来的好处是遍历代码不再依赖数据类型。无论 names 是 ArrayList、LinkedList、HashSet 还是 TreeSet遍历的写法完全一致。有人觉得这没什么写个for i也不费事。但把代码放在大型项目中这种统一性就是降低认知负荷的关键。你不需要去翻集合类型才能判断用不用get(i)你只需要知道它可迭代。除了统一性迭代器还让集合的内部结构变成真正的“内部”。链表不需要暴露 head树不需要暴露 root哈希表不需要暴露 table 数组。调用方拿到的只是一个个元素数据结构的隐私得到保护。这对封装性是非常明显的增强。我经常和团队说一个数据结构敢不敢让外部直接操作内部节点很大程度上取决于你有没有给外部一个干净的遍历入口。4.2 数据库遍历与分页场景如果你写过数据库访问层一定会遇到查询结果集的遍历。数据库返回的结果集本质上就是一个迭代器它有“是否还有下一行”的概念也有“取当前行并前进”的动作。持久层框架把 ResultSet 包装成高级迭代对象之后业务层就可以用熟悉的 foreach 循环去读每一条记录。这种包装的价值在分页场景里体现得最明显。假设一个接口需要分批拉取一批数据每批 100 条。普通写法是维护 page 和 size 两个变量每次循环都要检查当前批次是否已经凑满。如果引入迭代器思想你可以做一个“分页迭代器”它内部维护当前页号每次next就去数据库拉下一页hasNext判断当前批次是否还有未消费数据。业务侧只需要不断调用next根本不需要知道下一页的参数怎么拼。我曾在某个数据迁移项目里看到过类似的实现。当时要从旧库迁移几百万条记录如果一次性全量读入内存应用直接会挂。团队最终写了一个基于游标的分块迭代器每拉取一段数据就做一次落库最后用循环把整个迁移过程串起来。相比手动维护分页参数这种方案把“下一页怎么取”封装起来了对上层来说迁移过程只是一段连续的遍历。这种体验和标题里说的“自动导航”一样路线复杂但方向盘交给了迭代器。4.3 文件逐行读取与流式处理再看文件处理。很多人写文件读取时喜欢一次性把所有行读进 List再遍历。这种做法在小文件上没毛病但文件一旦变成上 GB内存就会告急。流式读取的思路本质上是惰性迭代每次读一行处理完即弃内存只保留当前行。Java 里的BufferedReader.readLine()天生就带有迭代器的味道只是没有显式接口。后来很多框架直接提供了行迭代器IteratorString lineIterator FileUtils.lineIterator(file); while (lineIterator.hasNext()) { String line lineIterator.next(); // 处理一行 }这种写法最大的优点是不用担心文件整体大小。你只需要跟随迭代器往前推进每行数据用完就丢。大文件处理、日志分析、ETL 管道几乎都是这个套路。顺着这个思路你会发现很多“流式处理”本质上都是迭代器的扩展。Source 给下游提供一个一个对象下游只消费不回头看。只要上游和下游都遵守“hasNext/next”这种协议整个管道就能以恒定内存运行起来这比在一开始就把所有数据塞进内存再处理高效得多。4.4 树与图的深度遍历最后说说树形结构。很多开发者写树遍历时脑子里只有递归。递归确实直观但遇到深度很大的树时会带来栈溢出风险而且想在遍历过程中暂停、分段、或者从上次位置继续实现起来非常别扭。迭代器恰好适合解决这类问题。假设我们要对一棵二叉树做中序遍历可以手写一个迭代器它内部维护一个栈把左子树一路压进去每次next弹出一个节点再处理右子树。调用方得到的效果是一个普通循环就能完成中序遍历而且不受递归深度限制。树的遍历路径被封装在迭代器内业务代码完全不需要关心“当前走的是左子树还是右子树”。这种改造的好处不只在于避免栈溢出。迭代器还可以让你在树的遍历中随时停住记住当前位置过一会儿再继续遍历。这在分页展示树形数据、按需展开层级节点等需求中非常实用。搜索功能里常见的“每次取匹配的前 N 条”也能靠有状态的迭代器做到。5. 迭代器的亲戚们生成器、Stream 流与惰性序列5.1 生成器更简洁的迭代器写法如果让你每次自定义迭代器时都手写hasNext和next你会发现有一半代码在维护状态。为了简化这件事很多语言引入了生成器Generator它是一种用函数写法来定义迭代过程的语法。比如 Python 里的yield关键字普通函数一旦带yield调用时不会立即执行而是返回一个生成器对象每次迭代才执行到下一个yield。用生成器实现斐波那契比手写迭代器省力得多def fibonacci(): a, b 0, 1 while True: yield a a, b b, a b这个生成器本质上就是一个迭代器。它有惰性求值的能力有持续推进的状态甚至可以被 foreach 直接消费。理解迭代器模式之后再看生成器会非常通透生成器只是用函数的局部变量隐藏了迭代器内部的状态变量让“状态保存”这件事从显式游标变成了函数的隐式状态。我不太建议新手一开始就只学生成器而忽略迭代器因为生成器虽然写起来方便但对底层原理的感知会被削弱。搞清楚迭代器怎么保存游标、边界条件怎么判断之后再学生成器就会知道它凭什么能“暂停”。5.2 Stream 流内部迭代的集大成者如果说迭代器是外部迭代的代表那 Stream 流就是内部迭代的集大成者。在声明式写法里你不再主动控制循环而是描述要对元素做什么names.stream() .filter(n - n.length() 3) .map(String::toUpperCase) .forEach(System.out::println);这套 API 内部其实也有一个迭代器在驱动。filter 和 map 不会立刻做完全部计算而是配合迭代器逐个元素处理每拉出一个元素先过滤再映射最后交给 forEach 消费。正因为底层是惰性的Stream 才能对无限流做限制操作而不至于内存爆炸。Stream 与迭代器的关系经常被搞混。我认为 Stream 更像是“迭代器模式 函数式流水线”的升级版它吸收了迭代器按需取数的优点又加入了链式组合能力。当你需要高度灵活的遍历控制时外部迭代器仍然是更好的选择当你追求表达力和代码意图清晰时Stream 这类内部迭代 API 会更顺手。这里想强调一个工程观点不要把 Stream 和迭代器对立起来它们是同一思想的两种表达。迭代器把“遍历动作”格式化为两个方法Stream 把“处理流程”格式化为数据管道。底层共享的是同一个“一个接一个取数据”的心智模型。6. 实际项目中最容易踩的坑并发修改与游标失效6.1 ConcurrentModificationException 是怎么出现的有了迭代器之后很多开发者会遇到一个经典异常在遍历集合的过程中修改了集合结构结果抛出了 ConcurrentModificationException。这个异常并不是并发编程专属的哪怕单线程也可能出现。它的来源可以这样解释迭代器在创建时会记录一个预期修改次数modCount。每次调用next时迭代器会检查集合当前的修改次数是否和预期值一致。如果不一致说明集合在遍历过程中被改过结构迭代器的游标位置已经不可信于是立刻抛出异常。比如这段代码ListString list new ArrayList(); list.add(A); list.add(B); for (String s : list) { if (s.equals(A)) { list.remove(s); } }foreach 底层用的是迭代器remove 操作改动了集合结构modCount 变了下一次next就会抛异常。这种设计称为快速失败它是用一次异常换掉了可能的静默错误。我碰到过不少开发者在这种情况下尝试 try-catch 然后继续结果越改越乱。正确做法是在迭代器上调用它自己的 remove 方法或者在遍历结束后单独再处理删除。6.2 fail-fast 与 fail-safe 的取舍快速失败机制不代表所有实现都这样。有的集合会采用安全失败fail-safe例如某些并发容器在迭代时的工作副本上遍历即使原集合被修改迭代也不会报错但代价是迭代器读到的数据可能不是最新数据。两者的取舍其实是一个工程选择。快速失败能尽早暴露问题防止脏数据悄悄扩散安全失败更注重可用的遍历结果用一定的延迟一致性换取稳定性。在并发高的场景我个人更倾向用快照迭代也就是在迭代开始前做一份不会变更的副本这样既能拿到一致的数据又不会因为外部写入而中断遍历。6.3 一份迭代器问题速查表我把平时最常碰到的问题整理成一张表方便排查时直接对照。这些坑我基本都在项目中踩过或者帮别人定位过。现象可能原因处理建议遍历抛 ConcurrentModificationException迭代过程中修改了集合结构使用迭代器的 remove或改为构建新集合并用循环后统一更新自定义迭代器死循环hasNext 没有正确推进或边界判断错误检查游标更新逻辑用单元测试覆盖空集合和单元素场景迭代到一半发现数据重复/缺失迭代器状态被外部修改把游标设为私有禁止外部直接操作内部字段next 返回结果顺序不稳定底层集合是哈希结构顺序本身不固定有顺序要求时改用有序集合或让迭代器显式排序大文件一次性读入导致 OOM使用了全量加载而不是流式遍历换成逐行迭代器保持常量内存消费这张表不一定穷尽所有问题但覆盖了我日常工作里迭代器相关的绝大多数排查点。每个问题背后其实都是同一个提醒迭代器把遍历抽象了但状态一致性和边界条件仍然需要认真对待。7. 什么时候该用迭代器什么时候别用我的架构建议7.1 迭代器模式最舒服的四个场景总结来说下面四类场景用迭代器模式会很舒服集合内部结构复杂或可能变化但业务层希望用统一方式遍历。数据量很大不能一次全部加载需要按需取数。遍历过程需要暂停、恢复或者分为多段处理。想隐藏集合的内部表达只把元素暴露给上层。如果你写的代码同时命中其中两条采用迭代器模式基本稳赚不赔。特别是第二点大数据的流式处理几乎离不开这种设计。7.2 别为了模式而模式但我也要泼一盆冷水。迭代器模式不是银弹如果集合非常简单、容量固定、也没有变化的可能那for i就是更直接的选择。一个只有十几个元素的数组非要去包装一层迭代器反而增加了代码层级阅读起来绕弯。过度抽象是设计模式最常见的滥用方式。我在评审代码时见过有人为了“让遍历看起来很厉害”给一个简单的配置列表做了自定义Iterable而列表底层就是 ArrayList数据量不超过二十条。这个设计不仅没有解决真实问题还让后续维护者多学一层间接。设计模式的正确用法是解决真问题而不是给代码加装饰。从工程成本角度说每增加一层抽象就增加了理解成本和出错面。迭代器模式的核心收益是解耦但如果本来就没有依赖需要解那就不需要引入。先把数据结构稳定下来再判断遍历方式是否需要抽象会稳妥很多。7.3 我的个人使用体会最后说一点我自己的体会。最早接触迭代器模式时我把它当成了一个“有接口、有实现的写法”用过几次就以为懂了。真正让我改变看法的是遇到一个数据结构频繁调整的项目。那时候我深刻意识到遍历逻辑如果不独立出来每一次数据结构变化都会撕开业务代码里一大片循环。而一旦把遍历收敛到迭代器里数据内部怎么变上层代码纹丝不动。从那以后我写遍历代码前会习惯性问自己一句这里到底是“通过下标取数据”还是“逐个消费元素”如果是后者即使底层就是一个数组我也会想清楚将来它有没有可能换成别的结构。有变化可能就值得用迭代器没有就直接for i。这里分享一个实操技巧给自定义迭代器写单元测试时至少覆盖空集合、单元素集合、多元素集合、遍历中修改集合四种情况。很多迭代器 bug 都藏在空集合和单元素集合的边界里这两个场景容易暴露边界判断错误。把这一层基础打牢再遇到那些看起来很高级的流式框架你也能一眼看穿它的内核。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询