迭代器模式实战:告别索引遍历,给数据装个自动导航
前阵子接了一个内部小项目的维护代码里有一段遍历逻辑写得极其辛苦外层是一个 ArrayList内层还有两个 Map为了把数据拼成报表写了三层循环索引变量从 i、j 到 k中间还穿插各种 size() 判断、remove 操作。结果上线不到两周就出问题了——某个列表在删除元素后索引错位活生生跳过了几条数据。当时我在群里吐槽了一句这哪是在写业务明明是在数着 i 走路。改完那一段之后我把很多类似的遍历逻辑都换成了迭代器也就是标题里说的“给数据装个自动导航”。迭代器模式听着很理论但它解决的就是这种最朴素的痛点调用方根本不关心数据放在数组里还是链表里也不关心下一个元素是怎么找出来的只要给我一个“导航器”让我能一个一个把数据取完就行。这篇文章就把这套模式的原理、实现、实战要点和一些容易翻车的细节一次讲清楚适合正在写业务代码、想提升代码可维护性的后端开发也适合刚学设计模式但不知道怎么落到实处的朋友。1. 别再数着 i 走路手写索引遍历到底败在哪里1.1 一个让人头大的真实场景我现在还记得那个报表生成函数的原始写法大概长这样for (int i 0; i userList.size(); i) { User user userList.get(i); ListOrder orders orderMap.get(user.getId()); for (int j 0; j orders.size(); j) { Order order orders.get(j); ListItem items itemMap.get(order.getOrderNo()); for (int k 0; k items.size(); k) { // 拼接报表行 } } }单纯看这段代码语法没毛病。但问题出现在后面一个很常见的需求变更产品说“报表里要把已取消的订单过滤掉”于是有人在中间那层循环里写了orders.remove(j)。就是这个 remove让内层循环的j和orders.size()发生了错位每删掉一个元素后面的元素整体往前挪了一位但j还是照常自增结果就是下一个元素被白白跳过了。这种问题在现场定位的时候特别浪费时间因为数据量不大的时候偶尔不触发等数据量一大跳数、越界、数组下标异常全冒出来了。最要命的是这种“索引 size 手动维护”的遍历方式把集合内部结构和业务逻辑死死绑在了一起。1.2 索引遍历的三个隐性成本很多人觉得for (int i 0; i list.size(); i)是天经地义的事毕竟入门教材就是这么教的。但从工程角度看这种写法至少有三个隐性成本第一调用方必须知道集合的底层结构。用get(i)前提是这个集合支持随机访问ArrayList 很快但 LinkedList 的get(i)是逐个遍历复杂度是 O(n)。如果某天你把 ArrayList 换成 LinkedList业务代码自己不会报错但性能会莫名其妙地下降一个量级。第二遍历逻辑散落在各处。十个地方需要遍历同一个集合就有十份类似的循环代码每个地方维护一套i/j/k。一旦遍历规则变化比如要跳过某些元素、要在某个条件下停止你得把所有调用点都找出来改一遍。第三遍历过程中做删除、修改操作极其容易出 bug。索引一旦发生位移轻则逻辑错误重则数组越界。这不只是新手会踩老手在手写复杂嵌套遍历时也经常翻车。1.3 导航器和乘客应该分开我后来想明白了一件事遍历集合这件事本质上和“从 A 地走到 B 地”是一样的。乘客需要的是“下一步往哪走”而不是自己去记路牌、算里程。手动索引遍历就像是乘客坐在副驾驶指挥司机按照“第 1 个路口左转、第 2 个路口右转……”的方式开车一旦路面有临时改道整个路线就全乱套了。迭代器模式做的事情就是把“怎么找到下一个元素”这件事从调用方手里收回去交给一个专门的导航器对象。调用方只握着这个导航器反复问两个问题还有没有下一个把下一个给我。至于下一个元素是数组的下一位还是链表的 next 指针调用方完全不关心。这就是这个模式的核心价值解耦。它把“遍历算法”从“集合结构”中抽离出来让同一套业务代码可以平滑适配不同的数据组织方式。2. 自动导航的地图与引擎迭代器模式的四个关键角色2.1 用导航仪来理解角色分工迭代器模式的标准定义是提供一种方法顺序访问一个聚合对象中的各个元素而又不需要暴露该对象的内部表示。展开来看它通常由四个角色组成角色对应导航场景核心职责Iterator迭代器接口导航仪面板定义 hasNext()、next() 等统一操作ConcreteIterator具体迭代器导航仪的芯片和算法维护当前遍历位置实现具体的“找下一个”逻辑Aggregate聚合接口地图服务平台声明创建迭代器的方法比如 iterator()ConcreteAggregate具体聚合类具体的地图数据持有真实数据返回一个能遍历自己的迭代器这里最容易混淆的是“聚合接口”和“具体聚合类”的关系。你可以把 ConcreteAggregate 理解成一个数据仓库它知道自己内部存的是什么结构但它不直接把这些结构细节抛给外面。外部要想遍历这批数据就问仓库要一个导航器仓库内部用最合适的方式把导航器造出来。这样一来外部拿到的永远是同一个“导航器接口”至于导航器里是查数组下标还是顺着链表指针跑都是内部的秘密。2.2 为什么迭代器接口通常只有两个核心方法最常见的迭代器接口就是hasNext()和next()两个方法很多语言里还会带一个可选的remove()。为什么这两个方法就够用了因为它们对应的是最朴素的遍历循环while (iterator.hasNext()) { Element e iterator.next(); // 处理 e }hasNext()回答“商店里还有没有商品”next()回答“把下一件商品拿出来”。把这两个操作封装好之后循环的骨架就固定了无论集合底层是数组、链表、树还是数据库游标遍历代码一字不改。有些人可能会问那为什么不在迭代器里直接提供一个forEach()方法把整个循环也封装掉这个问题问得很好。答案是迭代器模式更强调外部迭代——由调用方控制遍历节奏。这样做的好处是调用方可以在遍历过程中做各种判断提前终止、跳过某些元素、实现多个集合的同步遍历等等。把控制权交给调用方灵活性最高。至于那种“你告诉我做什么我替你遍历完”的方式很多语言里的 forEach、Stream API 也支持那是另一种风格后面我会单独对比。2.3 一个最小骨架代码先看最基本的接口设计。假设我们要做一个简化版public interface IteratorE { boolean hasNext(); E next(); } public interface AggregateE { IteratorE createIterator(); }然后有一个存放整数的集合类底层用数组实现public class IntAggregate implements AggregateInteger { private int[] data; private int size; public IntAggregate(int capacity) { data new int[capacity]; size 0; } public void add(int value) { data[size] value; } Override public IteratorInteger createIterator() { return new IntIterator(); } private class IntIterator implements IteratorInteger { private int cursor 0; Override public boolean hasNext() { return cursor size; } Override public Integer next() { return data[cursor]; } } }这里我只是为了演示最简结构。实际项目中通常会直接实现某个语言标准库里的迭代器接口比如 Java 的java.util.Iterator这样集合类就能直接用在增强 for 循环里。关键点是IntIterator是IntAggregate的内部类它可以随意访问外层类的私有数组和 size而外部完全感知不到数组的存在。3. 实战给两种截然不同的数据容器装上同一个自动导航3.1 场景设定某平台的好友列表理论讲完了得落到实际。我给一个自己经常用来培训新同事的案例假设你在做一个社交类的小项目用户的好友列表有时候是用数组顺序存储的有时候会因为业务需要被组织成链表结构。更麻烦的是有些历史接口返回的是ListUser有些返回的是自己封装的一个链表。如果按照老式写法每来一种数据结构就要写一套遍历代码// 遍历 ArrayList for (int i 0; i arrayFriends.size(); i) { User u arrayFriends.get(i); // 处理 } // 遍历自定义链表 LinkedNode node linkedFriends.getHead(); while (node ! null) { User u node.getData(); // 处理 node node.getNext(); }这两段代码的业务处理逻辑是完全一样的但因为存储结构不同就要写两遍。一旦处理逻辑有改动两个地方都得同步改很容易漏。用迭代器模式就可以统一收口。3.2 先定义统一导航接口我直接在项目里定义一个自己的迭代器接口也可以在 Java 项目中直接继承java.util.Iterator两者目的相同。这里为了演示完整过程用标准接口import java.util.Iterator; import java.util.NoSuchElementException; public interface UserIterator extends IteratorUser { // 继承 hasNext、next、remove }然后让两种容器都实现一个统一的“可迭代”接口。在 Java 里可以直接实现IterableUser它要求的核心方法就是iterator()返回一个IteratorUser。这部分是迭代器模式的“聚合接口”角色。3.3 数组型好友列表及其迭代器数组存储的版本实现起来非常直观public class ArrayFriendList implements IterableUser { private User[] users; private int size; public ArrayFriendList(int capacity) { users new User[capacity]; size 0; } public void add(User user) { users[size] user; } public int size() { return size; } Override public IteratorUser iterator() { return new ArrayFriendIterator(); } private class ArrayFriendIterator implements IteratorUser { private int cursor 0; Override public boolean hasNext() { return cursor size; } Override public User next() { if (cursor size) { throw new NoSuchElementException(no more user); } return users[cursor]; } } }这里的要点是cursor表示“下一个要返回的元素的当前位置”每次调用next()返回当前元素后自增一位。cursor size是判断是否还有元素的关键条件这里用的是容器写入数据的实际数量size而不是数组的容量 capacity否则会把还没写入数据的空位也遍历出来。3.4 链表型好友列表及其迭代器链表版本的核心区别在于“下一个元素”不是下标加一而是顺着节点的 next 引用走public class LinkedFriendList implements IterableUser { private Node head; private int size; private static class Node { User user; Node next; Node(User user) { this.user user; } } public void add(User user) { Node newNode new Node(user); newNode.next head; head newNode; size; } public int size() { return size; } Override public IteratorUser iterator() { return new LinkedFriendIterator(); } private class LinkedFriendIterator implements IteratorUser { private Node current head; Override public boolean hasNext() { return current ! null; } Override public User next() { if (current null) { throw new NoSuchElementException(no more user); } User user current.user; current current.next; return user; } } }现在神奇的事情发生了。这两种容器在遍历代码上完全统一void printAllFriends(IterableUser friends) { for (User u : friends) { System.out.println(u.getName()); } }不管传入的是数组型还是链表型这段代码都不用改。业务层彻底摆脱了对底层结构的好奇心只依赖“你能够给我一个迭代器”这个契约。3.5 实测中的真实收益这个案例我在内部代码评审的时候讲过很多次。统一迭代器之后最直观的收益有两个第一个是删除逻辑变安全了。在迭代器模式下如果你要在遍历时删除某个好友可以直接用标准Iterator提供的remove()方法而不是手动操作集合。标准迭代器的 remove 会保证 cursor 和集合状态保持一致不会再出现索引错位跳过元素的问题。第二个是新增数据结构时不用改业务。后来项目里又加了一个从 Redis 里取好友 ID 列表的需求按老写法又得新写一套遍历循环。但因为整个调用方已经面向IterableUser编程了我只需要做一个适配层让 Redis 数据也返回一个迭代器就行。业务代码稳如泰山一行没改。4. 自动导航也有隐藏关卡迭代器最容易翻车的四个细节4.1 外部迭代器和内部迭代器控制权到底在谁手里前面我写的都是“调用方主动拿迭代器在一个 while 或 for 循环里逐个取元素”这种叫外部迭代器。它的特点是遍历的过程由客户端驱动客户端可以随时中断、跳过、甚至同时遍历两个集合。与之相对的还有内部迭代器典型代表就是很多语言里的forEach。你只需要告诉集合“每个元素我要干什么”集合内部自己完成遍历。这种风格写起来更简洁代价是控制力下降如果你想在遍历过程中把两个集合按顺序一一配对内部迭代器很难优雅实现。我自己的经验是业务需求变化越频繁越适合外部迭代器。因为遍历节奏捏在自己手里中途加个 break、加个 filter、加个计数器都只是调用方局部代码的调整不用改集合和迭代器本身。如果你的场景就是简单地对每个元素做同一件事那用语言自带的 forEach 更省事也不用自己写迭代器。4.2 遍历时删除元素fail-fast 机制和 ConcurrentModificationException这个坑几乎每个人都踩过。Java 里有一个经典错误在 for-each 遍历 ArrayList 的过程中直接调用list.remove(...)程序抛出ConcurrentModificationException。原因很简单ArrayList 内部维护了一个modCount字段记录结构被修改的次数。迭代器在创建时会记住这个值之后每次调用next()都会检查当前modCount是否和创建时一致如果不一致就立刻抛异常。这种设计叫fail-fast意思是“宁可快速失败也不在错误状态下继续运行”。为什么要这么做因为如果迭代器不管这个变化还是按原来的 cursor 一路走下去可能会出现元素错乱、重复遍历、甚至死循环。快速失败是保护机制不是 bug。那正确做法是什么如果你要在遍历时删除当前元素就使用迭代器自带的remove()方法。如果要在遍历时批量删除符合条件的元素更好的办法是用Collection.removeIf()它内部会对迭代器和结构修改做统一处理。总之要记住一个原则集合结构变了迭代器必须知情不要让迭代器和集合各走各的。4.3 迭代器本身的“过期”问题继续把导航仪类比往深了推导航仪在使用过程中如果路网突然变了导航仪需要更新地图才能继续工作。迭代器也一样。迭代器创建完之后如果原本的集合被其他代码结构性修改了添加元素、删除元素、清空这个迭代器就处于“过期”状态继续使用会有风险。实际项目中经常遇到这个问题的场景是一个请求里多个地方共享同一个集合某个工具方法在遍历时偷偷删了几个元素结果外面还有另一个迭代器在用这个集合瞬间爆炸。这种问题从现象上看像是并发导致的但其实单线程里也可能出现因为结构修改的来源不止一个。解决办法没什么银弹核心还是降低共享可变性如果集合不需要变动就传只读视图或直接传不可变集合。如果确实需要多个遍历点就每次取新的迭代器。如果必须共享并修改就用并发容器或引入显式锁。4.4 自定义迭代器时最容易忽略的两件事我自己在带项目时看过很多人写自定义迭代器踩得最多的有两个地方。第一没判空就取 next。迭代器的使用契约是先调用hasNext()判断再调用next()。但很多自定义实现里直接return data[cursor]如果调用方手滑或逻辑不当在越界时才调用next()就会得到难看的越界异常。标准库的做法是抛NoSuchElementException语义更清晰。自定义实现时最好也这样处理。第二迭代器里持有外部可变对象。如果迭代器保存了集合的引用并且集合本身是会变化的那你的迭代器和集合的状态很容易不一致。稳妥的设计是让迭代器通过内部类持有集合的引用但在遍历期间监控结构变化或者干脆在创建迭代器时对数据做一次快照。快照迭代器在很多现代语言的集合库里都有应用好处是稳定坏处是多一次拷贝开销具体取舍要看数据规模。5. 自动导航不是万能钥匙这些场景我更愿意用回普通循环5.1 固定的小数组没必要写迭代器如果你面对的就是一个固定长度的、几乎不变的数组而且遍历时还要同时操作多个下标关系那老老实实写普通 for 循环反而更清晰。比如二维矩阵转置、图像卷积这类算法本质上就是在跟“位置”打交道用迭代器抽象掉索引反而添乱。迭代器模式的价值在于“屏蔽结构差异”但如果你压根没有结构差异也不需要可替换性那过度设计就不划算了。我在实际项目中会把握一个度当且仅当客户端不该知道数据组织方式、或者同一批数据可能来自不同组织方式时才值得引入迭代器抽象。5.2 遍历过程需要频繁回头、跳跃迭代器不合适迭代器大多是单向游走的设计hasNext()和next()只支持往前走不支持回退。如果你需要在集合中间任意位置插入、跳回上上个元素、或者进行一次二分查找那迭代器反而会限制你。这种场景更合适的做法是直接用索引访问或者使用一些支持双向遍历的结构比如 Java 的ListIterator。我的经验是该用索引的时候就用索引不要为了用设计模式而用设计模式。迭代器是工具不是信仰。5.3 和增强 for、Stream API 的关系很多人会问有了增强 for 循环和 Stream是不是就不用学迭代器模式了这是个好问题但答案是你其实一直在用迭代器。在 Java 里增强 for 循环的底层就是靠Iterable.iterator()实现的。你能写出for (User u : users)前提就是users提供迭代器。Stream API 里很多中间操作虽然看起来是在处理集合但底层仍然大量依赖迭代或类似 Iterator 的机制。也就是说迭代器模式不是被淘汰了而是变成了基础设施藏在语言细节下面。在我看来真正值得学的不是某个具体的迭代器类的用法而是那种“将遍历能力抽象出来”的思维。有了这种思维你面对一个新的数据结构时第一反应不是“我该怎么用下标访问它”而是“我该让它提供怎样的迭代能力”。5.4 一个务实的选择清单我在代码评审的时候经常给一个清单帮助团队判断要不要引入自定义迭代器判断问题如果答案是“是”如果答案是“否”数据结构是否会经常更换建议抽象迭代能力直接遍历即可业务代码是否依赖 get(i) 这类随机访问保留索引访问可以改为迭代器遍历过程中是否经常需要删除用标准迭代器 remove 或 removeIf随意数据规模是否很大且对性能极敏感评估迭代器开销优先保证正确性是否有多处重复的遍历逻辑集中抽象到迭代器或遍历方法不必过早抽象这个清单不是硬性标准更像是我自己踩坑之后沉淀出来的判断辅助。本质逻辑还是那句话抽象是用来降低复杂度的如果它反而引入更多复杂度就该停下来想想值不值。6. 一点个人实操心得把迭代器用“活”才算真正掌握讲了这么多原理和案例最后说点很实在的东西。我在最初写迭代器相关代码的时候注意力全放在“实现一个能跑的迭代器”上面后来才发现真正决定代码优雅程度的往往是“能不能站在调用方的角度设计接口”。比如说定义聚合类时我一般会在类上直接实现IterableT这样就能天然获得增强 for 循环的语法支持调用方写起来毫无学习成本。再比如说如果迭代器可能被多次使用我会让每次调用iterator()都返回一个新实例而不是复用同一个这样多个遍历点互不干扰。还有一个非常实用的小技巧在自定义迭代器时尽量保持迭代器为无状态之外的最小状态。你只需要维护“当前到哪了”这一个位置信息不要把业务临时变量塞进去。否则迭代器一旦共享状态混乱排查起来会非常痛苦。如果你正在做一个数据来源经常变化、或者要兼容多种存储结构的项目我建议你从今天开始把手头的那些for (int i 0; ...)逐步换成基于迭代器的遍历写法。不需要一步到位先挑几个最容易出错的删除场景改掉感受一下“不用自己数 i”带来的安心感。等哪天你再看到一段嵌套循环里充满了索引维护代码大概会和我一样不自觉地想给它装个自动导航。