toFrankie / blog

种一棵树,最好的时间是十年前。其次,是现在。
21 stars 1 forks source link

细读 ES6 | Iterator 迭代器 #257

Open toFrankie opened 1 year ago

toFrankie commented 1 year ago

配图源自 Freepik

迭代的英文是 “iteration”,源自拉丁文 “itero”,是“重复”或“再来”的意思。在软件开发领域,“迭代”的意思是按照顺序反复多次执行一段程序,通常会有明确的终止条件。ES6 规范新增了两个高级特性:迭代器生成器。使用这两个特性,能够更清晰、高效、方便地实现迭代。

一、迭代器简述

迭代器(Iterator)是一种机制,是一种接口。为各种不同的数据结构提供了统一的访问机制。

有些书籍或文章译为“遍历器”,其他语言多称为“迭代器”。

1. 为什么 ECMAScript 要引入“迭代器”?

在 ES6 之前,最常见的数据结构有数组和对象。

遍历这些数据结构通常使用 for 语句。例如数组,除了最原始的 for 循环,还有 Array.prototype.forEach() 等方法。而普通对象则使用 for...in 语句。那么,我们决定使用哪一种方法之前,是不是要提前知道它们内部结构是怎样的,才能正确地写出迭代的程序,对吧。

而在 ES6 标准中,新增了 MapSet 两种不同的数据结构。它们的内部结构与原本就存在的 ArrayObject 又不一样。假设我们要去遍历它们,是不是要实现一个类似 Array.prototype.forEach() 的方法,例如,遍历以上两种结构的方法叫 Map.prototype.forEach()Set.prototype.forEach()(当然这两个是我瞎编了)。那么这样是不是又多了两种迭代方式,无论对负责制定标准的 TC39 委员会,还是 JavaScript 开发者来说,都增加了工作量。委员会要负责实现,开发者要了解学习,都需要成本。

看到这种苗头,TC39 委员会那些大佬就内心开始骂街了。每增加一种数据结构,就要实现一种全新的方法去遍历它,那劳资不干了,都不能好好摸鱼了。

于是它们就想出一个“偷懒”的方法:实现一种通用的迭代机制,并制定相关标准。它一定要支持原有的数据结构,而且未来新增的数据结构,也要满足这种模式(机制)才行。因此,在 ECMAScript 中就出现了“迭代器”(Iterator)的概念。

这样的话,对于我们开发者来说,无需知道某种对象的内部数据结构,就可以利用 for...of 等语句,方便、快速、安全地写出迭代程序。假设在未来的 ES2050 标准中,新增了一种名为 Record 的数据结构,只要符合迭代器机制的,我们仍然可以使用 for...of 去遍历它们。

2. JavaScript 内置的可迭代对象

如果一个对象具有 Iterator 接口,我们将它称为“可迭代对象”(Iterable)。

在 JavaScript 中,目前内置的可迭代对象有:StringArrayTypedArrayMapSetArgumentsNodeList。它们的原型对象(prototype)都实现了 @@iterator方法。(即对象含有 Symbol.iterator 属性)。

除了内置的可迭代对象,我们可以实现自己的可迭代对象。例如:

const myIterable = {
  [Symbol.iterator]: function* () {
    yield 1
    yield 2
    yield 3
    // 生成器函数,返回一个可迭代对象
  }
}

console.log([...myIterable]) // [1, 2, 3]

3. Iterator 接口

迭代器是一个对象,带有特殊的接口。一个具有 Iterator 接口的对象,分为两部分:可迭代协议迭代器协议

简单来说:

某个对象只有实现了以上两个协议,才算是一个完整、合格的迭代器。

二、生成器简述

说到生成器,你们一定知道 Generator 函数(即“生成器函数”),它是 ES6 中提供的一种异步编程的解决方案。

生成器的形式是一个函数,在函数名称前面加一个星号(*),表示它是一个生成器。尽管语法上与普通函数相似,但语法行为却完全不同。

注意,箭头函数不能用来定义生成器函数。

如示例:

function* generatorFn() {} // generatorFn 表示一个生成器
const gen = generatorFn() // 调用生成器函数,会返回一个“生成器对象”

console.log(gen) // generatorFn {<suspended>}
console.log(gen.next()) // {value: undefined, done: true}

// 生成器对象,一开始会处于暂停执行(suspended)的状态
// 调用 next() 方法会让生成器开始或恢复执行
// 由于生成器函数体为空,因此调用一次 next() 方法就会让生成器到达 done: true 的状态

