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 灵感来自于数组的迭代选择器: mapfilter

数组可以通过 mapfilter 方法转换。这些方法可以一般化为把可迭代对象作为输入,然后输出另一个可迭代对象。

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 第一步 - 分词

下面的技巧使得代码更简单一点儿:迭代器的最后一个结果(属性 donefalse )设置为结束标记 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 的信息。

results matching ""

    No results matching ""