迭代器与生成器

226 阅读11分钟

在前面集合引用类型中,我们讨论了几个集合引用类型的可迭代。

那么这里开始深度学习一下迭代器。

ES6规范新增了两个高级特性:迭代器(iterator)生成器

使用这两个特性,能够更清晰、高效、方便地实现迭代

概念点

迭代器(iterator) --实现了--> Iterable接口(可迭代协议) --调用--> next() --生成--> IteratorResult对象 --包含--> done + value两个属性

迭代器: 对象内部设置 [Symbol.iterator] 工厂函数

生成器: 函数使用* + yield + next()

理解迭代

循环是迭代机制的基础。每次循环都会在下一次迭代开始之前完成,而每次迭代的顺序都是事先定义好的。

迭代会在一个有序集合上进行。(数组是JS中有序集合的最典型例子)

  • 最简单的迭代:for循环

    因为数组有已知长度,且数组每项都可以通过索引获取,所以整个数组可以通过递增索引来遍历

    但是由于以下原因,通过这种循环来执行例程并不理想:

    • 迭代之前需要事先知道如何使用数据结构,这种情况并不适用于所有数据结构

    • 遍历顺序并不是数据结构固有的,不适用于其他具有隐式顺序的数据结构

  • ES5新增了Array.prototype.forEach()方法,向通用迭代需求迈进了一步(但仍然不够理想)

    arr.forEach(item => {...})
    

    这个方法解决了单独记录索引和通过数组对象取值的问题。但是没有办法标识迭代何时终止。

    因此这个方法只适用于数组,而且回调结构也比较笨拙。

  • 最终,迭代器模式产生,在ES6以后开始支持

迭代器

迭代器(iterator)是按需创建的一次性对象。每个迭代器都会关联一个可迭代对象,而迭代器会暴露迭代器关联可迭代对象的API

用于迭代与其关联的可迭代对象,迭代器无须了解与其关联的可迭代对象的结构,也不用知道从可迭代对象中取得下一个值的过程,只需要知道如何取得连续的值,和迭代器是否到达done:true状态。

迭代器API使用next()方法在可迭代对象中遍历数据。每次成功调用next(),都会返回一个IteratorResult对象,其中包含迭代器返回的下一个值

next()方法返回的迭代器对象IteratorResult包含两个属性

  • done

    布尔值,表示是否还可以再次调用next()取得下一个值

  • value

    包含可迭代对象的下一个值

每个迭代器都表示对可迭代对象的一次性有序遍历。不同迭代器的实例相互之间没有联系,只会独立地遍历可迭代对象。

迭代器并不与可迭代对象某个时刻的快照绑定,而仅仅是使用游标来记录遍历可迭代对象的历程。

如果可迭代对象在迭代期间被修改了,那么迭代器也会反映相应的变化。

迭代器维护着一个指向可迭代对象的引用,因此迭代器会阻止垃圾回收程序回收可迭代对象。

可迭代协议

实现Iterable接口(可迭代协议)要求同时具备两种能力:

  • 支持迭代的自我识别能力

  • 创建实现Iterator接口的对象的能力

很多内置类型都实现了Iterable接口:字符串、数组、映射、集合、arguments对象、NodeList等DOM集合类型

检查是否存在默认迭代器属性可以暴露这个默认的工厂函数

let str = 'abc';
// 字符串、数组、映射、集合、arguments对象、NodeList等DOM集合类型都实现了Iterable接口,以字符串为例:
console.log(str[Symbol.iterator]); // f values() {[...]}
// 调用这个工厂函数会生成一个迭代器
console.log(str[Symbol.iterator]()); // StringIterato {}