本文不会详细介绍生成器函数相关语法,后面另起一文。本文提到它的缘由是:Generator 函数会产生一个“生成器对象”,该对象也实现了 Iterator 接口。

因此,生成器格外合适作为默认迭代器。在实现自定义可迭代对象时,也是最简单的一种实现。例如:

class Foo {
  constructor(value) {
    this.value = value
  }

  *[Symbol.iterator]() {
    yield* this.value
  }
}

// foo 实例对象是一个可迭代对象,因为它的原型上实现了 @@iterator 方法
const foo = new Foo([1, 2, 3])

// 访问迭代器
for (const x of foo) {
  console.log(x)
}
// 依次打印:1、2、3

三、Iterator 详解

迭代器是按需创建的一次性对象。每个迭代器都会关联一个可迭代对象。

这句话怎么理解,看示例:

const num = 1
const obj = {}
const arr = [1, 2, 3]

// 通过访问 Symbol.iterator 属性可判断对象是否实现了可迭代协议,
// 返回值为函数,则表示实现了,否则未实现,
// 因此 num、obj 不可迭代,arr 是可迭代的。
console.log(num[Symbol.iterator]) // undefined
console.log(obj[Symbol.iterator]) // undefined
console.log(arr[Symbol.iterator]) // ƒ values() { [native code] }

// 调用 @@iterator 方法,会生成一个迭代器,
// iter1、iter2 表示两个不同的迭代器,
// 但它们相关联的可迭代对象都是 arr
const iter1 = arr[Symbol.iterator]()
const iter2 = arr[Symbol.iterator]()

console.log(iter1 === iter2) // false
for (const x of iter1) {
  console.log(x)
}
// for...of 语句依次打印:1、2、3

在上面的实例中,结论与分析请看注释部分。

如何理解“一次性”对象,看示例:

// 示例一:
const gen = (function* () {
  yield 1
  yield 2
  yield 3
})()

for (let x of gen) {
  console.log(x)
  break // 关闭生成器
}

// 生成器不应该重用,以下没有意义!
for (let x of gen) {
  console.log(x)
}

// 由始至终,仅打印出 1

上面的示例一中,使用了一个生成器函数返回一个生成器(也是迭代器,它实现了 Iterator 接口)。当我们使用 for...of 循环遍历它,并使用了 break 关键字提前终止。在退出循环后,生成器关闭。若尝试再次迭代,不会产生任何进一步的结果。因此,我们不应该重用生成器。

再看以下示例:

// 示例二
const arr = [1, 2, 3, 4, 5]
const iter = arr[Symbol.iterator]()

// 第一次迭代
for (const x of iter) {
  console.log(x)
  if (x > 2) break
}
// 依次打印出:1、2、3

// 再次迭代
for (const x of iter) {
  console.log(x)
}
// 依次打印出:4、5

观察示例二,数组的迭代器跟生成器函数返回的迭代器又稍有不同。尽管我们在第一次迭代的时候,提前跳出循环了,但是迭代器 iter 并没有关闭。因此,我们可以尝试继续从上一次离开的地方继续迭代,这个离开的地方由迭代器内部维护。因此,数组的迭代器是不能主动关闭的,当它迭代至最后一个成员才会关闭。

结合两个示例,同一个迭代器最好不要重复使用,因此才说是一次性对象。

迭代器的概念很容易混淆,所以要注意了。

例如,数组不是一个迭代器(Iterator),但它是一个可迭代对象(Iterable)。在调用数组实例的 @@iteator 方法,才会生成一个与数组实例相关联的迭代器。

还需要注意的是,由于迭代器维护着一个执行可迭代对象的引用,因此迭代器会阻止垃圾回收程序回收可迭代对象。

Iterator 遍历过程

过程如下:

(1)创建一个指针对象,指向当前数据结构的起始位置。也就是说,迭代器对象本质上,就是一个指针对象。 (2)第一次调用指针对象的 next() 方法,可以将指针对象指向数据结构的第一个成员。 (3)第二次调用指针对象的 next() 方法,可以将指针对象指向数据结构的第二个成员。 (4)不断调用指针对象的 next() 方法,直到它指向数据结构的结束位置。

每一次调用迭代器的 next() 方法,都会返回数据结构的当前成员的信息,即前面提到的 IteratorResult 对象。它有包含 donevalue 两个属性,其中 done 是一个布尔值,表示遍历是否结束;value 表示当前成员的值。

我们基于数组的结构特点,模拟一个简单的 next() 方法(迭代器协议):

