21.6 生成器实例
本节给出几个例子,用于说明生成器可以用来干什么。
下面的 GitHub 仓库包含了示例代码: generator-examples
21.6.1 通过生成器实现迭代器
在关于迭代器的章节,我“顺手”实现了几个迭代器。在本节,我用生成器来实现它们。
21.6.1.1 可迭代的选择器 take()
take()
将一组可迭代的值(可能是无限的)转换成一个长度 n
的序列:
function* take(n, iterable) {
for (let x of iterable) {
if (n <= 0) return;
n--;
yield x;
}
}
下面是使用它的一个例子:
let arr = ['a', 'b', 'c', 'd'];
for (let x of take(2, arr)) {
console.log(x);
}
// Output:
// a
// b
一个不使用生成器实现的 take()
版本会更加复杂:
function take(n, iterable) {
let iter = iterable[Symbol.iterator]();
return {
[Symbol.iterator]() {
return this;
},
next() {
if (n > 0) {
n--;
return iter.next();
} else {
maybeCloseIterator(iter);
return { done: true };
}
},
return() {
n = 0;
maybeCloseIterator(iter);
}
};
}
function maybeCloseIterator(iterator) {
if (typeof iterator.return === 'function') {
iterator.return();
}
}
注意迭代选择器 zip() 通过生成器实现并不会获益多少,因为要使用多个迭代器,不能使用 for-of
循环。
21.6.1.2 无限迭代器
naturalNumbers()
返回一个迭代所有自然数的迭代器:
function* naturalNumbers() {
for (let n=0;; n++) {
yield n;
}
}
这种函数通常和选择器结合使用:
for (let x of take(3, naturalNumbers())) {
console.log(x);
}
// Output
// 0
// 1
// 2
下面是非生成器版本的实现,你可以对比一下:
function naturalNumbers() {
let n = 0;
return {
[Symbol.iterator]() {
return this;
},
next() {
return { value: n++ };
}
}
}
21.6.1.3 灵感来自于数组的迭代选择器: map
, filter
数组可以通过 map
和 filter
方法转换。这些方法可以一般化为把可迭代对象作为输入,然后输出另一个可迭代对象。
21.6.1.3.1 一般化的 map()
下面是 map()
的一般化版本:
function* map(iterable, mapFunc) {
for (let x of iterable) {
yield mapFunc(x);
}
}
处理无限迭代对象的 map()
:
> [...take(4, map(naturalNumbers(), x => x * x))]
[ 0, 1, 4, 9 ]
21.6.1.3.2 一般化的 filter()
下面是 filter()
的一般化版本:
function* filter(iterable, filterFunc) {
for (let x of iterable) {
if (filterFunc(x)) {
yield x;
}
}
}
处理无限迭代对象的 filter()
:
> [...take(4, filter(naturalNumbers(), x => (x % 2) === 0))]
[ 0, 2, 4, 6 ]
21.6.2 用于延迟执行的生成器
接下来的两个例子展示生成器如何用于处理字符流。
- 输入是字符流。
- 第一步 - 分词(字符→单词):字符组合成单词,就是满足正则表达式
/^[A-Za-z0-9]$/
的字符串。忽略非单词的字符,但是这些字符分割单词。这一步的输入是字符流,输出是单词流。 - 第二步 - 提取数字(单词→数字):仅保留匹配正则
/^[0-9]+$/
的单词,并将它们转换成数字。 - 第三步 - 添加数字(数字→数字):对于每个接收到的数字,返回到目前为止接收到的所有数字之和。
所有内容都延迟执行(增量地和按需地),很平滑优雅:接收到第一个字符之后就立即开始计算。例如,我们不需要等到接受完所有字符才去拿第一个单词。
21.6.2.1 延迟拉取(用作迭代器的生成器)
用生成器实现的延迟拉取以如下的方式工作。实现步骤一到三的三个生成器像下面这样链式调用:
addNumbers(extractNumbers(tokenize(CHARS)))
链中的每个成员从数据源中拉取数据,然后生产出另一组数据。整个过程开始于 tokenize
,它的数据源就是字符串 CHARS 。
21.6.2.1.1 第一步 - 分词
下面的技巧使得代码更简单一点儿:迭代器的最后一个结果(属性 done
是 false
)设置为结束标记 END_OF_SEQUENCE 。
/**
* Returns an iterable that transforms the input sequence
* of characters into an output sequence of words.
*/
function* tokenize(chars) {
let iterator = chars[Symbol.iterator]();
let ch;
do {
ch = getNextItem(iterator); // (A)
if (isWordChar(ch)) {
let word = '';
do {
word += ch;
ch = getNextItem(iterator); // (B)
} while (isWordChar(ch));
yield word; // (C)
}
// Ignore all other characters
} while (ch !== END_OF_SEQUENCE);
}
const END_OF_SEQUENCE = Symbol();
function getNextItem(iterator) {
let {value,done} = iterator.next();
return done ? END_OF_SEQUENCE : value;
}
function isWordChar(ch) {
return typeof ch === 'string' && /^[A-Za-z0-9]$/.test(ch);
}
该生成器是如何延迟的?当通过 next()
请求一个值的时候,该生成器就从 iterator
(行 A 和行 B )中拉取数据,处理之后就放在 yield 后面返回出去(行 C )。然后生成器函数暂停,直到下一次数据请求的到来。这意味着这种分词在第一个字符就绪的时候就开始了,这对流( stream )很方便。
让我们尝试一下分词。注意空格和点符号不是单词,忽略它们,但是它们分割单词。我们利用一个事实:字符串就是字符( unicode 码点)的迭代。 tokenize()
的结果就是一组单词的迭代,可以通过扩展操作符(...)将其转换为数组。
> [...tokenize('2 apples and 5 oranges.')]
[ '2', 'apples', 'and', '5', 'oranges' ]
21.6.2.1.2 第二步 - 提取数字
这一步相当简单,仅将包含数字的单词(通过 Number()
转换成数字类型)放在 yield
之后。
/**
* Returns an iterable that filters the input sequence
* of words and only yields those that are numbers.
*/
function* extractNumbers(words) {
for (let word of words) {
if (/^[0-9]+$/.test(word)) {
yield Number(word);
}
}
}
可以再次看到延迟:如果通过 next()
请求一个数字,那么在 words
中遇到一个数字单词的时候,就会立马获取到(通过 yield
)这个数字。
让我们从一组单词中获取数字:
> [...extractNumbers(['hello', '123', 'world', '45'])]
[ 123, 45 ]
注意字符串转换成了数字。
21.6.2.1.3 第三步 - 数字相加
/**
* Returns an iterable that contains, for each number in
* `numbers`, the total sum of numbers encountered so far.
* For example: 7, 4, -1 --> 7, 11, 10
*/
function* addNumbers(numbers) {
let result = 0;
for (let n of numbers) {
result += n;
yield result;
}
}
尝试一个简单的例子:
> [...addNumbers([5, -2, 12])]
[ 5, 3, 15 ]
21.6.2.1.4 获取输出
生成器链本身并不会产生输出。我们需要通过扩展操作符主动地获得输出数据:
const CHARS = '2 apples and 5 oranges.';
const CHAIN = addNumbers(extractNumbers(tokenize(CHARS)));
console.log([...CHAIN]);
// [ 2, 7 ]
辅助函数 logAndYield
让我们能够判断出是否真的延迟执行了:
function* logAndYield(iterable, prefix='') {
for (let item of iterable) {
console.log(prefix + item);
yield item;
}
}
const CHAIN2 = logAndYield(addNumbers(extractNumbers(tokenize(logAndYield(CHARS)\
))), '-> ');
[...CHAIN2];
// Output:
// 2
//
// -> 2
// a
// p
// p
// l
// e
// s
//
// a
// n
// d
//
// 5
//
// -> 7
// o
// r
// a
// n
// g
// e
// s
// .
上面的输出表明 addNumbers
在接收到 '2'
和 ''
的时候就生成了一个结果。
21.6.2.2 延迟推入数据(用作观察者的生成器)
将前面基于输出的逻辑改成一个基于输入的例子不需要做太多事情。步骤是一样的。但是不是以获取数据而结束,而是以输入数据而开始。
正如之前讲解的,如果生成器通过 yield
接收到输入,那么该生成器上的第一次 next()
调用什么也不干。这就是为什么我要使用之前展示的辅助函数 coroutine()
来在此处创建协同程序,它帮助我们首次调用 next()
。
下面的函数 send()
完成数据推入。
/**
* Pushes the items of `iterable` into `sink`, a generator.
* It uses the generator method `next()` to do so.
*/
function send(iterable, sink) {
for (let x of iterable) {
sink.next(x);
}
sink.return(); // signal end of stream
}
当生成器处理流的时候,它需要知道流的结束点,以便于正确地做清理工作。对于拉取数据,我们通过一个特殊的流终结符来做这件事情。对于推入数据,流的结束信号通过 return()
发出。
让我们通过一个生成器来测试 send()
,该生成器只是简单地打印出接收到的数据:
/**
* This generator logs everything that it receives via `next()`.
*/
const logItems = coroutine(function* () {
try {
while (true) {
let item = yield; // receive item via `next()`
console.log(item);
}
} finally {
console.log('DONE');
}
});
通过一个字符串(可以迭代其中的 unicode 码点)给 logItems()
传入三个字符。
> send('abc', logItems());
a
b
c
DONE
21.6.2.2.1 第一步 - 分词
注意生成器是如何在两个 finally
子句中处理流结束的(通过 return()
发出信号)。我们依赖于 return()
信号被送给两个 yield
中的一个。否则,该生成器将永不会结束,因为从行 A 开始的无限循环不会停止。
/**
* Receives a sequence of characters (via the generator object
* method `next()`), groups them into words and pushes them
* into the generator `sink`.
*/
const tokenize = coroutine(function* (sink) {
try {
while (true) { // (A)
let ch = yield; // (B)
if (isWordChar(ch)) {
// A word has started
let word = '';
try {
do {
word += ch;
ch = yield; // (C)
} while (isWordChar(ch));
} finally {
// The word is finished.
// We get here if
// - the loop terminates normally
// - the loop is terminated via `return()` in line C
sink.next(word); // (D)
}
}
// Ignore all other characters
}
} finally {
// We only get here if the infinite loop is terminated
// via `return()` (in line B or C).
// Forward `return()` to `sink` so that it is also
// aware of the end of stream.
sink.return();
}
});
function isWordChar(ch) {
return /^[A-Za-z0-9]$/.test(ch);
}
这一次,该延迟通过数据推入来驱动:当生成器接收到能够构成一个单词的字符数时(在行 C ),就会把该单词推入 sink
(行 D )。也就是说,生成器不会等到接收到所有的字符才开始工作。
tokenize()
显示了生成器作为线性状态机的实现,工作得很好。在此场景下,状态机有两种状态:“在单词内部”和“没在单词内部”。
让我们对一个字符串分词:
> send('2 apples and 5 oranges.', tokenize(logItems()));
2
apples
and
5
oranges
21.6.2.2.2 第二步 - 获取数字
这一步很直接。
/**
* Receives a sequence of strings (via the generator object
* method `next()`) and pushes only those strings to the generator
* `sink` that are “numbers” (consist only of decimal digits).
*/
const extractNumbers = coroutine(function* (sink) {
try {
while (true) {
let word = yield;
if (/^[0-9]+$/.test(word)) {
sink.next(Number(word));
}
}
} finally {
// Only reached via `return()`, forward.
sink.return();
}
});
同样是延迟的:当遇到数字的时候,就推入 sink
。
让我们从一组单词中获取数字:
> send(['hello', '123', 'world', '45'], extractNumbers(logItems()));
123
45
DONE
注意输入是一个字符串序列,输出是一个数字序列。
21.6.2.2.3 第三步 - 数字求和
这一次,通过推入单个值来响应流的结束,然后关闭 sink 。
/**
* Receives a sequence of numbers (via the generator object
* method `next()`). For each number, it pushes the total sum
* so far to the generator `sink`.
*/
const addNumbers = coroutine(function* (sink) {
let sum = 0;
try {
while (true) {
sum += yield;
sink.next(sum);
}
} finally {
// We received an end-of-stream
sink.return(); // signal end of stream
}
});
让我们试一下这个生成器:
> send([5, -2, 12], addNumbers(logItems()));
5
3
15
DONE
21.6.2.2.4 推入输入数据
生成器链以 tokenize
开始,以 logItems
结束,并且打印出所有接收到的东西。我们通过 send
推入一系列字符到生成器链:
const INPUT = '2 apples and 5 oranges.';
const CHAIN = tokenize(extractNumbers(addNumbers(logItems())));
send(INPUT, CHAIN);
// Output
// 2
// 7
// DONE
下面的代码证明整个处理过程真的是延迟发生的:
const CHAIN2 = tokenize(extractNumbers(addNumbers(logItems({ prefix: '-> ' }))));
send(INPUT, CHAIN2, { log: true });
// Output
// 2
//
// -> 2
// a
// p
// p
// l
// e
// s
//
// a
// n
// d
//
// 5
//
// -> 7
// o
// r
// a
// n
// g
// e
// s
// .
// DONE
打印出来的内容显示 addNumbers
在接收到 '2'
和 ''
的时候就立马产生一个结果。
21.6.3 通过生成器实现的多任务协作
21.6.3.1 推入长时间运行的任务
在本例中,我们创建一个在 web 页面显示的计数器。我们不断地优化最初的版本,直到得到一个多任务协作的版本,并且不会阻塞主线程和用户界面。
下面是 web 页面的一部分,在其中展示计数器:
<body>
Counter: <span id="counter"></span>
</body>
下面的函数不停地计数:
function countUp(start = 0) {
const counterSpan = document.querySelector('#counter');
while (true) {
counterSpan.textContent = String(start);
start++;
}
}
如果你运行这个函数,它会完全阻塞它所运行在的用户界面线程,并且失去响应。
让我们用生成器实现同样的功能,该生成器通过 yield
定期暂停(后面会展示一个用于运行该生成器的调度函数):
function* countUp(start = 0) {
const counterSpan = document.querySelector('#counter');
while (true) {
counterSpan.textContent = String(start);
start++;
yield; // pause
}
}
让我们稍微地改进一下。把用户界面的更新挪到另一个生成器 displayCounter
里面,通过 yield*
调用该生成器。由于是一个生成器函数,所以也能够暂停。
function* countUp(start = 0) {
while (true) {
start++;
yield* displayCounter(start);
}
}
function* displayCounter(counter) {
const counterSpan = document.querySelector('#counter');
counterSpan.textContent = String(counter);
yield; // pause
}
最后,下面是一个调度函数,可以用它来调用 countUp()
。生成器执行的每一步都由一个独立的任务处理,该任务通过 setTimeout()
创建。这意味着用户界面可以调度其它任务,并且保持可响应。
function run(generatorObject) {
if (!generatorObject.next().done) {
// Add a new task to the event queue
setTimeout(function () {
run(generatorObject);
}, 1000);
}
}
有了 run
的帮助,我们得到了一个近乎无尽的计数器,并且不会阻塞用户界面:
run(countUp());
你可以在线运行这个例子。
21.6.3.2 生成器版本的多任务协作和 Node.js 风格的回调
如果你调用一个生成器函数(或者方法),该函数(或方法)并不能访问到它的生成器对象;它的 this
与它是普通函数时的 this
一样。一种变通方法就是通过 yield
将生成器对象传入该生成器。
下面的 Node.js 脚本使用了这种技术,但是将生成器包裹在一个回调中( next
,行 A )。必须要通过 babel-node 运行。
import {readFile} from 'fs';
let fileNames = process.argv.slice(2);
run(function* () {
let next = yield;
for (let f of fileNames) {
let contents = yield readFile(f, { encoding: 'utf8' }, next);
console.log('##### ' + f);
console.log(contents);
}
});
在行 A ,我们使用一个 Node.js 回调风格的的回调函数。该回调函数使用生成器对象唤醒生成器,你可以看一下 run()
的实现代码:
function run(generatorFunction) {
let generatorObject = generatorFunction();
// Step 1: Proceed to first `yield`
generatorObject.next();
// Step 2: Pass in a function that the generator can use as a callback
function nextFunction(error, result) { // (A)
if (error) {
generatorObject.throw(error);
} else {
generatorObject.next(result);
}
}
generatorObject.next(nextFunction);
// Subsequent invocations of `next()` are triggered by `nextFunction`
}
21.6.3.3 通信顺序进程( CSP )
js-csp 将通信顺序进程( CSP )引入了 JavaScript 。 CSP 是一种多任务协作的方式,类似于 ClojureScript 的 core.async 和 Go 的 goroutine 。 js-csp
有两处抽象:
- 进程:是多任务协作的,通过将生成器函数传递给调度函数
go()
来实现。 - 通道:用于进程通信的队列。通过调用
chan()
创建通道。
作为一个示例,让我们使用 CSP 处理 DOM 事件,这是一种让人联想到函数式编程的方式。下面的代码使用函数 listen()
(将在后面展示)创建一个输出 mousemove
事件的通道,然后在一个无限循环中通过 take
持续地获得输出内容。多亏了 yield
,进程才会阻塞直到通道有输出。
import csp from 'js-csp';
csp.go(function* () {
let element = document.querySelector('#uiElement1');
let channel = listen(element, 'mousemove');
while (true) {
let event = yield csp.take(channel);
let x = event.layerX || event.clientX;
let y = event.layerY || event.clientY;
element.textContent = `${x}, ${y}`;
}
});
listen()
的实现如下所示:
function listen(element, type) {
let channel = csp.chan();
element.addEventListener(type,
event => {
csp.putAsync(channel, event);
});
return channel;
}
此例来自 James Long 的博客《 Taming the Asynchronous Beast with CSP Channels in JavaScript 》 。查阅这篇博客以获得更多关于 CSP 的信息。