news 2026/9/29 20:15:25

像翻书一样遍历数据:迭代器模式详解与实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
像翻书一样遍历数据:迭代器模式详解与实战

写代码这些年,我越来越觉得,很多设计模式并没有想象中那么玄乎,它就是把日常处理事务的自然逻辑提炼成了规矩。就拿标题里这个“像翻书一样遍历数据”来说——你读一本书,从来不会把整本书倒出来一页一页摆满桌子,只会按顺序从第1页往后翻,翻到哪就是哪,再用书签夹住当前位置。迭代器模式(Iterator Pattern)干的就是这件小事:把“遍历一个数据集合”这个动作,抽象成“翻一页看一页”,让调用方不需要关心背后的数据到底是数组、链表、树,还是数据库游标,也不需要知道数据集总共多大、元素存在哪个内存角落。只要拿一个迭代器,hasNext()问一句“还有没有下一页”,next()翻过去取出内容,就完事了。

这篇文章要讲清楚三件事:迭代器模式为什么值得学、怎么从零手写一个可用的迭代器,以及真实项目中遍历数据时最容易踩的坑。它适合刚接触设计模式、想把for循环写得更有章法的开发者,也适合准备面试时总被追问“手写迭代器,说一下快速失败机制”的老手。我会把思路、代码、坑位都一并抖出来,争取看完就能直接上手用。

1. 迭代器模式的设计思路与核心角色拆解

1.1 没有迭代器时我们经历过的痛苦

先回忆一个很常见的场景:你负责维护一个图书管理系统,内部的数据结构一开始用的是ArrayList,于是你满脑子都是for i加get(i)。某天需求变了,要换成LinkedList,或者要换成一个按书名排序的自定义树结构,这时候你会发现,所有依赖“按索引访问”的代码全部要跟着改。

这就是没有迭代器时的第一个痛苦:调用方和数据结构强耦合。业务代码被迫知道容器内部用的是数组还是链表,一旦容器换实现,业务代码也要跟着返工。

第二个痛苦是遍历逻辑散落得到处都是。每个业务方法里都写着自己的循环体,循环的判断条件、游标的进退规则各不相同。有人从0开始,有人从1开始,有人用while,有人用递归,时间一长,代码里全是风格迥异的遍历方式,别人接手的时候只能一头雾水地猜。

第三个痛苦是封装被破坏。如果你给外部提供的是一个public List<Book> getBooks()方法,等于把整个内部存储全部交了出去。调用方可以随意增删改,容器自己完全控制不住数据完整性。可如果不提供内部结构,调用方又没有办法拿到数据,最后只能往类里堆一大堆getBookAt(int index)、getSize()之类的访问方法,类越来越臃肿,职责越来越混乱。

1.2 四个核心角色以及它们各自的职责

迭代器模式把这个问题拆得非常干净,一共只有四个角色:

  • Iterator(迭代器接口):约定两个基本能力——hasNext()判断是否还有下一个元素,next()返回下一个元素。有些语言还会加一个remove(),但我会在后面的章节详细说,这个remove()其实是个麻烦制造者。
  • ConcreteIterator(具体迭代器):真正记录“当前翻到第几页”的地方。它的内部通常持有一个游标index,如果是链表结构,就可能持有当前节点引用。
  • Aggregate(聚合接口):声明一个返回迭代器的方法,通常叫iterator()或者createIterator()。它代表“这本书可以翻”。
  • ConcreteAggregate(具体聚合类):真正存储数据的地方,比如书架、列表、树。它负责创建出一个对自己的数据集合有感知的具体迭代器。

这四个角色在翻书类比里对应得明明白白:书就是聚合类,书签就是迭代器里的游标状态,翻页的动作就是next(),判断还有没有下一页就是hasNext()。

1.3 为什么“像翻书”这个类比如此贴切

我特别喜欢标题里这个比喻,因为它一下抓住了迭代器模式的三个本质特征。

第一是顺序访问。翻书只能一页一页地看,你不需要荡气回肠地跳着读,迭代器也不承诺随机访问。它故意不给你get(103)这种能力,逼着你按顺序走完整个序列。这个限制恰恰是优点:调用方逻辑简单,迭代器实现也简单。

