细读 ES6 | Iterator 迭代器

简介: 细读 ES6 | Iterator 迭代器

前言


迭代的英文是 “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 接口的对象,分为两部分:可迭代协议迭代器协议


简单来说:

  • 可迭代协议,是指对象实现了 @@iterator 方法(不管是本身属性,还是对象原型链上的属性都可以),并返回一个迭代器。可通过常量 Symbol.iterator 访问该属性。
  • 迭代器协议,是指 @@iterator 方法返回的迭代器实现了 next 方法。调用 next 方法返回一个 IteratorResult 对象,包括两个属性:{ done: true/false, value: '...' },其中 done 是一个布尔值,表示遍历是否结束;value 表示当前成员的值。


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


二、生成器简述


说到生成器,你们一定知道 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 遍历结束。


5128488-1bc7314ff63367b4.webp.jpg


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


上面实现了 return() 方法,以下“提前退出”的情况,可以看到都触发了此方法。

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 函数是实现自定义迭代器的一种选择。它非常强大,语法上更为简练。但如果要实现类似数组默认迭代器那样的话,就不能用它了。


六、Iterator 应用场景


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

  • for...of 循环
  • 数组解构
  • 扩展运算符
  • Array.from()
  • new Set()
  • new Map()
  • Promise.all()
  • Promise.race()
  • Promise.allSettled()
  • Promise.any()
  • yield* 操作符


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


The end.


目录
相关文章
|
7月前
|
JavaScript
ES6之迭代器
ES6之迭代器
|
7月前
|
JavaScript 前端开发
ES6之迭代器(Iterator)
ES6引入了迭代器的概念,这个特性为JavaScript带来了更强大的迭代和异步编程能力。本文将深入探讨ES6的迭代器,介绍其概念、用法以及在实际开发中的应用。 迭代器(Iterator)是ES6引入的一种新的数据结构,它提供了一种统一的遍历机制,可以用来遍历各种不同类型的数据。迭代器的概念、作用和遍历原理如下所述:
65 0
|
6月前
|
存储 Java
JavaSE——集合框架一(2/7)-Collection集合的遍历方式-迭代器、增强for循环、Lambda、案例
JavaSE——集合框架一(2/7)-Collection集合的遍历方式-迭代器、增强for循环、Lambda、案例
50 1
|
7月前
|
算法 C++ 容器
【C++进阶(四)】STL大法--list深度剖析&list迭代器问题探讨
【C++进阶(四)】STL大法--list深度剖析&list迭代器问题探讨
【C++STL】list的反向迭代器
【C++STL】list的反向迭代器
76 0
|
数据库
ES6 从入门到精通 # 14:迭代器 Iterator 的用法
ES6 从入门到精通 # 14:迭代器 Iterator 的用法
88 0
ES6 从入门到精通 # 14:迭代器 Iterator 的用法
|
JavaScript 前端开发
ES6——迭代器
迭代器(iterator):是一种接口,为各种不同的数据结构提供统一的访问机制。任何数据结构只要部署lterator接口,就可以完成遍历操作。
88 0
C#编程-94:迭代器Iterator简单实例
C#编程-94:迭代器Iterator简单实例
116 0
C#编程-94:迭代器Iterator简单实例