ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

迭代器模式深度解析:从hasNext到Stream的遍历进化

迭代器模式深度解析:从hasNext到Stream的遍历进化 1. 为什么要单独设计一种遍历方式——从最原始的循环代码说起我在刚开始接触设计模式的时候有一个很真实的困惑迭代器模式看起来太简单了不就是写个hasNext()加next()吗这个模式真的值得单独占一篇来讲吗直到后来做项目时遇到一个特别痛的问题我才明白这个模式被严重低估了。当时我需要遍历一个自定义的多边形顶点集合、一个从数据库查出来的结果集、还有一个 UI 上的菜单树。三个数据结构完全不同但遍历逻辑都要写。第一版代码我图省事直接在每个使用方写循环结果就是同一个屏幕上出现了三四段长得差不多、细节又完全不同的手动遍历代码// 遍历顶点集合用的是数组下标 for (int i 0; i polygon.vertexCount(); i) { Vertex v polygon.vertexAt(i); drawLine(v); } // 遍历数据库结果集用的是游标 while (rs.next()) { Record record mapRow(rs); process(record); } // 遍历菜单树用的是递归栈 StackMenuItem stack new Stack(); stack.push(root); while (!stack.isEmpty()) { MenuItem item stack.pop(); render(item); item.getChildren().forEach(stack::push); }这段代码最大的问题不是重复而是每次遍历的写法都和数据结构本身深度绑定。当我后来把顶点集合从数组换成链表、把结果集换成内存分页对象、把菜单树改成扁平列表时所有调用方的代码全部要改一遍。更麻烦的是这三种数据结构对外暴露的访问方式完全不一样使用方必须知道你是数组所以用下标、你是游标所以用 while、你是树所以用栈这种认知负担非常重。如果有朋友当时问我为什么需要迭代器模式我一句话就能回答它把怎么拿到下一个元素这件事从使用方手里抽走了。使用方不再需要关心对面到底是个数组、链表、游标还是树只需要知道三件事——有没有下一个、给我下一个、完事了告诉我遍历结束。数据结构的内部细节被一堵墙挡住了墙的名字就叫 Iterator。更深一层这其实是单一职责原则的体现。集合类原本要管两件事一是存数据添加、删除、查找二是给人遍历数据。如果遍历逻辑直接写在集合类里集合类会越来越臃肿而且每种遍历方式正序、倒序、跳跃遍历都得塞进同一个类里改一个就可能影响另一个。迭代器模式把遍历这个职责单独拆出来让集合只关心我存了哪些东西让迭代器只关心我怎么把这些东西一个个交出去。这和现实里仓库只管存货、快递员只管送货是一个道理两边职责清晰出问题也好排查。所以这一章我想先把为什么这个问题彻底讲透。没有这个动机做铺垫后面所有代码都只是干巴巴的接口定义你背完了也记不住。2. 迭代器模式的核心角色拆解——谁在干活谁在收保护费设计模式的书里迭代器模式一般会画出四个角色Iterator迭代器接口、ConcreteIterator具体迭代器、Aggregate聚合对象接口、ConcreteAggregate具体聚合对象。名词很枯燥但拆开看其实特别简单。我用一个音乐播放器的歌单来举例。歌单 SongList 是一个聚合对象它的职责是存放歌曲。而遍历歌单这件事交给一个叫 PlaylistIterator 的迭代器来做。客户端比如 UI 界面不直接碰歌单的内部数组它只会跟迭代器说下一首是什么是的就这一句话。2.1 迭代器接口的两个关键方法为什么必须成对出现所有迭代器接口核心方法其实就两个hasNext()和next()。有些语言里叫moveNext()加current有些叫length加get(index)但思路都一样。为什么这两个方法必须成对存在而不是只留一个next()直接返回元素原因很简单调用方需要在拿元素之前先知道还拿不拿得到。如果不提供hasNext()调用方只能赌赌集合里面还有元素然后调next()去取。取不到就抛异常或者返回 null。这种写法在循环次数不确定的场景下就是埋雷。而hasNext()的存在把有没有下一个变成了一个可以提前预判的问题遍历的终止条件不需要靠异常来兜底。这就是为什么我一直建议不管用什么语言实现迭代器这两个方法一定最好遵循这样的约定别的都可以省它俩不能拆。public interface IteratorE { /** 判断是否还有下一个元素不移动指针 */ boolean hasNext(); /** 返回当前元素并将指针后移 */ E next(); }注意注释里第二句的指针后移非常重要。一个常见的理解误区是next()只是读一下当前位置读完不动。实际上next()是读 挪两步合并的操作而hasNext()绝不移动指针、只是看一眼。如果搞混了这两个语义很容易写出死循环或者漏元素的代码。后面讲坑的时候我会专门再提。2.2 用歌单的例子把四个角色串起来我手写一个最小可运行的 Java 例子把四个角色一次说清楚。首先是聚合接口和具体聚合对象public interface SongAggregate { IteratorSong createIterator(); } public class SongList implements SongAggregate { private final ListSong songs new ArrayList(); public void add(Song song) { songs.add(song); } public int size() { return songs.size(); } public Song get(int index) { return songs.get(index); } Override public IteratorSong createIterator() { return new SongIterator(this); } }然后是迭代器接口和具体迭代器public class SongIterator implements IteratorSong { private final SongList list; private int position 0; public SongIterator(SongList list) { this.list list; } Override public boolean hasNext() { return position list.size(); } Override public Song next() { return list.get(position); } }客户端使用的时候完全不知道 SongList 内部是数组、ArrayList 还是别的什么结构SongList playList new SongList(); playList.add(new Song(晴天, 周杰伦)); playList.add(new Song(夜曲, 周杰伦)); IteratorSong it playList.createIterator(); while (it.hasNext()) { Song song it.next(); System.out.println(song.getTitle() - song.getSinger()); }这段代码里使用方唯一的依赖就是IteratorSong这个接口。任务来来回回就两个hasNext()判断next()取。这就是整个模式的核心。2.3 为什么我说具体迭代器里封装变化比实现接口更重要很多初学者写迭代器的时候容易把注意力全放在我实现了 Iterator 接口这件事上却忽略了一个更关键的点具体迭代器是这个模式里唯一允许知道集合内部结构的角色。比如SongIterator知道 SongList 有自己的songs列表知道它有size()和get(index)。这对迭代器来说完全没问题因为它本身就是为这个集合量身定做的。真正需要隔离的是客户端客户端不能知道这些。这个边界非常微妙很多人会把集合内部实现细节一路漏到客户端去那迭代器就白写了。如果你有多个遍历需求比如歌单要支持按添加顺序遍历、按歌手过滤遍历、按播放次数排序遍历那就多写几个具体迭代器类每新增一个遍历规则就新增一个迭代器而不是去改 SongList 本身。这就是对扩展开放、对修改关闭的活例子。聚合对象永远只提供稳定的createIterator()具体怎么遍历由迭代器自己决定。3. 从 Java 到 C 再到 Python——三种主流生态里的迭代器长相完全不同迭代器模式最大的魅力就是它并不强迫你非得按教科书写一套Iterator接口。不同语言根据自己的语法特性把这个模式演变出了完全不同的方言。这一部分我挑三个最典型的主流生态来对比理解了它们的差异你对这个模式的理解才不会停留在背接口层面。3.1 JavaIterable 与 Iterator 分离增强 for 循环只是个语法糖Java 里迭代器模式是半强制的。集合框架的所有核心类都实现了IterableT接口而Iterable的核心方法就一个iterator()返回一个IteratorT。如果你写过for (String s : list) { System.out.println(s); }那么请记住这个增强 for 循环在编译之后本质上会被翻译成类似下面这段代码IteratorString it list.iterator(); while (it.hasNext()) { String s it.next(); System.out.println(s); }换句话说你每天都在用迭代器模式只是你自己没意识到。这也是我常跟朋友说的一句话很多设计模式不是要不要用的问题而是你已经在用了只是没认出来的问题。Java 这边要注意的点是如果你自己的类实现了IterableT那么能不能用增强 for 循环直接取决于你有没有提供iterator()方法。所以在自己设计类的时候别只写一个createIterator()就完事了直接让它实现IterableT体验完全不同。3.2 CSTL 迭代器是柔性指针begin/end 才是遍历的关键C 的迭代器理念和 Java 差别非常大。Java 的迭代器是对象里面有hasNext()这种语义方法C 的迭代器更接近指针它的操作是、*、!最常见的一对函数是begin()和end()。#include vector #include iostream std::vectorint nums {1, 2, 3, 4, 5}; for (auto it nums.begin(); it ! nums.end(); it) { std::cout *it std::endl; }这里begin()指向第一个元素end()指向最后一个元素的后一个位置也就是尾后位置并不真实存在的元素。用it ! nums.end()作为循环条件等价于 Java 的it.hasNext()用*it取值等价于 Java 的it.next()用it移动指针则是 Java 里next()帮忙做的事被拆了出来。这套设计最核心的哲学是迭代器与容器是解耦的。同一个std::vectorint你既可以用begin()/end()正向遍历也可以用rbegin()/rend()反向遍历任何一个容器类型只要能提供这四组函数的语义就能用完全一致的循环代码去遍历。C 因为语言本身没有垃圾回收和统一的接口约束所以迭代器的开销能做到极低这也是为什么 STL 算法库能长时间高效运行的原因之一。3.3 Python鸭子类型的迭代协议生成器才是精髓Python 的迭代器模式则走了一条完全不同的路。它不强制你实现某个抽象基类而是靠行为约定来实现——只要你的对象有__iter__()方法并且这个方法返回的对象有__next__()方法那它就是可迭代的。如果你用for循环遍历它Python 解释器会在内部自动帮你调用这两套魔法方法。class Playlist: def __init__(self): self.songs [] def __iter__(self): return PlaylistIterator(self) class PlaylistIterator: def __init__(self, playlist): self.playlist playlist self.index 0 def __next__(self): if self.index len(self.playlist.songs): raise StopIteration song self.playlist.songs[self.index] self.index 1 return songPython 里更常见、也更推荐的做法是用生成器因为生成器本身就是一种迭代器def song_generator(playlist): for song in playlist.songs: yield song这里yield关键字让函数变成了一个懒执行的迭代器——每次for循环向下取一个值的时候函数才从上一次的yield处继续执行而不是一次性把列表全部算完。这种懒特性其实是迭代器模式的极致形态你不必知道我全部的数据你每次都只拿一个我算一个给你一个。数据量大到内存装不下的时候这种懒执行几乎是唯一的解法。3.4 三种实现生态的对比一览为了方便记忆我把三种语言的核心差异整理成一张表。你在面试或者做技术方案对比时可以直接把这张表搬出来讲。对比维度JavaCPython接口形态IterableTIteratorT迭代器泛型类型 begin()/end()__iter__()__next__()协议取下一个的关键操作iterator.next()*it再itnext(iterator)判断结束方式hasNext()返回 false迭代器等于end()抛出StopIteration异常是否强制接口约束半强制集合适配模板约束编译期匹配完全鸭子类型运行时看方法遍历失败的兜底方法返回布尔值安全需自行保证不越界靠异常来终止循环性能取向对象调用有一定的装箱和动态分派成本接近原生指针性能最极致灵活度高但不追求极致性能这张表最值得记住的一点是同样是完成遍历集合这一个目标三种语言选择的边界和处理方式各不相同。Java 把有没有和取什么拆成两个方法C 把取和移动分开Python 则用异常来充当到底了的信号。没有绝对正确的做法只有和语言生态最匹配的做法。4. 真正写业务代码时才会暴露的坑——遍历时删除、并发修改与迭代器失效问题教科书只教你写一个从前往后挨个读的迭代器永远不会告诉你真正的生产环境里遍历这件事有特别多变态场景。这里我挑三个我自己踩过、也帮别人排查过的坑每一个都能让线上代码瞬间崩掉或者跑出奇怪的结果。4.1 边遍历边删除元素的经典崩溃现场最常见的坑就是在遍历一个集合的过程中删除元素。比如你有这样一个需求一个在线名单要把所有名字以测试开头的条目删掉。新手第一版代码长这样ListString names new ArrayList(List.of(测试1, 张三, 测试2, 李四)); for (String name : names) { if (name.startsWith(测试)) { names.remove(name); } }运行一下大概率直接抛出ConcurrentModificationException。原因在于增强 for 循环背后隐藏着一个迭代器这个迭代器在被创建时会记下集合的modCount修改计数器。每次next()被调用之前迭代器都会检查modCount有没有变变了就认为有人在背后偷偷修改集合立刻抛异常保护现场。这个设计叫快速失败机制意思是一旦发现迭代器和集合状态不一致宁可立刻终止也不要把错误状态继续带下去。这个异常不是 bug是保护机制在正常工作。正确的写法是用迭代器自己的remove()方法IteratorString it names.iterator(); while (it.hasNext()) { String name it.next(); if (name.startsWith(测试)) { it.remove(); // 删除的是刚刚 next 返回的那个元素 } }关键点在于it.remove()会把本次删除同步给这个迭代器内部维护的状态迭代器知道这个删除是自己干的所以不会误判为外部修改。而names.remove(name)是集合直接删迭代器完全不知情。4.2 两个迭代器互相嵌套遍历同一个集合千万别用同一个迭代器第二个坑是嵌套遍历。比如你要对歌单里的每首歌再和其他所有歌做一次配对那代码如下IteratorSong outer songList.listIterator(); while (outer.hasNext()) { Song a outer.next(); IteratorSong inner songList.listIterator(); // 重新创建一个迭代器 while (inner.hasNext()) { Song b inner.next(); // 配对处理 } }这段代码是安全且正确的因为它创建了两个独立的迭代器对象各自维护各自的position。很多新手会误写成IteratorSong inner outer;试图一个迭代器搞定内外两层循环结果外层outer.next()一移动内层也受影响配对逻辑直接乱套。请记住迭代器是有状态的独立对象每个迭代器维护的指针是分开的不能共享。如果你要做嵌套遍历内层一定要重新调用listIterator()或者iterator()来创建新迭代器。4.3 自定义迭代器的边界条件——next()越界和空值判断第三个坑是自定义迭代器时很容易出错的边界问题。比如你写了一个迭代器内部的position从 0 开始hasNext()判断position list.size()看起来没问题。但是如果有人在循环体里把position改了或者在多线程环境里另一个线程往 list 里删了一条数据那next()在调用list.get(position)时就会越界抛出IndexOutOfBoundsException。写自定义迭代器时一个稳妥的自保做法是在next()里也做一次越界检查Override public Song next() { if (!hasNext()) { throw new NoSuchElementException(没有更多歌曲了); } return list.get(position); }这样即使调用方忘掉hasNext()判断至少不会直接抛数组越界这种让人摸不着头脑的异常而是得到一个语义清晰的NoSuchElementException。这种接口边界做防御性检查的思路所有自定义迭代器都该加上。我见过太多只写next()不写健壮性判断的迭代器上线后第一个访问越界问题就把人搞得焦头烂额。另外一个容易被忽略的点是迭代器里的next()返回的元素能否为 null。Java 的ArrayList允许存 null但很多业务迭代器实现的过滤逻辑会把 null 跳过导致hasNext()返回 true、next()却返回 null 的奇怪状态。调用方如果没做判空后续代码直接空指针。如果你在设计一个不允许 null 元素的集合最好在迭代器里同样不允许 null 通过保持语义一致。5. 迭代器不只是用来遍历——与其他设计模式的组合实战如果你觉得迭代器模式就是一个花架子遍历接口那就太可惜了。它真正的价值是在和其他模式组合的时候迸发出来的。这一章我挑三个实战中最常见的组合方式每个都附上场景说明和代码示意。5.1 与组合模式配合用迭代器统一整棵树和一片叶子组合模式用于处理树形结构比如公司组织架构、文件目录、UI 菜单树。树的典型特征是一个节点可能包含子节点也可能是叶子节点。如果你想遍历整棵树传统写法是每个客户端都写递归。但如果在组合模式之上叠加迭代器模式事情就变得优雅很多。假设你有这样一个菜单节点接口public interface MenuComponent { String getName(); boolean isLeaf(); ListMenuComponent getChildren(); }你可以写一个TreeIterator让它实现IteratorMenuComponent内部用一个栈来模拟深度优先遍历public class TreeIterator implements IteratorMenuComponent { private final DequeMenuComponent stack new ArrayDeque(); public TreeIterator(MenuComponent root) { if (root ! null) { stack.push(root); } } Override public boolean hasNext() { return !stack.isEmpty(); } Override public MenuComponent next() { MenuComponent node stack.pop(); if (!node.isLeaf()) { for (MenuComponent child : node.getChildren()) { stack.push(child); } } return node; } }把这个迭代器挂在树的根节点上客户端遍历整棵树只需要IteratorMenuComponent it root.createIterator(); while (it.hasNext()) { MenuComponent item it.next(); // 渲染菜单项 }更有趣的是如果你树的实现从每个节点都存子节点改成子节点从数据库懒加载你只需要改TreeIterator和getChildren()的实现客户端代码一行都不动。这就是组合模式负责结构长什么样、迭代器模式负责结点怎么被消费的分工。5.2 与工厂模式配合用工厂方法隐藏具体迭代器的创建过程在标准迭代器模式里聚合对象总是要自己new一个具体迭代器比如前面歌单例子里的return new SongIterator(this)。但如果你的集合有多种迭代方式比如正序迭代器反序迭代器按歌手过滤迭代器每个都直接在聚合对象里写new聚合对象的代码就会越来越乱。更优雅的做法是引入工厂方法。聚合对象不直接依赖具体的迭代器类只依赖一个迭代器创建的工厂接口具体是正序还是反序由工厂决定public interface IteratorFactoryT { IteratorT createIterator(T[] items); }聚合对象持有一个IteratorFactory需要迭代器时调用工厂去拿。这套组合的好处是新增加一种遍历方式时不需要改动聚合对象只需要新写一个具体工厂类。完全符合对扩展开放、对修改关闭的原则。设计模式本来就是相互叠加的普通项目里单独只用一个模式反而少见。5.3 与访问者模式配合遍历和动作彻底解耦想换动作就换访问者极客一点的做法是迭代器 访问者模式。迭代器负责把所有元素找出来访问者负责对每个元素做不同操作。这就像生产线上的传送带迭代器把每个工件送到工人面前工人访问者决定是喷漆、质检还是包装。以歌单为例如果你想统计歌单总时长、又想导出歌单为文本传统做法是给每个操作单独写遍历逻辑。而用 迭代器 访问者 的写法遍历逻辑只写一次动作一换就完事public interface SongVisitor { void visit(Song song); } public class DurationVisitor implements SongVisitor { private int totalSeconds 0; Override public void visit(Song song) { totalSeconds song.getDurationSeconds(); } } public class ExportVisitor implements SongVisitor { Override public void visit(Song song) { // 生成一行文本 } }客户端把迭代器和访问者拼接起来void walkSongs(SongList list, SongVisitor visitor) { IteratorSong it list.createIterator(); while (it.hasNext()) { visitor.visit(it.next()); } }以后不管歌单里加多少类新操作你只需要新增一个访问者类遍历代码一行不改。这就是迭代负责广度、访问负责深度的组合哲学。5.4 现实框架里的迭代器模式为什么 Java Stream 和 Kotlin Sequence 这么像迭代器还有一个非常前沿的迭代器进化形态值得单独说一说Java 8 的 Stream 和 Kotlin 的 Sequence。它们本质上是外部迭代到内部迭代的升级。传统迭代器模式里客户端主动调用hasNext()和next()叫外部迭代。调用方控制节奏灵活但代码啰嗦。Java Stream 的map()、filter()、forEach()叫内部迭代。调用方只描述要做什么遍历的节奏由 Stream 内部控制代码更简洁还方便做懒计算和并行优化。playList.getSongs().stream() .filter(s - s.getSinger().equals(周杰伦)) .map(Song::getTitle) .forEach(System.out::println);如果你理解了这个演化链路就能明白迭代器模式的真正价值不在于那个接口本身而在于它奠定了一种数据生产和数据消费分离的思想。Stream、Sequence、生成器都是这个思想在不同语言生态里的衍生物。面试时如果你能把这段从 Iterator 到 Stream 的演进逻辑讲清楚比单纯背模式定义要加分得多。最后再说一点实战体会写了这么多年代码我越来越觉得迭代器模式是一个平时感知不到、遇难题才想起的模式。它不像工厂模式那样出场自带光环也不像单例模式那样天天被讨论但它确实是所有集合类框架的基石。你每天用的 for 循环、SQL 查询游标、前端遍历 DOM 节点流、甚至大文件逐行读取背后全都有迭代器的影子。我个人在实际项目里的建议是每当你的代码里出现我需要遍历一个自定义结构的需求时先别急着写循环想想这个结构以后会不会换内部实现遍历方式会不会有多种。如果答案是可能那花 20 分钟写一个迭代器接口比未来改 10 个调用方的痛苦要值得多。如果是那种永远不会变的简单数组那用 for 循环就挺好设计模式不是为了炫技而存在的它是为了对抗未知的变化。最后分享一个小技巧我自己迭代器写多了以后养成的习惯给迭代器的每个方法写清楚语义注释特别是那些移动指针和不移动指针的区别。因为在真正的项目里你的迭代器会被很多不熟悉内部实现的人调用一个含糊的next()注释可能让后来者白白排查好几个小时。代码写清楚比什么都重要。
返回列表