第二是状态可以共存。同一本书,你完全可以同时夹两个书签:一个在序言,一个在附录。迭代器模式也一样,同一个聚合对象可以同时存在多个迭代器,各自推进,互不干扰。这个特性在写嵌套循环、分页加载时极其有用。

第三是内部表示完全隐藏。你翻一本纸质书,根本不需要知道纸张是怎么造的,墨水是喷上去的还是印上去的。迭代器把“如何存储”“如何定位下一个元素”这些细节统统藏到了具体迭代器内部,调用方只依赖稳定的next()接口。

再说深一层,这个类比还能解释“为什么把遍历算法从容器里拆出来”的设计取舍。如果树的遍历方法直接写在树类里,前序、中序、后序、层序各写一个方法,树类很快会膨胀成一个无所不包的巨类。而用迭代器模式,每种遍历方式独立成一个类,按需创建,想用哪种就 new 哪种,容器和算法的职责一分为二,改起来互不影响。

2. 从零手写一个迭代器:完整实操过程

2.1 场景设定:一个可以翻的书架

理论说完了,直接上手写代码。我们构建一个最小的案例:一个BookShelf(书架),内部用ArrayList存书,外部可以通过迭代器按顺序遍历每一本书。这个案例虽然简单,但它能完整展示聚合类和迭代器类如何配合。

第一步,定义迭代器接口。在实际项目中,如果你用的是Java,直接用java.util.Iterator就行,但为了看清原理,我们先用自建的接口:

public interface Iterator<E> { boolean hasNext(); E next(); }

第二步,定义可迭代的聚合接口:

public interface Aggregate<E> { Iterator<E> iterator(); }

2.2 写一个ConcreteAggregate:BookShelf

BookShelf干两件事:负责存放书,负责创建迭代器。它内部到底用ArrayList还是数组,外部完全不需要知道。

public class BookShelf implements Aggregate<Book> { private List<Book> books = new ArrayList<>(); public void addBook(Book book) { books.add(book); } public int getSize() { return books.size(); } public Book getBookAt(int index) { return books.get(index); } @Override public Iterator<Book> iterator() { return new BookShelfIterator(this); } }

注意看,我在这里保留了getSize()和getBookAt(int index),这两个方法是用给迭代器用的。它们是聚合类开放的内部访问点,但调用方不需要直接碰它们,因为迭代器会屏蔽掉这些细节。

2.3 写一个ConcreteIterator:BookShelfIterator

这是整个模式最关键的部分,游标状态就藏在这里:

public class BookShelfIterator implements Iterator<Book> { private BookShelf bookShelf; private int index; public BookShelfIterator(BookShelf bookShelf) { this.bookShelf = bookShelf; this.index = 0; } @Override public boolean hasNext() { return index < bookShelf.getSize(); } @Override public Book next() { return bookShelf.getBookAt(index++); } }

这里有几个容易被忽略的细节:

index从0开始,hasNext()判断的是“当前游标是否还没有越过最后一位元素”。注意next()返回的是bookShelf.getBookAt(index++),也就是说,先取出当前游标位置的元素,再把游标往后移动一位。这个“先取后移”的顺序已经成了迭代器的事实标准。

如果调用方不检查hasNext()直接连续调用next(),当游标越界时,getBookAt(index)会抛出越界异常。所以在真正的迭代器实现里,一般会先做一次显式检查,发现没有下一个元素就直接抛出NoSuchElementException,而不是等到getBookAt那里抛一个语义不清晰的异常。这个习惯值得养成。

2.4 为什么“当前读到哪”不能放在聚合类里

很多初学者会疑惑:既然index只是记录读到哪一页,把它放在BookShelf里不行吗?反正书架总共也就一个。

答案是不行。想象一下,如果两个业务逻辑同时遍历同一个书架:一个正在读前50本统计平均价格,另一个想从第10本开始刷新封面缓存。如果把游标放在聚合类里,第二个遍历一开始就把第一个遍历的位置顶掉了,第一个循环瞬间错乱。

这就是“书签必须由读者自己带着”的道理。聚合类就像一本书,书本身不应该关心有多少人正在读、各自读到哪页了。每个迭代器自己保存一份独立的index,多路遍历才能并行不悖。这个设计点在整个模式里价值极高,值得反复体会。