function createIterator(array) {
  let nextIndex = 0
  return {
    next: function () {
      return nextIndex < array.length
        ? { done: false, value: array[nextIndex++] }
        : { done: true, value: undefined }

      // 对迭代器对象来说,`done: false` 和 `value: undefined` 都是可以省略的,
      // 因此可以简写成:
      // return nextIndex < array.length
      //   ? { value: array[nextIndex++] }
      //   : { done: true }
    }
  }
}

const iter = createIterator([1, 2, 3])

上面示例中,定义了一个 createIterator 函数,作用是返回一个迭代器。当我们对数组 [1, 2, 3] 执行 createIterator(),就会返回与该数组相关联的遍历器对象 iter(即上文提到的指针对象)。

第一次调用指针对象 iternext() 方法,指针指向数组的开始位置,并返回一个 IteratorResult 对象 {done: false, value: 1},它包含了当前数据成员的信息。若 donefalse 表示遍历未结束,即可继续调用 next() 方法访问下一个成员信息......直到 donetrue 遍历结束。

其实迭代器(Iterator)与可迭代对象(Iterable)是分开的,迭代器无需了解与其关联的可迭代对象,只需要知道如何取得连续的值。

四、默认迭代器

迭代器的目的就是提供一种统一的访问机制。可供给 JavaScript 所有接收可迭代对象的语句或方法使用。

目前支持以下这些:
for...of 语句
数组解构
扩展运算符
Array.from()
new Set()
new Map()
Promise.all()
Promise.race()
Promise.allSettled()
Promise.any()
yield* 操作符

前面提到,只要某种数据结构(对象)部署了 Iterator 接口,那么我们就称这种数据结构是“可迭代的”。

ES6 规定,默认的 Iterator 接口部署在对象的 Symbol.iterator 属性上。该属性本身就是一个函数,就是当前对象的迭代器生成函数。调用此方法就会返回一个迭代器。

插个话:

千万不要被 Symbol.iterator 属性吓到了,标准规定用它指向对象的默认遍历器方法而已,你完全可以在内心把它当成名称为 iterator 的属性。那么 arr[Symbol.iterator](),就相当于 arr.iterator()arr['iterator']()。它就是一个键名,仅此而已。

我们知道使用 Symbol 类型去表示独一无二的值。同样的,当使用 Symbol 值作为对象的键名,就能保证不会出现同名属性。假设我们的默认迭代器部署在一个名称为 iterator(或其他)的属性上,那么是不是很容易被我们改写或覆盖掉。因此指定标准的那帮家伙才会使用 Symbol 值作为键名,并规定它的特殊含义,这样开发者就不会随意覆盖它了。

由于 Symbol 值是唯一的,因此不能使用点运算符(.)去访问,需通过方括号([])的形式才能访问。

除了 Symbol.iterator,还有很多内置的 Symbol 值。例如 Symbol. hasInstanceSymbol.toPrimitiveSymbol.toStringTag 等等,它们都有特定的含义。有时候也会对应称作 @@iterator@@toPrimitive 等。

在 JavaScript 中,有以下这些数据结构原生具备 Iterator 接口:

String、Array、TypedArray、Map、 Set、Arguments 和 NodeList。

因此我们不用任何处理,无需自己编写迭代器生成函数,就可以使用如 for...of 语句去遍历它们。for...of 内部会自动访问默认 Iterator 接口 Symbol.iterator

而普通对象并没有默认部署 Iterator 接口,因此我们不能使用 for...of 去迭代普通对象。但我们可以为它实现自定义迭代器接口。

实现迭代器接口,最简单的方法应该是使用 Generator 函数。

如何判断某种数据结构是否实现了 Iterator 接口,可以这样:

// Symbol.iterator 属性值为 undefined,表示未实现 Iterator 接口
console.log(1[Symbol.iterator]) // undefined
console.log({}[Symbol.iterator]) // undefined
console.log('abc'[Symbol.iterator]) // ƒ [Symbol.iterator]() { [native code] }
console.log([][Symbol.iterator]) // ƒ values() { [native code] }

五、自定义迭代器

普通对象,为什么不实现默认 Iterator 接口。主要是因为对象的属性是无序的,先遍历哪个属性,后遍历哪个属性是不确定的。若需要有序的对象结构,那不就 Map 对象嘛。因此,普通对象好像没必要默认部署迭代器接口了。

尽管原生未部署 Iterator 接口,我们可以自定义啊,标准又没有限制我们不能自定义对吧。

我们来定义一个 Counter 类,并实现 Iterator 接口。

1. 粗略版本

如下:

class Counter {
  constructor([min = 0, max = 10]) {
    this.min = min
    this.max = max
  }

