JavaScript 是目前最流行的编程语言之一,这一点在许多平台上都有所体现。然而,流行是否意味着它是最先进或最受喜爱的语言呢?它缺少一些在其他语言中被认为是核心特性的东西,比如丰富的标准库、不可变性和宏。但有一个细节,在我看来,没有得到足够的关注——那就是生成器(generators)。
在这篇文章中,我想解释一下迭代器(iterators)和生成器的可能使用场景,以及它们如何让你的代码更加简洁。希望读完这篇文章后,下面这段代码对你来说会变得清晰易懂:
while (true) {
const data = yield getNextChunk();
const processed = processData(data);
try {
yield sendProcessedData(processed);
showOkResult();
} catch (err) {
showError();
}
}
这是系列文章的第一部分:迭代器与生成器。
迭代器
首先,迭代器是一个提供顺序访问数据的接口。
你可能已经注意到,这个定义并没有提到数据结构或内存。事实上,一个由空值组成的序列也可以表示为迭代器,而不会占用任何内存空间。
让我们来看几个例子:
数组可能是你想到迭代器时第一个想到的东西。它是一种数据结构,将一系列值存储在内存中。它也是一个迭代器,因为它提供了对其元素的顺序访问。
const arr = [1, 2, 3];
for (const item of arr) {
console.log(item);
}
字符串也是如此。它们在内存中存储为字符序列,并提供对它们的顺序访问。
const str = 'abc';
for (const char of str) {
console.log(char);
}
再看下面这个函数例子:
const fn = () => Math.random();
这个函数也可以被视为一个迭代器,因为它提供了对随机数的顺序访问。
好吧,既然数组(作为语言的基本数据结构之一)允许我们以任意顺序访问数据,为什么我们还需要迭代器呢?
想象一下,如果我们需要一个实现自然数序列、斐波那契数列或其他无限序列的迭代器。将无限序列存储在数组中会非常困难。我们需要一种机制来逐步填充数组并删除旧数据,以防止填满整个进程的内存。这种不必要的复杂性增加了额外的实现和维护开销,而一个不使用数组的解决方案只需几行代码就能实现:
const getNaturalRow = () => {
let current = 0;
return () => ++current;
};
迭代器还可以用来表示从外部通道(如 WebSocket)检索的数据。
在 JavaScript 中,任何具有 next()
方法的对象都被视为迭代器。next()
方法返回一个包含 value
(当前迭代器的值)和 done
(表示序列结束的标志)的结构。这个约定在 ECMAScript 语言标准 中有描述。这样的对象实现了 Iterator 接口。让我们用这种格式重写前面的例子:
const getNaturalRow = () => {
let current = 0;
return {
next: () => ({ value: ++current, done: false }),
};
};
在 JavaScript 中,还有一个 Iterable 接口。它表示一个具有 @@iterator
方法的对象(可通过 Symbol.iterator
常量访问),该方法返回一个迭代器。实现此接口的对象可以使用 for..of
循环进行迭代。让我们再次重写我们的例子,这次作为 Iterable 的实现:
const naturalRowIterator = {
[Symbol.iterator]: () => ({
_current: 0,
next() { return {
value: ++this._current,
done: this._current > 3,
}},
}),
}
for (num of naturalRowIterator) {
console.log(num);
}
// 输出: 1, 2, 3
如你所见,我们不得不让 done
标志在某个时刻改变,否则循环将是无限的。
生成器
迭代器的下一个进化阶段是生成器的引入。它们提供了一种语法糖,使得迭代器的值可以像函数返回值一样被生成。生成器是一个用 function*
声明的函数,并返回一个迭代器。迭代器本身并不显式返回,而是通过 yield
关键字生成迭代器的值。当函数执行完毕时,迭代器被视为完成(后续的 next
方法调用将返回 { done: true, value: undefined }
)。
function* naturalRowGenerator() {
let current = 1;
while (current <= 3) {
yield current;
current++;
}
}
for (num of naturalRowGenerator()) {
console.log(num);
}
// 输出: 1, 2, 3
即使在这个简单的例子中,生成器的主要特点也很明显:生成器函数内部的代码不是同步执行的。生成器代码的执行是逐步进行的,作为对应迭代器的 next
方法调用的结果。让我们通过前面的例子来研究生成器代码的执行过程。我们将使用一个特殊的光标来标记生成器暂停执行的位置。
在调用 naturalRowGenerator
时,创建了一个迭代器。
function* naturalRowGenerator() {
▷let current = 1;
while (current <= 3) {
yield current;
current++;
}
}
接下来,当我们调用 next
方法三次(或在循环中迭代三次)时,光标会停在 yield
语句之后。
function* naturalRowGenerator() {
let current = 1;
while (current <= 3) {
yield current; ▷
current++;
}
}
对于所有后续的 next
调用,以及在退出循环后,生成器将完成其执行。后续 next
调用的结果将是 { value: undefined, done: true }
。
向迭代器传递参数
假设我们需要为自然数迭代器添加一个功能:重置当前计数器并从头开始计数。
naturalRowIterator.next() // 1
naturalRowIterator.next() // 2
naturalRowIterator.next(true) // 1
naturalRowIterator.next() // 2
很明显,如何在自定义迭代器中处理这样的参数,但生成器呢?事实证明,生成器支持参数传递!
function* naturalRowGenerator() {
let current = 1;
while (true) {
const reset = yield current;
if (reset) {
current = 1;
} else {
current++;
}
}
}
传递的参数会成为 yield
操作符的结果。让我们用光标的方式来进一步说明这一点。在创建迭代器时,没有任何变化。现在,让我们在第一次 next
方法调用后暂停:
function* naturalRowGenerator() {
let current = 1;
while (true) {
const reset = ▷yield current;
if (reset) {
current = 1;
} else {
current++;
}
}
}
光标停在从 yield
操作符返回之后。通过下一次 next
调用,传递给函数的值将设置 reset
变量的值。但第一次 next
调用传递的值去哪了呢?它无处可去!如果你需要向生成器传递初始值,可以通过生成器的参数来实现。下面是一个例子:
function* naturalRowGenerator(start = 1) {
let current = start;
while (true) {
const reset = yield current;
if (reset) {
current = start;
} else {
current++;
}
}
}
const iterator = naturalRowGenerator(10);
iterator.next() // 10
iterator.next() // 11
iterator.next(true) // 10
总结
我们已经探讨了迭代器的概念及其在 JavaScript 中的实现。此外,我们还学习了生成器,这是一种方便实现迭代器的语法结构。
尽管我在本文中提供了数字序列的示例,但 JavaScript 中的迭代器可以解决各种任务。它们可以表示任何数据序列,甚至许多有限状态机。在下一篇文章中,我想讨论如何使用生成器来构建异步流程(协程、goroutines、CSP 等)。
希望这篇文章能让你对迭代器和生成器有更深入的理解!