JavaScript需要更多用于迭代的辅助函数(map、filter等)--我们应该把它们放在哪里?

186 阅读12分钟

迭代是一个连接操作和数据容器的标准。每个遵循这个标准的操作,都可以应用于每个实现这个标准的数据容器。

在这篇博文中。

  • 我们首先探讨三个问题。
    • JavaScript的迭代是如何工作的?
    • 它有哪些怪异之处?
    • 迭代的辅助函数是什么样子的?例子包括Array方法的迭代版本.map(),.filter(), 和.forEach()
  • 接下来,我们将研究实现辅助函数的几种方法的利弊。作为数据容器的方法?作为函数?等等。其中两种方法得到了具体建议的支持。
  • 这篇文章最后解释了迭代和基于迭代的辅助函数的额外好处。

目录。


JavaScript迭代和它的古怪之处

什么是迭代?

迭代是在ECMAScript 6中添加到JavaScript的。这个_协议_有两个方面(接口和使用规则)。

  • 一个数据生产者(比如一个数据结构)可以实现迭代协议,并通过它暴露其输出(或内容)。
  • 一个数据消费者(如一个算法)可以通过迭代协议检索其输入。

一个实现了迭代协议的数据生产者被称为_可迭代_。这个术语也被用作形容词:"可迭代数据结构"。

迭代的一个关键好处是,每个使用迭代的数据消费者,都可以和每个可迭代的数据生产者一起使用。

JavaScript标准库已经有几个基于迭代的数据生产者和数据消费者--例如。

  • 数据生产者。
    • 数组、地图、集合、字符串
    • array.keys() (不是数组的可迭代对象)的结果
    • map.entries() (不是数组的可迭代对象)的结果
  • 数据消费者
    • for-of
    • Array.from()
    • 扩散到数组中 ([...input])
    • 展开到函数调用中 (func(...input))

唉,JavaScript还不支持许多基于迭代的算法。以下是三个辅助函数的例子,会很有用。

  • map :列出对一个可迭代的每个值调用回调的结果。
  • filter: 列出一个回调返回的迭代器的所有值true
  • forEach: 对一个可迭代的每个值调用一个回调。

mapfilter 的输入和输出都是可迭代的,这意味着我们可以连锁这些操作。

核心迭代实体:迭代器和迭代者

迭代协议中最重要的两个实体是。

  • Iterable。这个实体是保存数据的容器。它通过作为_迭代器_的工厂来公开这些数据。
  • 迭代_器_。这个实体返回迭代器中包含的每个值,一次一个(想想数据库中的游标)。

一个对象obj ,通过实现一个方法成为可迭代器。

  • obj[Symbol.iterator]() :该方法返回_迭代器_。

一个_迭代器_ iter 是一个通过一个方法来传递数值的对象。

  • iter.next() :该方法返回具有两个属性的对象。
    • .value: 包含当前值
    • .done: 只要还有值,就是true ,之后是false

这就是使用迭代器在实践中的样子。

> const iterable = ['a', 'b'];
> const iterator = iterable[Symbol.iterator]();
> iterator.next()
{ value: 'a', done: false }
> iterator.next()
{ value: 'b', done: false }
> iterator.next()
{ value: undefined, done: true }

迭代器也是可迭代的

当实现一个迭代器时,一个常见的技术是使该迭代器也成为一个迭代器。

在A行,我们不返回一个新的对象,而是返回this

这种技术有三个好处。

  1. 我们的代码变得更简单。
  2. 我们可以对迭代器进行迭代。
  3. 生成器函数和方法可以用来实现迭代器和迭代器。

我们先来看看第2个优点,然后是第3个优点。

我们可以对可迭代的迭代器进行迭代

在下面代码的B行,我们可以继续我们在A行开始的迭代。

生成器返回可迭代的迭代器,可以同时实现可迭代的迭代器和迭代器

所有由JavaScript标准库创建的迭代器都是可迭代的。生成器返回的对象也既是迭代器又是可迭代的。

因此,我们可以使用生成器来实现迭代器。

function* iterArgs(...args) {
  for (const arg of args) {
    yield arg;
  }
}
const iterable = iterArgs('red', 'green', 'blue');
assert.deepEqual(
  Array.from(iterable),
  ['red', 'green', 'blue']
);

但是我们也可以用它们来实现迭代器(A行)。

class ValueContainer {
  #values;
  constructor(...values) {
    this.#values = values;
  }
  * [Symbol.iterator]() { // (A)
    for (const value of this.#values) {
      yield value;
    }
  }
}

const iterable = new ValueContainer(1, 2, 3);
assert.deepEqual(
  Array.from(iterable),
  [1, 2, 3]
);

可迭代的迭代器的弊端

有了可迭代的迭代器,我们现在有两种迭代器。

一方面,有可迭代的迭代器,我们可以随心所欲地进行迭代。

function iterateTwice(iterable) {
  return [...iterable, ...iterable];
}

const iterable1 = ['a', 'b'];
assert.deepEqual(
  iterateTwice(iterable1),
  ['a', 'b', 'a', 'b']);

另一方面,还有一些迭代器,我们只能在上面迭代一次。