  // 实现迭代器协议
  next() {
    const isDone = this.min > this.max
    return {
      done: isDone,
      value: isDone ? undefined : this.min++
    }
    // 迭代器的 next() 方法,需要返回一个对象,
    // 对象包括 { done, value } 属性,
    // 其中 done 为 false,或 value 为 undefined 时,返回值可省略该属性。
    // 这里返回 this(即实例对象),它的原型上实现了 next() 方法
  }

  // 实现可迭代协议
  [Symbol.iterator]() {
    return this
    // @@iterator 方法返回一个迭代器,它是一个对象。
    // 不能返回类似 return 1 等无意义的值,否则进行迭代操作时会报错。
  }
}

以上 Counter 类实现了可迭代协议和迭代器协议,因此其实例对象就具备了 Iterator 接口,可使用 for...of 进行迭代。

const counter = new Counter([0, 3]) // { min: 0, max: 3 }
for (const x of counter) {
  console.log(x)
}
// 依次打印出:0、1、2、3

尽管实现了 Iterator 接口,但不理想。原因是每个实例只能被迭代一次,而且实例的 min 属性值会发生变化。

// 上一个代码块里面,counter 实例对象已迭代过一次
// 现在尝试再迭代一次,以下代码并不会打印出什么
for (const x of counter) {
  console.log(x)
}

// 原因很简单:
// 当我们再次使用 for...of 之前,counter 实例已经变成:{ mix: 4, max: 3 }
// 然后执行到 for...of 的时候,内部做了以下这些步骤:
// 1) 首先创建一个迭代器,即访问实例的 @@iterator 方法,
//    返回的迭代器就是 this,值为 { mix: 4, max: 3 }。
// 2) 接着循环,其实就是不停调用迭代器的 next() 方法,即 this.next()
//    那么循环什么时候终止呢,那就是 done 为 true 时,表示遍历结束。
//    此时,第一次调用 next() 方法,返回值 done 就是为 true,即结束了。
//    因此,压根不会执行 for...of 的循环体,也就不会打印相关值了。
//
// 可通过以下示例去验证:
// for (const x of counter) {
//   console.log(x)
//   if (x === 1) break
// }
// 依次打印:0、1
// for (const x of counter) {
//   console.log(x)
// }
// 依次打印:2、3

2. 改进版本

鉴于以上原因,我们还需要改进一下。

为了让一个实例对象能够创建多个迭代器,必须每创建一个迭代器就对应一个新计算器。因此,我们可以把计算器变量放到闭包里,然后通过闭包返回迭代器。

class Counter {
  constructor([min = 0, max = 10]) {
    this.min = min
    this.max = max
  }

  [Symbol.iterator]() {
    let point = this.min
    const end = this.max
    return {
      next: () => {
        const isDone = point > end
        return {
          done: isDone,
          value: isDone ? undefined : point++
        }
      }
    }
  }
}

const counter = new Counter([0, 3])
for (const x of counter) {
  console.log(x)
}
// 依次打印出:0、1、2、3
for (const x of counter) {
  console.log(x)
}
// 依次打印出:0、1、2、3

以上这种改进写法,与数组迭代的表现是相似的。

3. 完善版本

但目前跟数组,还存在一点区别。例如:

// ✅ 数组可以这样使用
const arr = [0, 1, 2, 3]
const iter = arr[Symbol.iterator]()
for (const x of iter) {
  console.log(x)
}
// 依次打印:0、1、2、3

// ❌ 但是如果 Counter 这样去使用
const counter = new Counter([0, 3])
const iter = counter[Symbol.iterator]()
// 当代码执行到 for...of 这行的时候,发现就会报错:TypeError: iter is not iterable
for (const x of iter) {
  console.log(x)
}
// 原因分析:
// 其实很简单,使用 for...of 的时候,内部会自动访问 iter[Symbol.iterator],
// 但 iter 是仅含有 next 属性(不计原型)的普通对象,并没有 Symbol.iterator 属性,
// 因此判断 iter 不是一个可迭代对象,所以报错:TypeError: iter is not iterable

解决方法也很简单,只要我们给 iter 添加一个 Symbol.iterator 属性,并返回其本身即可。

class Counter {
  constructor([min = 0, max = 10]) {
    this.min = min
    this.max = max
  }

  [Symbol.iterator]() {
    let point = this.min
    const end = this.max
    return {
      [Symbol.iterator]() {
        // 不能使用箭头函数,否则 this 指向就不对了
        // 因此,我也把下面 next 方法,将原来的箭头函数改回普通函数
        return this
      },
      next() {
        const isDone = point > end
        return {
          done: isDone,
          value: isDone ? undefined : point++
        }
      }
    }
  }
}

