ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

迭代器模式实战:告别索引遍历,给数据装个自动导航

迭代器模式实战:告别索引遍历,给数据装个自动导航

前阵子接了一个内部小项目的维护,代码里有一段遍历逻辑,写得极其辛苦:外层是一个 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); List<Order> orders = orderMap.get(user.getId()); for (int j = 0; j < orders.size(); j++) { Order order = orders.get(j); List<Item> 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 Iterator<E> { boolean hasNext(); E next(); } public interface Aggregate<E> { Iterator<E> createIterator(); }

然后有一个存放整数的集合类,底层用数组实现:

public class IntAggregate implements Aggregate<Integer> { 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 Iterator<Integer> createIterator() { return new IntIterator(); } private class IntIterator implements Iterator<Integer> { 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 场景设定:某平台的好友列表

理论讲完了,得落到实际。我给一个自己经常用来培训新同事的案例:假设你在做一个社交类的小项目,用户的好友列表有时候是用数组顺序存储的,有时候会因为业务需要被组织成链表结构。更麻烦的是,有些历史接口返回的是List<User>,有些返回的是自己封装的一个链表。

如果按照老式写法,每来一种数据结构,就要写一套遍历代码:

// 遍历 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 Iterator<User> { // 继承 hasNext、next、remove }

然后让两种容器都实现一个统一的“可迭代”接口。在 Java 里可以直接实现Iterable<User>,它要求的核心方法就是iterator(),返回一个Iterator<User>。这部分是迭代器模式的“聚合接口”角色。

3.3 数组型好友列表及其迭代器

数组存储的版本实现起来非常直观:

public class ArrayFriendList implements Iterable<User> { 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 Iterator<User> iterator() { return new ArrayFriendIterator(); } private class ArrayFriendIterator implements Iterator<User> { 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 Iterable<User> { 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 Iterator<User> iterator() { return new LinkedFriendIterator(); } private class LinkedFriendIterator implements Iterator<User> { 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(Iterable<User> friends) { for (User u : friends) { System.out.println(u.getName()); } }

不管传入的是数组型还是链表型,这段代码都不用改。业务层彻底摆脱了对底层结构的好奇心,只依赖“你能够给我一个迭代器”这个契约。

3.5 实测中的真实收益

这个案例我在内部代码评审的时候讲过很多次。统一迭代器之后,最直观的收益有两个:

第一个是删除逻辑变安全了。在迭代器模式下,如果你要在遍历时删除某个好友,可以直接用标准Iterator提供的remove()方法,而不是手动操作集合。标准迭代器的 remove 会保证 cursor 和集合状态保持一致,不会再出现索引错位跳过元素的问题。

第二个是新增数据结构时不用改业务。后来项目里又加了一个从 Redis 里取好友 ID 列表的需求,按老写法又得新写一套遍历循环。但因为整个调用方已经面向Iterable<User>编程了,我只需要做一个适配层,让 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. 一点个人实操心得:把迭代器用“活”才算真正掌握

讲了这么多原理和案例,最后说点很实在的东西。

我在最初写迭代器相关代码的时候,注意力全放在“实现一个能跑的迭代器”上面,后来才发现,真正决定代码优雅程度的,往往是“能不能站在调用方的角度设计接口”。比如说,定义聚合类时,我一般会在类上直接实现Iterable<T>,这样就能天然获得增强 for 循环的语法支持,调用方写起来毫无学习成本。再比如说,如果迭代器可能被多次使用,我会让每次调用iterator()都返回一个新实例,而不是复用同一个,这样多个遍历点互不干扰。

还有一个非常实用的小技巧:在自定义迭代器时,尽量保持迭代器为无状态之外的最小状态。你只需要维护“当前到哪了”这一个位置信息,不要把业务临时变量塞进去。否则迭代器一旦共享,状态混乱排查起来会非常痛苦。

如果你正在做一个数据来源经常变化、或者要兼容多种存储结构的项目,我建议你从今天开始,把手头的那些for (int i = 0; ...)逐步换成基于迭代器的遍历写法。不需要一步到位,先挑几个最容易出错的删除场景改掉,感受一下“不用自己数 i++”带来的安心感。等哪天你再看到一段嵌套循环里充满了索引维护代码,大概会和我一样,不自觉地想给它装个自动导航。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进