实际写代码过程中,不需要显式调用这个工厂函数来生成迭代器。实现可迭代协议的所有类型都会自动兼容接收可迭代对象的任何语言特性。接收可迭代对象的原生语言特性包括:

  • for-of循环
    let arr = ['a', 'b', 'c'];
    for(let i of arr) {...}
    
  • 数组解构
    let [a, b, c] = arr;
    
  • 扩展操作符
    let arr2 = [...arr];
    
  • Array.from()
    let arr3 = Array.from(arr);
    
  • 创建集合(Set
    let s = new Set(arr);
    
  • 创建映射(Map
    let m = arr.map((x, i) => [x, i]);
    
  • Promise.all()接收由期约组成的可迭代对象
  • Promise.race()接收由期约组成的可迭代对象
  • yeild *操作符,在生成器中使用
  • 如果对象原型链上的父类实现了Iterable接口,那这个对象也就实现了这个接口
    class FooArray extends Array{}
    let fooArr = new FooArray('a', 'b', 'c');
    for(let i of fooArr){...}
    

这些原生语言结构会在后台调用提供的可迭代对象的这个工厂函数,从而创建一个迭代器

迭代器模式

迭代器模式描述了一个方案,即可以把有些结构称为“可迭代对象”,因为他们实现了正式的Iterable接口,而且可以通过迭代器Iterator消费。

即任何实现Iterable接口的对象都有一个Symbol.iterator属性,这个属性引用默认迭代器。

可迭代对象必须暴露一个属性作为“默认迭代器”,而且这个属性必须使用特殊的Symbol.iterator作为键

这个默认迭代器属性必须引用一个迭代器工厂函数,调用这个工厂函数必须返回一个新迭代器(一个实现Iterator接口的对象)。

基本上,可以把可迭代对象理解成数组或集合这样的集合类型的对象(不一定是集合对象,也可以是仅仅具有类似数组行为的其他数据结构)。它们包含的元素都是有限的,而且都具有无歧义的遍历顺序。

自定义迭代器

class Counter {
    constructor(limit) {
        this.limit = limit;
    }
    
    [Symbol.iterator]() {
        let count = 1, limit = this.limit;
        return {
            next() {
                if(count <= limit) {
                    return { done: false, value: count++ };
                } else {
                    return { done: true, value: undefined };
                }
            }
        }
    }
}
let counter = new Counter(3);
for(let i of counter) {
    console.log(i);
}
// 1 2 3

提前终止迭代器

可选的return()方法用于指定在迭代器提前关闭时执行的逻辑。

可能的情况包括:

  • for-of 循环通过breakcontinuereturnthrow提前退出

  • 解构操作并未消费所有值

return()方法必须返回一个有效的IteratorResult对象。简单情况下,可以只返回{done: true}

class Counter {
    constructor(limit) {
        this.limit = limit;
    }
    [Symbol.iterator]() {
        let count = 1, limit = this.limit;
        return {
            next() {
                if(count <= limit) {
                    return { done: false, value: count++ }
                } else {
                    return { done: true }
                }
            },
            return() {
                console.log('Exiting early');
                return { done: true }
            }
        }
    }
}
let counter = new Counter(5);
let [a, b] = counter;
// Exiting early

如果迭代器没有关闭,则还可以继续从上次离开的地方继续迭代。比如,数组的迭代器就是不能关闭的。

let a = [1, 2, 3, 4, 5];
let iter = a[Symbol.iterator]();

for(let i of iter) {
    console.log(i);
    if(i > 2) break;
}
// 1 2 3
for(let i of iter) {
    console.log(i);
}
// 4 5

并非所有迭代器都是可关闭的,因为return()方法是可选的。

调用return()不会强制迭代器进入关闭状态,不过return()依旧会被调用

生成器

生成器是一种特殊的函数,拥有在一个函数块内暂停和恢复代码执行的能力,调用生成器之后会返回一个生成器对象。

函数名称前加一个*就表示它是一个生成器,yield关键字可以将跟在它后面的可迭代对象系列化为一连串值。

标识生成器函数的星号不受两侧空格的影响

// 生成器函数声明
function* fun() {}
// 作为类静态方法的生成器函数
class Bar {
    static * fun() {}
}

箭头函数不能用来定义生成器函数

调用生成器函数后产生的生成器对象一开始处于暂停执行的状态(suspended)。

与迭代器相似,生成器对象也实现了Iterator接口(他们默认的迭代器是自引用的),因此具有next()方法。调用这个方法会让生成器开始或恢复执行。

console.log(fun.next); // f next() { [native code] }

next()方法的返回值类似于迭代器,有一个done属性和一个value属性。value值即为生成器函数的返回值

生成器的独特之处在于支持yield关键字,这个关键字能够暂停执行生成器函数,此时函数作用域的状态会被保留,停止执行的生成器函数只能通过在生成器对象上调用next()方法来恢复执行。

function* gFn() {
    yield;
}
let g = gFn();
console.log(g.next()); // {done: false, value: undefined}
console.log(g.next()); // {done: true, value: undefined}

通过yield关键字退出的生成器函数会处在done: false状态;通过return关键字退出的生成器函数会处于done: true状态

yield关键字只能在生成器函数内部使用,用在其他地方会抛出错误。

// 无效
function* invalidGeneratorFn() {
    function a() {
        yeild;
    }
}

生成器函数内部的执行流程会针对每个生成器对象区分作用域。在一个生成器对象上调用next()不会影响其他生成器

在生成器对象上显式调用next()方法的用处并不大。其实,如果把生成器对象当成可迭代对象,那么使用起来会更方便

function* gFn() {
    yield 1;
    yield 2;
    yield 3;
}

for(const x of gFn()) {
    console.log(x);
}
// 1 2 3

除了可以作为函数的中间返回语句使用,yield关键字还可以作为函数的中间参数使用

function* gFn(initial) {
    console.log(initial);
    console.log(yield);
    console.log(yield);
}
let g = gFn('foo');
// 第一次调用next()传入的值不会被使用,因为这一次调用是为了开始执行生成器函数
g.next('bar'); // foo
g.next('baz'); // baz
g.next('qux'); // qux

yield关键字可以同时用于输入和输出

function* gFn() {
    return yield 'foo';
}
let g = gFn();
console.log(g.next()); // {done: false, value: 'foo'}
console.log(g.next('bar')); // {done: true, value: 'bar'}

因为函数必须对整个表达式求值才能确定要返回的值,所以它在遇到yield关键字时暂停执行并计算出要产生的值:'foo'。下次调用next()传入bar,作为交给同一个yield的值,然后这个值被确定为本次生成器函数要返回的值

可以使用*增强yield的行为,让它能够迭代一个可迭代对象,从而一次产出一个值

因为yield*实际上只是将一个可迭代对象序列化为一连串可以单独产出的值,所以这跟把yield放到一个循环里没什么不同

function* gFn() {
    yield* [1, 2, 3]
}
// 等价于
function* gFn() {
    for(const x of [1, 2, 3]) {
        yield x;
    }
}
for(const x of gFn()) {
    console.log(x);
}
// 1 2 3 

yield*的值是关联迭代器返回done:true时的value属性。

对于普通迭代器来说,这个值是undefined

function* gFn() {
    console.log('iter value:', yield* [1, 2, 3]);
}
for(const x of gFn()) {
    console.log('value:', x);
}
// value: 1
// value: 2
// value: 3
// iter value: undefined

对于生成器函数产生的迭代器来说,这个值就是生成器函数返回的值

function* innerFn() {
    yield 'foo';
    return 'bar';
}
function* outerFn() {
    console.log('iter value:', yield* innerFn());
}
for(const x of outerFn()) {
    console.log('value:', x);
}
// value: foo
// iter value: bar

yield*最有用的地方是实现递归操作,此时生成器可以产生自身

function* nTimes(n) {
    if(n > 0) {
        yield* nTimes(n-1);
        yield n-1;
    }
}

生成器作为默认迭代器

因为生成器对象实现了Iterable接口,而且生成器函数和默认迭代器被调用之后都产生迭代器,所以生成器格外适合作为默认迭代器。

class Foo {
    constructor() {
        this.values = [1, 2, 3];
    }
    * [Symbol.iterator]() {
        yield* this.values;
    }
}
const f = new Foo();
for(const x of f) {
    console.log(x);
}
// 1 2 3

提前终止生成器

与迭代器类似,生成器也支持“可关闭”的概念。

一个实现Iterator接口的对象一定有next()方法,还有一个可选地return()方法用于提前终止迭代器。

除了以上两个方法,还有throw()

function* gFn() {}

const g = gFn();

console.log(g);         // gFn {<suspended>}
console.log(g.next);    // f next() {[native code]}
console.log(g.return);  // f return() {[native code]}
console.log(g.throw);   // f throw() {[native code]}

return()throw()方法都可以用于强制生成器进入关闭状态

return()方法会强制生成器进入关闭状态。提供给return()方法的值,就是终止迭代器对象的值

与迭代器不同,所有生成器对象都有return()方法,只要通过它进入关闭状态,就无法恢复了。后续调用next()会显示done: true状态,而提供的任何返回值都不会被存储或传播

function* gFn() {
    for(const x of [1, 2, 3]) {
        yield x;
    }
}
const g = gFn();
console.log(g.next());    // {done: false, value: 1}
console.log(g.return(4)); // {done: true, value: 4}
console.log(g.next());    // {done: true, value: undefined}

// for-of循环等内置语言结构会忽略状态为done:true的IteratorObject内部返回的值
for(const x of g) {
    if(x>1) {
        g.return(4);
    }
    console.log(x);
}
// 1 2

throw()方法会在暂停的时候将一个提供的错误注入到生成器对象中。如果错误未被处理,生成器就会关闭

function* gFn() {
    for(const x of [1, 2, 3]) {
        yield x;
    }
}
const g = gFn();
console.log(g); // gFn {<suspended>}
try {
    g.throw('foo');
} catch(e) {
    console.log(e); // foo
}
console.log(g); // gFn {<closed>}

如果生成器函数内部处理了这个错误,那么生成器就不会关闭,而且还可以恢复执行。错误处理会跳过对应的yield

function* gFn() {
    for(const x of [1, 2, 3]) {
        try {
            yield x;
        } catch(e) {}
    }
}
const g = gFn();
console.log(g.next()); // {done: false, value: 1}
g.throw('foo');
console.log(g.next()); // {done: false, value: 3}

如果生成器对象还没有开始执行,那么调用throw()抛出的错误不会在函数内部被捕获,因为这相当于在函数块外部抛出了错误