再次执行,就可以了。

const counter = new Counter([0, 3])
const iter = counter[Symbol.iterator]()
for (const x of iter) {
  console.log(x)
}
// 依次打印出:0、1、2、3

4. 更完善版本

假设我们在 for...of 循环使用 breakcontinuereturnthrow 提前退出,或者使用解构操作未消费所有值时,我们希望就此“关闭”迭代器(即 done 变为 true)。例如,生成器对象若提前退出,它的状态就会关闭。

怎么实现呢?

ECMAScript 标准为我们提供了一个“可选”的 return() 方法,它属于迭代器协议的一部分。

由于 return() 方法是可选的,因此并非所有数据结构的默认迭代器都是可关闭的。比如,数组的迭代器就是不可关闭的。而生成器对象就是可关闭的。

数组迭代器示例:

const arr = [1, 2, 3, 4, 5]
const iter = arr[Symbol.iterator]()

for (const x of iter) {
  console.log(x)
  if (x > 3) break
}
// 依次打印:1、2、3

for (const x of iter) {
  console.log(x)
}
// 依次打印:4、5

// 需要注意的是:
// `for (const x of arr) {}` 与 `for (const x of iter) {}` 是有区别的,
// 前者每次调用,内部都会产生一个全新的迭代器,因此每次迭代,都会输出 0 ~ 5;
// 而后者则同一个迭代器,因此再次迭代时,会接着遍历下一个成员。
// 所以,不用误以为数组的迭代器是“可关闭的”。

生成器对象示例:

function* generatorFn() {
  yield 0
  yield 1
  yield 2
  yield 3
}

// 生成器对象,也是一个迭代器。
const gen = generatorFn() // generatorFn {<suspended>}
for (const x of gen) {
  console.log(x)
  if (x === 1) break
}
console.log(gen) // generatorFn {<closed>}
// 迭代器“关闭”之后,再次去迭代的话就没意义了,它不会打印任何东西。
for (const x of gen) {
  // do something...
}

其实 return() 的实现要求也很简单,它必须返回一个有效的 IteratorResult 对象。一般情况,可以只返回 { done: true }。因为这个返回值只会用在生成器的上下文中。

我们基于前面的 Counter 类,修改一下:

class Counter {
  constructor([min = 0, max = 10]) {
    this.min = min
    this.max = max
  }

  [Symbol.iterator]() {
    let point = this.min
    const end = this.max
    let isDone
    return {
      [Symbol.iterator]() {
        return this
      },
      next() {
        isDone = isDone || (point > end)
        return {
          done: isDone,
          value: isDone ? undefined : point++
        }
      },
      return() {
        console.log('Exiting early')
        isDone = true
        return { done: true }
        // 只会在“提前退出”才会触发此方法,
        // 正常遍历结束,是不会执行此方法的。
      }
    }
  }
}

上面除了实现了 return() 方法,且在触发 return() 方法时,迭代器也会“关闭”,只要将原先表示是否结束的 isDone 变量提到外面一层,利用闭包即可实现。

以下为“提前退出”的情况,可以看到都触发了此方法。

const counter = new Counter([0, 3])
for (const x of counter) {
  console.log(x)
  if ( x === 1 ) break // 或 throw 'xxx' 或 return 都行
}
// 依次打印出:0、1、"Exiting early"

const [a, b] = counter
// "Exiting early"

利用迭代器实例的 return 属性,就可以判断一个可迭代对象是否具备可关闭的 Iterator 接口。但注意,我们不能给一个不可关闭的迭代器仅增加 return() 方法,使其变成可关闭的。除非自定义 Iterator 接口去覆盖原生的 Symbol.iterator 方法。

5. 最终版本

既然又要提前关闭,为什么不直接利用 Generator 函数呢,简单又快捷。

class Counter {
  constructor([min = 0, max = 10]) {
    this.min = min
    this.max = max
  }

  *[Symbol.iterator]() {
    let point = this.min
    const end = this.max
    while (point <= end) {
      yield point++
    }
  }
}

一般情况下,Generator 函数是实现自定义迭代器的一种选择。它非常强大,语法上更为简练。但如果要实现类似数组默认迭代器那样的话,就不能用它了。

若对 Generator 函数不太熟悉,可看这篇文章

六、Iterator 应用场景

无论是默认的迭代器接口,还是自定义迭代器接口,它们主要供以语法或方法消费。目前有以下这些方法:

这些语法(方法)内部都会去访问(可迭代)对象的 @@iterator 方法,或叫作 Symbol.iterator 方法。

The end.