cv.cm

编码 · 设计模式

迭代器模式详解与示例

阅读约 6 分钟

简短回答

迭代器是一种行为型设计模式,它提供一种依次访问集合元素、而不暴露集合存储方式的办法。遍历逻辑放在单独的迭代器对象里,通过 next() 之类的方法驱动,因此同一个集合可以提供多种遍历顺序,客户端代码也能用同样的方式遍历数组、树和数据流。在 TypeScript 和 JavaScript 中,这一模式通过 Symbol.iterator 和 for...of 内置在语言里。

意图与它要解决的问题

意图:提供一种方法,顺序访问聚合对象中的各个元素,而又不暴露该对象的内部表示。迭代器是《设计模式》(Gang of Four,GoF)一书中的行为型模式之一。

设想一个把公司组织架构存成树的类。程序的不同部分都需要列出所有员工:一份报表按层级逐层输出,另一份需要深度优先顺序。如果每份报表都自己遍历这棵树,每份报表就都依赖树的内部字段,一旦把树改成别的存储方式,它们会全部出错。

有了迭代器,树会提供迭代器对象。报表只需要获取一个迭代器,并不断调用 next() 直到结束。报表并不知道数据是一棵树、一个数组,还是从数据库逐页取回的行。

结构与参与者

角色职责
迭代器遍历接口,通常是返回下一个元素的 next(),同时带有表示遍历是否结束的标志
具体迭代器实现对某一种集合的遍历,并记住当前位置
聚合(可迭代对象)带有创建迭代器方法的接口
具体聚合集合本身;每次被请求时返回一个新的具体迭代器
客户端通过迭代器访问元素,而不必了解集合的结构

在 JavaScript 中,这些角色有标准名称,由 ES2015 的迭代协议定义。可迭代对象(iterable)拥有 [Symbol.iterator]() 方法。迭代器(iterator)拥有返回 { value, done } 的 next() 方法。每一个 for...of 循环、展开语法 [...x] 和解构赋值都使用这些协议。

TypeScript 实现:手写迭代器

这个 range 类只保存起点、终点和步长,而不是一个数字列表。它的迭代器按需计算每个值。

class Range implements Iterable<number> {
  constructor(private start: number, private end: number, private step = 1) {}

  [Symbol.iterator](): Iterator<number> {
    let current = this.start;
    const { end, step } = this;
    return {
      next(): IteratorResult<number> {
        if (current >= end) return { value: undefined, done: true };
        const value = current;
        current += step;
        return { value, done: false };
      },
    };
  }
}

const evens = new Range(0, 10, 2);
for (const n of evens) console.log(n); // 0 2 4 6 8 (one per line)
console.log([...evens]); // [ 0, 2, 4, 6, 8 ]
console.log(Math.max(...new Range(1, 4))); // 3

每次调用 [Symbol.iterator]() 都会返回一个带有自己 current 位置的新迭代器,因此 evens 可以被循环两次。如果集合每次返回同一个迭代器,第二次循环就看不到任何元素。

生成器:用更少的代码写迭代器

用 function* 编写的生成器函数会自动创建迭代器。每个 yield 产出下一个值,函数随后暂停,直到再次调用 next()。下面的二叉树提供了中序遍历,而不暴露其节点:

class TreeNode<T> {
  constructor(public value: T, public left?: TreeNode<T>, public right?: TreeNode<T>) {}
}

class BinaryTree<T> implements Iterable<T> {
  constructor(private root?: TreeNode<T>) {}

  *[Symbol.iterator](): Generator<T> {
    yield* inOrder(this.root);
  }
}

function* inOrder<T>(node?: TreeNode<T>): Generator<T> {
  if (!node) return;
  yield* inOrder(node.left);
  yield node.value;
  yield* inOrder(node.right);
}

const tree = new BinaryTree(
  new TreeNode(5, new TreeNode(3, new TreeNode(1)), new TreeNode(8)),
);
console.log([...tree]); // [ 1, 3, 5, 8 ]

for (const value of tree) {
  if (value > 3) break; // stops early; the rest is never computed
  console.log(value); // 1, then 3
}

何时使用,何时不用

  • 当集合的内部结构复杂(树、图、分页 API),而你希望客户端能简单地循环访问时使用。
  • 当需要对同一份数据提供多种遍历顺序(例如深度优先和广度优先)时使用。
  • 用于惰性序列或无限序列,其中的值只在被请求时才计算。
  • 用它为不同的集合提供统一的循环接口。
  • 对已经是数组的数据,不要再写自定义迭代器。直接返回数组(或只读副本),让调用方使用内置的迭代方式即可。
  • 当调用方需要按索引随机访问或需要提前知道长度时,不要使用惰性迭代器;数组更合适。

实际应用

  • JavaScript:Array、Map、Set 和 String 都是可迭代对象,map.entries() 或 set.values() 返回迭代器。
  • Java:带有 hasNext() 和 next() 的 java.util.Iterator 接口,任何 Iterable 上的增强 for 循环都用它。
  • Python:由 __iter__ 和 __next__ 构成的迭代器协议,以及使用 yield 的生成器函数。
  • 数据库和 API:查询结果的游标和分页 HTTP API 每次只提供一页结果,客户端因此可以遍历非常大的结果集,而不必全部载入内存。

相关模式

模式与迭代器的关系
组合迭代器常用来遍历组合树,而不暴露其节点。
访问者迭代器负责遍历元素;访问者定义对每个元素做什么。两者经常配合使用。
工厂方法创建迭代器的方法(例如 [Symbol.iterator]())就是工厂方法:每个集合返回自己的迭代器类。
备忘录迭代器可以把当前位置保存为备忘录,以便之后恢复遍历。

常见问题

可迭代对象和迭代器有什么区别?

可迭代对象是能产生迭代器的对象,在 JavaScript 中通过 [Symbol.iterator]() 实现。迭代器是保存当前位置、并通过 next() 逐个返回元素的对象。

生成器是迭代器吗?

是。调用生成器函数会返回一个生成器对象,它既是迭代器(有 next()),也是可迭代对象([Symbol.iterator]() 返回它自身)。

为什么不直接暴露内部数组?

暴露数组会让每个调用方都绑定到这种存储方式,还能随意修改它。迭代器隐藏了结构,因此你以后可以更换存储,或惰性计算元素,而无需改动调用方。

什么是内部迭代器?

内部迭代器是接收回调并对每个元素执行它的方法,例如 array.forEach(fn),循环由集合控制。外部迭代器(如 next())则由客户端控制何时前进、何时停止。

全部编码教程