从概念上讲,事情变得更加混乱了。

  • 一方面,for-of 、spreading等只接受可迭代项。
  • 另一方面,诸如生成器、array.keys()map.entries() 等构造返回的迭代器恰好也是可迭代的。

%IteratorPrototype% :标准库中所有迭代器的原型

在ECMAScript规范中,内部对象是用百分号括起来的。一个这样的对象是 %IteratorPrototype%:

尽管这个对象不能从JavaScript中直接访问,但我们可以间接地访问它。

const IteratorPrototype = Object.getPrototypeOf(
  Object.getPrototypeOf(
    [][Symbol.iterator]()
  )
);

这个对象是标准库创建的所有迭代器的原型(直接或间接)。

> IteratorPrototype.isPrototypeOf('abc'[Symbol.iterator]())
true
> IteratorPrototype.isPrototypeOf([].keys())
true
> IteratorPrototype.isPrototypeOf(new Map().entries())
true
> IteratorPrototype.isPrototypeOf((function* () {})())
true
> IteratorPrototype.isPrototypeOf('aaa'.matchAll(/a/g))
true

迭代帮助器的一个提议取决于这样一个事实:%IteratorPrototype% 是许多迭代器的原型。我们将在研究该提议时了解细节。

把迭代器的辅助函数放在哪里?

办法:迭代器的方法

如果我们希望能够像使用数组一样使用迭代辅助函数的方法链,那么在概念上最简洁的方法就是让这些辅助函数成为迭代对象的方法。这可以看成是这样。

class Iterable {
  * map(mapFn) {
    for (const item of this) {
      yield mapFn(item);
    }
  }
  * filter(filterFn) {
    for (const item of this) {
      if (filterFn(item)) {
        yield item;
      }
    }
  }
  toArray() {
    return [...this];
  }
}
class Set2 extends Iterable {
  #elements;
  constructor(elements) {
    super();
    // The real Set eliminates duplicates here
    this.#elements = elements;
  }
  [Symbol.iterator]() {
    return this.#elements[Symbol.iterator]();
  }
  // ···
}

现在我们可以这样做了。

const arr = new Set2([0, -1, 3, -4, 8])
  .filter(x => x >= 0)
  .map(x => x * 2)
  .toArray()
;
assert.deepEqual(
  arr, [0, 6, 16]
);

迭代对象的方法的好处和坏处

如果我们想让我们的帮助器成为方法,那么迭代器就是它们的正确位置。除了链式之外,方法的另一个好处是,如果特定的类可以更有效地实现某个操作(通过类的特定功能),就可以覆盖该操作的默认实现。

唉,我们面临着一个致命的障碍:我们不能改变现有类的继承层次(尤其是不能改变Array )。这使得我们无法使用这种方法。

办法:包装迭代变量

另一种启用方法链的方法是通过jQuery和Underscore库在JavaScript世界中流行的技术。我们将我们想要操作的对象包裹起来,并通过这种方式向它们添加方法。

function iter(data) {
  return {
    filter(filterFn) {
      function* internalFilter(data) {
        for (const item of data) {
          if (filterFn(item)) {
            yield item;
          }
        }
      }
      return iter(internalFilter(data));
    },
    map(mapFn) {
      function* internalMap(data) {
        for (const item of data) {
          yield mapFn(item);
        }
      }
      return iter(internalMap(data));
    },
    toArray() {
      return [...data];
    },
  };
}

const arr = iter(new Set([0, -1, 3, -4, 8]))
  .filter(x => x >= 0)
  .map(x => x * 2)
  .toArray()
;
assert.deepEqual(
  arr, [0, 6, 16]
);
  • 这种方法的好处。这种方法的好处是:它在概念上很简洁。不需要任何变通,我们不需要以任何方式改变迭代。
  • 我们可以用方法链来帮助调用。
  • 这种方法的缺点:包装引入了额外的开销。

方法:为迭代器引入一个超类

然而,另一种获得方法链的方法是通过向迭代器添加辅助方法。有一个ECMAScript语言建议采用这种方法。

那是怎么做的呢?正如我们所看到的,所有由标准库创建的迭代器在其原型链中已经有了对象%IteratorPrototype% 。我们可以将这个原型完成为一个完整的类。

有趣的是,由于instanceof 的工作方式和由于Iterator.prototype 是标准库中每个迭代器的原型,许多对象已经是Iterator 的实例。

> 'abc'[Symbol.iterator]() instanceof Iterator
true
> [].values() instanceof Iterator
true
> new Map().entries() instanceof Iterator
true
> (function* () {})() instanceof Iterator
true
> 'aaa'.matchAll(/a/g) instanceof Iterator
true

这就是使用新方法的样子。

处理不同种类的迭代器

使用这种方法,我们必须区分三种迭代器。

  • 不是迭代器的迭代器(Arrays, Sets, Maps, results ofObject.keys(), 等等)。
  • 扩展了Iterator 的可迭代的迭代器(array.keys(),map.entries(), 等等)。
  • 不扩展Iterator (在现有代码中)的可迭代的迭代器。