2.5 用Python再写一遍,感受语言层面的差异

我们可以用Python实现同一个书架,对照理解:

class BookShelfIterator: def __init__(self, shelf): self._shelf = shelf self._index = 0 def __next__(self): if self._index >= len(self._shelf._books): raise StopIteration book = self._shelf._books[self._index] self._index += 1 return book class BookShelf: def __init__(self): self._books = [] def add_book(self, book): self._books.append(book) def __iter__(self): return BookShelfIterator(self)

调用方式:

shelf = BookShelf() shelf.add_book("设计模式") shelf.add_book("代码大全") for book in shelf: print(book)

注意Python这里的细微差别:Python的for循环会自动捕获StopIteration异常并停止遍历,迭代器只需要实现__next__即可。这个异常本质上是“没有下一页了”的显式信号。对比Java版本,两种语言一个用返回值判断、一个用异常终止,但模式骨架完全一致,这恰好说明迭代器模式的语言无关性。

3. 进阶实战:语言内置迭代器与复杂场景应用

3.1 Java的Iterable与增强for循环

实际项目里,很少有人会自己去实现java.util.Iterator接口的完整语义,大部分时候只需要让类实现Iterable<T>接口,就能直接用增强for:

public class BookShelf implements Iterable<Book> { // 省略存储细节 @Override public Iterator<Book> iterator() { return new BookShelfIterator(this); } }

然后调用方这样写:

for (Book book : bookShelf) { System.out.println(book.getName()); }

编译器会把这段语法糖展开成:

Iterator<Book> it = bookShelf.iterator(); while (it.hasNext()) { Book book = it.next(); System.out.println(book.getName()); }

也就是说,增强for循环不过是迭代器模式的语法封装。理解了底层原理,你在阅读反编译代码或者排查ConcurrentModificationException时,就能一眼看出循环展开后的真实逻辑。

3.2 Python生成器:编译器帮你写好了迭代器

Python里有一个更偷懒的大杀器:生成器(generator)。它让你用几乎“写普通函数”的姿势完成迭代器:

def book_gen(shelf): for book in shelf._books: yield book for book in book_gen(shelf): print(book)

yield关键字背后藏着一个完整的状态机:函数每次执行到yield就暂停,把当前局部变量、程序计数器全部保存起来;下一次调用next()时,从暂停点继续执行。你不需要手动管理index,也不需要手动判断边界,这些杂事全部交给了编译器生成的隐藏状态机。

这个特性和“看书先夹书签”的道理一模一样:你在某一页夹一个书签,回头再看时从这一页接着读,不用重新从第1页开始找。生成器的yield就是那个书签。

3.3 C#与JavaScript的迭代器对比

C#的迭代器实现更是把“惰性求值”玩到了极致。用yield return写的迭代器,本质上被编译器转换成了一个实现了IEnumerator<T>的状态机类,这个类的MoveNext()方法对应hasNext(),Current属性对应next()的返回值。C#里的LINQ之所以能实现链式惰性查询,底层靠的就是这套迭代器机制。

JavaScript则提供了显式的迭代器协议。一个对象只要部署了[Symbol.iterator]方法并返回next函数,就能被for...of循环消费:

const bookShelf = { books: [ { title: "设计模式" }, { title: "代码大全" } ], [Symbol.iterator]() { let index = 0; return { next: () => { if (index >= this.books.length) { return { done: true }; } return { done: false, value: this.books[index++] }; } }; } }; for (const book of bookShelf) { console.log(book.title); }

JavaScript这里的done字段和Python的StopIteration、Java的hasNext()本质上是同一件事的三种说法:告诉消费者“数据流已经走完了”。我把这几个语言的差异整理成一张表,方便对照:

语言迭代器接口/协议循环语法惰性求值生成器支持
JavaIterator<T>/Iterable<T>for (T t : coll)需自行设计无原生yield
Python__iter__/__next__for x in obj天然支持yield
C#IEnumerator<T>/IEnumerable<T>foreach (T t in coll)天然支持yield return
JavaScript[Symbol.iterator]协议for...of天然支持function*+yield

看完这张表你会发现,主流语言已经把迭代器模式内化成了语法一级的支持。但语法支持并不代表你可以不学原理,因为一旦遇到性能问题、并发修改,或者需要自己设计一种全新的遍历顺序时,你还是得回到模式本身去理解它。

3.4 迭代器在链表和树形结构上的应用

讲完了语言层面的差异,再讲两个最常见的复杂数据结构上的迭代器实现。

链表是迭代器最天然的适配对象。链表本来就不支持随机访问,你要找第i个节点,只能从头节点逐个next过去。迭代器内部直接保存当前节点引用,每次next()执行current = current.next,时间复杂度降到O(1)。很多刚写完链表就想用for i遍历的新手会撞得头破血流,其实就是还没适应这个道理:链表天然是“迭代器友好”的,绝不是“索引友好”的。

树形结构更有意思。前序遍历、中序遍历、后序遍历、层序遍历,本质上是四套完全不同的遍历策略。如果把它们全部堆在树类里,树类会变得又臭又长;如果用迭代器模式,每种遍历写成独立迭代器,按需取用。以层序遍历为例,迭代器内部持有一个队列,next()时从队头取节点,然后把它的左右孩子依次入队,外部调用方完全感知不到队列的存在,它只需要不停地next(),就能一层一层地把树读完。这是迭代器封装复杂状态的一个绝佳案例。

另外提醒一句:数据库游标、文件读取流、分页查询结果集,本质上都是迭代器思想。它们全都遵循同一个原则——一次只取一条,按需拉取,边读边丢,从而避免一次性把大量数据灌进内存。理解了迭代器模式,你会觉得这些技术背后的设计逻辑全都串起来了。

4. 常见问题与排查技巧实录

4.1 在Python的list遍历过程中删除元素,问题出在哪

热搜里有一条“python+list遍历删除”,这几乎是每个Python开发者都踩过的坑。很多人写过这样一段代码:

lst = [1, 2, 3, 4, 5] for item in lst: if item % 2 == 0: lst.remove(item)

执行完你会发现,列表压根没有按照预期删干净。原因在于:for循环底层用的是迭代器,迭代器内部维护着一个基于索引的游标。每删除一个元素,后面的所有元素集体前移,而游标还会继续往后走,于是紧跟在被删除元素后面的那一个就被直接跳过了。

老老实实看一遍执行细节:第2个元素被删除后,原来的第3个元素变成了新的第2个元素,但游标已经进到第3个位置,这个新元素的命运就是被跳过去。

解决方案有几种:

