迭代器模式:把遍历从数据结构中解耦
发布时间:2026/10/1 10:42:22来源:尧图网络
接手一个维护了三年的项目最让我头疼的往往不是复杂的业务逻辑而是看别人怎么遍历数据。同样一个集合有人用下标for有人用迭代器还有人先把列表转成Stream再筛选。等你准备重构数据结构时会发现所有遍历代码都和他们各自的数据结构绑死了一换存储方式所有调用方都要跟着改。这种痛几乎每个写了几年代码的人都体会过。今天要聊的迭代器模式就是专门解决这个问题的一套成熟方案——它让遍历数据和翻书一样你手里拿着的只是一个书签永远只关注当前页读完一页翻一页至于这本书有多少页、内部装订结构是什么样根本不用管。这篇文章很适合写业务代码写到烦躁的开发者也适合正在学数据结构但总被遍历细节绕晕的同学。1. 先说痛点为什么我们写遍历时总在复制粘贴我把最常见的几种数据结构拆开看过数组有下标链表有next指针Map只能通过key集合才能迭代树要么递归要么自己维护栈。每种结构都自带一套访问协议。麻烦的是绝大多数业务逻辑根本不关心它是什么结构——我只想一个接一个地把数据拿出来处理。于是业务代码里就开始疯狂复制粘贴同样的遍历逻辑。这不只是一个编码习惯问题。我见过最夸张的案例一个单链表遍历的代码在三个服务里各抄了一份。后来链表为了性能优化加了一个头结点哨兵字段三个服务全得跟着改改完还要担心有没有漏改。而真正需要动的业务逻辑一个字都没变。这说明一个问题只要遍历方式和数据结构耦合在一起任何一小处存储调整都可能变成一次跨项目的大扫除。遍历这个动作表面上很简单但拆开看其实包含三个隐含的问题从头还是从任意位置开始拿到当前元素之后怎么找到下一个元素什么时候算结束如果换个角度想这三个问题完全可以被压缩成两个约定好的操作hasNext()判断还有没有下一页next()取出当前内容并翻到下一页。这样一来调用方就不需要知道底层到底是数组、链表还是树它只需要知道这个对象可以被顺序访问。这也是迭代器模式最核心的动机把如何遍历这件事从聚合对象内部抽离出来放进一个独立的迭代器对象。用翻书的比喻最贴切——迭代器就是书签你不需要把整本书背下来也不需要知道每页纸是怎么装订的只要顺着书签一页一页往下翻就行。2. 迭代器模式的骨架书、书签和约定迭代器模式在经典设计模式分类里属于行为型模式因为它拆分的对象是行为——遍历行为。它本身的结构非常稳定主要角色就三个Iterator接口、具体迭代器、聚合对象。这里必须把原理讲透否则换种语言、换个场景你又认不出它了。2.1 三个角色缺一不可第一个角色是Iterator接口它规定了遍历必须具备的操作。在Java里是hasNext()、next()、remove()在Python里是__next__()在JavaScript里是next()返回一个包含value和done的对象在C#里是MoveNext()和Current属性。不同语言的叫法不同但翻到底层意思完全一样当前位置从哪里开始、如何移动、如何判断结束。第二个角色是ConcreteIterator也就是具体迭代器。它保存了当前游标位置并且实现了那个接口。注意具体迭代器是独立于聚合对象的这样聚合对象内部结构再怎么复杂迭代器都只需要关心一件事我该怎么定位下一个元素。第三个角色是Aggregate聚合接口它提供创建迭代器的方法。Java里是iterator()Python里是__iter__()JavaScript里是 Symbol.iterator 。调用方不直接new一个迭代器而是让聚合对象自己提供一个迭代器实例这样迭代器知道该用什么方式访问它的内部。语言/平台迭代器抽象获取迭代器的方式结束判定JavaIterator hasNext/next/removeiterable.iterator()hasNext() 返回 falsePythonnext()iter(obj)对象实现iter抛 StopIteration 异常JavaScriptnext() 返回 { value, done }obj Symbol.iteratordone 字段为 trueC#IEnumerator MoveNext/CurrentGetEnumerator()MoveNext() 返回 false这张表很有意思你会发现现代主流语言不约而同地把迭代协议内建到了语法层面。这其实是在告诉开发者把遍历行为封装起来不是一个可选的优化技巧而是语言设计者默认你应该遵守的基础约定。2.2 为什么怎么存和怎么读必须拆分假设你有个ArrayList也有一个LinkedList业务逻辑想逐个打印它们的所有元素。如果不知道迭代器模式你可能会这样写对ArrayList用下标for对LinkedList用while配合指针或者干脆用增强for循环碰运气。但一旦你知道了迭代器模式代码就统一成这样while (iterator.hasNext()) { Item item iterator.next(); System.out.println(item); }这段代码对ArrayList和LinkedList完全通用因为迭代器把下一个元素在哪变成了自己内部的问题。调用方不需要知道ArrayList是连续内存块LinkedList是节点链式结构。不只是写法统一性能也会天差地别。LinkedList如果非要用for(int i0; ilist.size(); i)配合get(i)每次get都要从头部重新遍历到第i个位置整体复杂度是O(n²)。这条路是迭代器设计的一大驱动力用迭代器保持当前位置每次移动只需要O(1)整体遍历是干净的O(n)。更深一层这种拆分还带来一个隐蔽的好处你可以随时换掉底层数据结构。只要新结构依然能提供同样语义的迭代器调用方代码一行都不用改。这就是接口设计的价值——调用方依赖的是可迭代这个能力而不是这是一个ArrayList这个事实。2.3 语言层面的隐藏协议Python里有非常典型的协议设计。你只要实现__iter__()返回一个迭代器以及让迭代器实现__next__()这个类的对象就可以直接放进for循环。JavaScript的Symbol.iterator也是同样的思路甚至可以让同一个对象被for...of消费。再提一个容易被忽略的点迭代器天生是单向、向后移动的不允许回头。如果需要双向遍历Java的ListIterator提供了previous()Python里没有现成的双向迭代器但可以自己包一层缓存或者倒序列表。所以千万不要以为迭代器模式只解决向前走的问题它更多是定义了一种你和数据之间的取用约定约定的边界在哪里完全由接口决定。3. 动手实现如何让迭代器在遍历时安全删除元素读代码的时候大家都觉得自己懂了一到自己写就踩坑。迭代器模式最容易出状况的就是遍历过程中修改集合。这个问题在Python、Java、JavaScript里几乎都会遇到而且踩坑的方式惊人一致。我拿一个真实场景演示完整的排查思路。3.1 一个让很多人懵圈的经典场景边遍历边删除先看这段Python代码lst [1, 2, 3, 4, 5] for x in lst: if x 2: lst.remove(x) print(lst)直觉上你可能会以为结果只剩[1, 2]。但实际运行出来是[1, 2, 4]。为什么4还在因为Python的list迭代器内部维护了一个自增索引。当遍历到3时满足条件执行remove(3)list变成[1, 2, 4, 5]迭代器内部的索引却已经指向了下一个位置。remove操作导致后面的4和5整体前移了一位于是下一次迭代器读取到的是5而4被直接跳过了。等于说remove操作悄悄越过了一个本应该被检查的元素。这个问题的根源不是迭代器有bug而是一边遍历一边用集合自身的方法修改结构会造成迭代器维护的游标和集合真实状态失去同步。站在迭代器模式的角度看它正是在告诉你任何对集合结构的修改都必须在迭代器的语义之内完成否则后果自负。3.2 我自己写一个能安全删除的迭代器理解了上面那个坑再来看正确解法就特别清晰。以Python为例手写一个支持安全删除的迭代器。关键点在于迭代器不能只记录我当前在哪还要记录我上一个返回的是哪个位置这样删除的时候才能避开前移导致的索引错位。class MyContainer: def __init__(self): self._items [] def add(self, item): self._items.append(item) def __iter__(self): return MyIterator(self) class MyIterator: def __init__(self, container): self._container container self._index 0 self._last_index -1 def __next__(self): if self._index len(self._container._items): raise StopIteration item self._container._items[self._index] self._last_index self._index self._index 1 return item def remove_current(self): if self._last_index 0: raise RuntimeError(必须先调用 next 再 remove) self._container._items.pop(self._last_index) self._index self._last_index self._last_index -1使用起来像这样c MyContainer() for i in range(1, 6): c.add(i) it iter(c) try: while True: x next(it) if x 2: it.remove_current() print(x) except StopIteration: pass print(c._items) # [1, 2]注意remove_current里那两行pop(self._last_index)删除当前返回元素后后面的元素会前移一格原本位置_last_index 1的元素现在跑到了_last_index上。所以把_index重新设回_last_index下一次next就能正好读取那个偏移过来的元素不会跳过。这也是Java设计Iterator.remove()时的统一语义remove只能跟在next()之后调用删除的必须是你刚刚取到过的那个元素。这个限制不是多余的它是保证迭代器游标不脱轨的关键。3.3 Java里的fail-fast机制与ConcurrentModificationExceptionJava里很多人一遇到ConcurrentModificationException就懵。其实它和并发没有必然关系同一个线程也能触发。标准场景就是一边用迭代器遍历一边通过集合自身的remove方法修改结构。ListInteger list new ArrayList(Arrays.asList(1, 2, 3, 4, 5)); IteratorInteger it list.iterator(); while (it.hasNext()) { Integer x it.next(); if (x 2) { list.remove(x); // 这可不行 } }运行到第二次循环时迭代器内部会检查modCount。modCount是集合内部维护的修改次数标记每次结构发生变化都会自增。迭代器创建时保存了一个期望的modCount每次next之前比对发现不一致就立刻抛出ConcurrentModificationException。这种机制叫fail-fast意思是系统宁可立刻失败也不愿意在错误的状态下静默继续。正确写法是把list.remove(x)换成it.remove()让迭代器自己同步维护modCount。Python里也类似遍历字典时直接删除key会抛RuntimeError: dictionary changed size during iteration标准的做法是先收集要删的key循环结束后统一删或者用字典推导式生成新字典。踩过几次坑之后你就会明白一个小规律任何遍历中修改集合的问题本质上都要求你先想清楚语义——你是希望跳过被删元素之后的那个元素还是希望删除后原地继续。想清楚了再选择用迭代器自带的remove还是先收集再删除。别把锅甩给迭代器模式迭代器恰恰是为了让这种修改变得可控才存在的。4. 二叉树也能翻书用迭代器把前序和层序遍历变成可暂停的进度条数组、链表都是线性结构用迭代器很自然。但树是分叉的还能用迭代器吗答案是能而且特别有价值。很多人学数据结构时写递归遍历写得很爽但递归有一个先天缺陷你不能在遍历到一半停下来让外部代码慢慢消费剩下的部分。递归一旦开始就是一路到底。而迭代器恰恰擅长这件事——它把当前走到哪了保存在迭代器对象里任何时刻都可以暂停也可以继续。这就像翻书你完全可以在第100页停下来干点别的事三天后再从第100页接着翻而不是每次都从第1页重新来。4.1 前序遍历迭代器用一个栈模拟递归调用栈先看二叉树的前序遍历。递归版本大家都会def preorder_recursive(node): if node is None: return print(node.val) preorder_recursive(node.left) preorder_recursive(node.right)递归之所以能记住走哪了是因为系统帮你维护了一个调用栈。现在不递归了就自己用栈模拟这个过程class TreeNode: def __init__(self, val, leftNone, rightNone): self.val val self.left left self.right right def preorder_iter(root): stack [root] while stack: node stack.pop() if node is None: continue yield node.val stack.append(node.right) stack.append(node.left)这里先压右子节点再压左子节点弹出来的时候左子先出顺序就和递归版完全一致。yield是关键它把一个普通函数变成了惰性迭代器。调用方可以这样玩it preorder_iter(root) first next(it) # 只取第一个节点 second next(it) # 累了先取两个再说这在业务里非常有实用价值。比如树形菜单特别深你想找第一个符合条件的菜单找到就立刻返回不遍历整棵树。如果每次都用递归你只能先递归整棵树搜集所有节点再filter用迭代器则可以在第一个命中节点处直接break省去大量无效访问。4.2 层序遍历迭代器队列就是迭代器的内页夹层序遍历也就是常说的按层遍历在热词里反复出现。它和前序的本质区别在于前序是深度优先需要栈来记忆回退点层序是广度优先天然需要一个队列来记住下一层有哪些节点还没被访问。只要把队列封装进迭代器层序遍历也能变成一次可翻页的过程。from collections import deque def level_order_iter(root): if root is None: return queue deque([root]) while queue: node queue.popleft() yield node.val if node.left: queue.append(node.left) if node.right: queue.append(node.right)第一次next弹出根节点同时把左右子树放进队列第二次next从队列里弹出树的第二层左节点第三、第四次next依次拿到第二层右节点和第三层节点。迭代器对象内部始终装着那个还没处理完的队列这就是它的当前位置状态。用起来可以做到一次取一层也可以一次取一个。比如我把一棵权限树按层序输出成报表时层序遍历迭代器配合yield每调用一次就生成一条记录前端拿到一条渲染一条。这种按需生产数据的体验递归函数完全给不了。前序和中序遍历的迭代器写法其实都是同一个套路自己维护一个栈来模拟系统调用栈。层序遍历则把栈换成队列。只要你理解了深度优先用栈、广度优先用队列之后不管遇到多复杂的树结构都能把它变成翻书式的连续访问。5. 边界与陷阱迭代器不是万能遍历器讲了这么多迭代器的好处也该泼点冷水。它确实能解决很多问题但如果你不分场合一律强行套用代码不会变好只会变得更绕。下面这几个边界情况我几乎都在真实项目中踩过。5.1 Fail-Fast还是Fail-Safe前面说了Java的ArrayList是fail-fast一检测到并发修改就抛异常。但Java里还有一类叫fail-safe的容器典型代表是CopyOnWriteArrayList。它的做法是迭代器创建时拿到的是当前数组的一个快照之后你修改原列表迭代器继续读旧快照不会抛异常也不受修改影响。这两种机制没有绝对的对错完全取决于业务语义。如果你希望遍历过程中修改集合能被立刻发现避免脏数据扩散fail-fast是合理的选择。如果你更看重读操作不被打断愿意接受遍历的可能是一份旧数据,fail-safe更合适。做技术选型时心里要清楚自己用的是哪一种否则会被隐蔽的旧快照坑惨。Python里有个非常常见的经验遍历字典时如果要在循环里删除满足条件的key不要在循环体里直接del。先收集要删除的key循环结束后统一删d {a: 1, b: 2, c: 3} to_delete [k for k, v in d.items() if v 2] for k in to_delete: del d[k]更地道的做法是直接用字典推导式重建一个字典。这看起来像是绕开了迭代器模式实际上它选了一个更符合场景的替代方案当整批筛选比逐个消费更贴合需求时就不要纠结迭代器怎么边删边走。5.2 外部迭代与内部迭代的取舍迭代器模式属于外部迭代由调用方主动调用hasNext()和next()每一步都由你控制。现代语言里大量使用的forEach、map、filter则是内部迭代集合对象自己负责遍历把你的回调函数应用到每个元素上。外部迭代和内部迭代的差别可以类比自助餐和套餐。外部迭代是你自己端着盘子想吃哪个窗口吃哪个窗口随时可以停下来不吃内部迭代是厨师把整套流程安排好了你只需要坐等结果。维度外部迭代内部迭代控制力可随时break可多个迭代器并行回调函数内跑完整轮中途难中断性能有迭代器对象和方法的调用开销语言和运行时可以做更多批量优化表达能力适合需要复杂分支、跨步移动的场景适合筛选、映射、聚合的链式表达典型实现Iterator、yield生成器forEach、map、filter、Stream如果你只想做一个简单映射把每个对象拿到某个字段那直接map一行的可读性远好于手写迭代器循环。迭代器模式的真正价值是在你需要每次拿一个元素按自己的节奏处理还可能中途停止的场景。硬把所有遍历都改成迭代器反而会制造一堆没必要的模板代码。5.3 无限迭代器与惰性求值迭代器还有一个常规for循环给不了的能力无限迭代。只要不在循环里写明确的退出条件生成器可以无限提供元素由调用方决定取多少个。def fibonacci(): a, b 0, 1 while True: yield a a, b b, a b fib fibonacci() for _ in range(8): print(next(fib))这段代码永远不需要知道序列的上界在哪里。说白了它是按需生产而不是先造好再消费。这正是迭代器模式在设计思想上最锋利的地方位置状态完全封装在迭代器内部数据的生产与消费被彻底解耦。但无限迭代器也最容易出事。如果你习惯性地写一个while (iterator.hasNext())然后没有break那就会变成死循环。使用无限迭代器时一定要清楚地设置边界比如用range限制次数或者用take等工具截断。这一类边界控制是迭代器模式的最后一课它把能力交到你手里但责任也同时交给你了。我最后再分享一点个人实操中的体会。刚接触迭代器模式那阵子我总想着给每个类都加一个iterator()方法后来发现很多场景根本用不上——你既不需要多个游标并存也不需要半路暂停简单for循环反而更直白。真正让我体会到它价值的是一次重构树形权限菜单的遍历模块。原来的递归函数一运行就到底测试时根本没有办法插进去验证细节改成迭代器之后我可以在取到每个节点时立刻做权限判断命中就直接不再取下一个测试也变得极其舒服。这个模式不是要把简单的事变复杂而是把选择权还给调用方。如果你在设计一个内部数据结构非常复杂的类我特别建议先用迭代器把类内部的结构藏起来。调用方看到的只是next和hasNext就像读者从来不需要关心书是怎么装订的。坚持这个习惯你会少改很多因为数据结构变动而要顺带调整遍历逻辑的无聊代码。
网站建设高端定制企业官网