本提案提供了一个工具函数 Iterator.from()来以同样的方式处理所有这些情况。

  • 如果一个迭代器不是一个迭代器,则返回其迭代器。
  • 如果一个迭代器是一个迭代器,并且扩展了Iterator ,它将原样返回。
  • 如果一个迭代器是一个迭代器,并且没有扩展Iterator ,那么它就会包装该迭代器,使其拥有Iterator 的方法。

迭代器方法的优点

  • 与前两种方法一样,我们可以将方法连锁起来。
  • 类可以用更有效的方法覆盖默认的帮助器实现。

迭代器方法的坏处

  • 要求所有的迭代器都要扩展Iterator ,这是对原来迭代协议的一个重大改变。
    • 不是所有的现有代码都会被更新以满足这一要求。
    • 一些迭代器可能无法满足这一要求(例如子类)。
  • 我们必须区分三种迭代器:非迭代器的迭代器,扩展Iterator 的迭代器,不扩展Iterator 的迭代器。
    • 这可以通过Iterator.from() ,但这样我们就会得到一个类似于包装方法的API。
  • 如果帮助器方法是为可迭代器准备的,那么把它们放在迭代器中就是概念上的不匹配。
    • 这一点在使用Iterator.from() 时变得尤为明显:它将一个迭代器转换为一个迭代器,然后我们调用迭代器方法,再使用诸如for-of 这样的构造来处理结果。该结构体只接受可迭代对象,但Iterator 的实例也恰好是可迭代对象。
    • 如果一个操作接受一个以上的操作数(比如zip() ),这些操作数将是可迭代的,而this 将是Iterator 的一个实例。
    • 这种不匹配的实际后果是,我们必须先创建一个迭代器,然后才能对一个非迭代器的可迭代器应用一个帮助器。
    • 我同意关于迭代器与迭代器的现状已经有点混乱了。我希望不要让事情变得更糟。
  • 内置操作的集合不能被扩展。如果一个库想提供更多的迭代器助手,它们不能被添加到Iterator ,而可能是函数。
  • 与现有技术不同。已有的库,如JavaScript的Underscore/Lodash库和Python的itertools是基于函数的。

方法:函数

为迭代器提供帮助的既定方式实际上不是通过方法而是通过函数。例子包括Python的itertools和JavaScript的Underscore/Lodash。

这个库@rauschma/iterable,它的原型是这样的。它的实现大致是这样的。

注意,Iterable 并不是一个真正的对象,而是一个函数的命名空间/伪模块,类似于JSONMath

如果JavaScript有一个流水线操作符,我们甚至可以把这些函数当作方法来使用。

const {filter, map, toArray} = Iterable;

const arr = new Set([0, -1, 3, -4, 8])
  |> filter(x => x >= 0, ?)
  |> map(x => x * 2, ?)
  |> toArray(?)
;

函数的优点

  • 作为函数的帮助器遵循了Underscore/Lodash等人建立的传统。
  • 从概念上讲,函数作为可迭代的帮助器运行良好:操作数和结果总是可迭代的。
  • 这套帮助器可以被任何人扩展,因为任何函数都会和内置的帮助器属于同一类别。
    • 特别是创建可迭代项的函数(如range(start, end) )将是这个类别的重要补充。
  • 目前的迭代协议不需要改变。

函数的缺点

函数不允许方法链。然而。

  • 根据我的经验,链很少是长的。而且即使是长链,我也不介意引入中间变量(就像前面的例子中所做的那样)。
  • 最终,我们有望在JavaScript中得到一个流水线操作符,然后可以将辅助函数链起来(就像前面的例子一样)。

如果我们不进行链式运算,函数就很方便。

const filtered = filter(x => x, iterable);

相比之下,使用迭代器方法就是这个样子了。

const filtered1 = iterable.values().filter(x => x);
const filtered2 = Iterator.from(iterable).filter(x => x);

迭代和迭代帮助器的更多好处

在这篇博文中,我们只看到了_同步_迭代。当我们从迭代器中请求时,我们立即得到一个新的项目。

但也有 异步迭代其中代码会暂停,直到下一个项目可用。

异步迭代的一个重要用例是处理数据流(例如,网络流Node.js流)。

即使我们对一个异步迭代器应用多个操作,输入数据也是一次处理一个项目。这使我们能够处理非常大的数据集,因为我们不必在内存中保留所有的数据。而且我们能更快地看到输出(相比之下,先对完整的数据集应用第一个操作,然后对结果应用第二个操作,等等)。

总结

我们已经探索了迭代和实现迭代帮助器的四种方法。

  1. 迭代器的方法
  2. 包裹迭代器
  3. 为迭代器引入一个超类(建议)。
  4. 函数(建议)。

考虑到我们所面临的所有限制,我最喜欢的是(4),因为我们不必改变当前的迭代协议,它在概念上是干净的,而且内置的操作集可以被扩展。

如果(3)或(4)中的任何一个成为ECMAScript的一部分,我不指望以后会有另一种方法加入。因此,我们必须明智地选择。

关于迭代的进一步阅读

我的书 "JavaScript for impatient programmers "中的几个章节(可以在网上免费阅读)。