  • 反向遍历:for item in reversed(lst),从尾到头删除,前面的元素不会受影响;
  • 用列表推导式生成新列表:lst = [x for x in lst if x % 2 != 0],然后直接替换引用;
  • 用while循环手动控制索引;
  • 如果确实需要边遍历边删除且要求高效,借助collections.deque等专门的数据结构。

4.2 迭代器失效:集合变了,迭代器还拿着旧状态

Java用户对这个异常应该非常熟悉:ConcurrentModificationException。当你用迭代器遍历一个ArrayList时,如果另一个线程或者同一个循环体里直接调用了list.add()或list.remove(),迭代器在下一次操作时会立刻抛出这个异常。

这是因为ArrayList内部维护了一个modCount字段,记录结构被修改的次数。迭代器创建时会保存当时的modCount,每次next()之前都会检查一遍当前值是否仍然相等,不等就直接快速失败。这个机制叫“快速失败”(fail-fast),它的设计意图是:与其让你在数据错乱的环境里继续跑出错误结果,不如立刻告诉你“你对这个集合的结构做了非法修改”。

碰到这种情况,解决办法通常是四个方向:

  • 遍历时只读不写,把要删除的元素先收集到一个临时列表,遍历结束后统一删除;
  • 使用CopyOnWriteArrayList,它的迭代器是弱一致性的,允许在遍历期间修改结构;
  • 显式加锁,保证修改和遍历串行化;
  • 改用removeIf()这类容器自带的条件删除方法,内部会正确处理迭代器状态。

4.3 迭代器接口上该不该有remove方法

这算得上是一个设计争议点。java.util.Iterator本身提供了remove()方法,但它的语义限制非常严格:只能删除刚被next()返回的那个元素,而且在调用remove()之前必须先调用过一次next()。很多人根本不用它,因为它既容易写错,又容易把容器结构搞乱。

我的建议是:自己设计迭代器接口时,尽量不要把remove()放进去。原因很简单,迭代器模式的定位是“遍历”,不是“修改”。删除操作应该由聚合类本体提供明确的方法(比如removeById),而不是通过迭代器的后门摸进去。如果实在需要边遍历边删,现代语言提供的removeIf、列表推导式、Collectors.toList等方案都更安全、语义更清晰。

4.4 自定义容器忘了实现迭代器接口,会发生什么

这种问题在现实项目里太常见了。你写了一个OrderCollection,觉得内部用一个List存订单就够了,于是直接把getOrders()方法暴露出去,让外部自己遍历。一开始还行,直到某个调用方为了性能把List换成了自定义的链表结构,你的getOrders()被迫跟着改动,所有调用方的代码都要重新编译、重新测试。

反过来,如果一个自定义容器从一开始就实现了Iterable接口,那么不管是内部存数组、链表、树还是数据库游标,调用方永远只需要写for (Order order : collection)这一行。这个投资回报率极高,所以我强烈建议:凡是“装着一堆对象”的自定义类,第一步就要问自己“它需要被遍历吗”。需要,就先实现迭代器,再写其他业务逻辑。

4.5 几个容易让人当场懵掉的细节

整理几个我在代码评审里反复见到的问题:

  • hasNext()不会移动游标,next()才会。所以如果你在循环里不小心调用了两次next(),就会跳过元素。这个错误非常隐蔽,尤其在处理树结构时表现成“莫名其妙的漏数据”。
  • 每个迭代器的生命周期只属于一次遍历。遍历到末尾之后,这个迭代器就废了,想重新遍历,要再调一次iterator()拿新迭代器。这就像书签抽出来之后,再想从开头看就得重新夹一个。
  • 迭代器持有了大对象的引用,用完后不及时释放。遍历完成就把它丢掉,别一直攥在手里,否则可能造成不必要的内存驻留。
  • 嵌套遍历同一个集合时,内层循环尽量使用新迭代器。如果外层迭代器还在使用就被内层操作修改了集合结构,那又是一颗定时炸弹。

我自己的习惯是:写任何自定义集合类时,永远先把迭代器实现放进去,再写业务方法。这个顺序能倒逼我思考清楚这个类的边界和职责——它对外暴露什么、隐藏什么、允许多少种遍历方式,都一目了然。至于遇到特别复杂的遍历场景(比如树、图、嵌套结构),那就老老实实在纸上把状态机画清楚再动代码,先设计“书签夹在哪”,再设计“怎么翻页”,一次成型,后面省下的调试功夫远比你预想的多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 20:14:06

肿瘤免疫治疗:免疫编辑机制与治疗策略分类

简述 肿瘤免疫治疗通过激活机体免疫系统、增强抗肿瘤免疫应答&#xff0c;特异性清除肿瘤细胞并打破免疫耐受&#xff0c;已成为继手术、放疗和化疗之后的第四大肿瘤治疗技术。其核心逻辑在于克服肿瘤免疫逃逸机制&#xff0c;重新唤醒免疫细胞对癌细胞的识别与杀伤能力。本文系…

作者头像 李华
网站建设 2026/9/29 20:11:33

微信打卡小程序有哪些?免费工具亲测,2026实用避坑指南

核心导读&#xff1a;作为一线班主任和任课教师&#xff0c;每学期都需要高频发起各类班级打卡、接龙、报名、填表、通知下发、作业统计等事务。日常的作业完成统计、课文背诵打卡、课外阅读监督、安全教育回执、学平险报名登记等工作&#xff0c;都需要一款稳定、功能全面、操…

作者头像 李华
网站建设 2026/9/29 20:11:32

Codex定时任务怎么设置?ChatGPT Plus用Worktree自动巡检项目

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 20:09:44

[DeepSeek Harness深度拆解-17]如何使用DSH的配置服务

deepseek-ai/dsh-settings包提供settings服务是DSH中用于管理运行时配置的底层核心服务&#xff0c;专门负责系统参数的命名空间注册、分层解析、热更新持久化以及变更检测。deepseek-ai/dsh-settings-file进一步提供了基于文件的配置系统&#xff0c;本篇文章将通过几个简单的…

作者头像 李华