深入 ES6:集合

深入 ES6 是一个系列,介绍了在 ECMAScript 标准的第 6 版(简称 ES6)中添加到 JavaScript 编程语言的新功能。

本周早些时候,ES6 规范(正式名称为 _ECMA-262, 第 6 版, ECMAScript 2015 语言规范_)通过了最后的障碍,被批准为 Ecma 标准。祝贺 TC39 和所有做出贡献的人。ES6 已完成!

更棒的消息是:下一次更新不会再等六年。标准委员会现在计划大约每 12 个月发布一个新版本。 第 7 版的提案 已经开始开发。

因此,在这个场合谈论我一直渴望在 JS 中看到的东西,并且我认为它还有改进的空间,再合适不过了!

共同进化的难题

JS 与其他编程语言不太一样,有时这会以出人意料的方式影响语言的演变。

ES6 模块就是一个很好的例子。其他语言有模块系统。Racket 有一个很棒的模块系统。Python 也有。当标准委员会决定在 ES6 中添加模块时,为什么他们没有直接复制现有的系统呢?

JS 与众不同,因为它在 Web 浏览器中运行。I/O 可能需要很长时间。因此,JS 需要一个能够支持异步加载代码的模块系统。它也不能负担得起在多个目录中按顺序搜索模块。复制现有的系统并不好。ES6 模块系统需要做一些新的事情。

这如何影响最终的设计是一个有趣的故事。但我们今天不是来谈论模块的。

这篇文章是关于 ES6 标准称之为“键值集合”的内容:SetMapWeakSetWeakMap。在大多数方面,这些功能就像其他语言中的 哈希表。但标准委员会在此过程中做了一些有趣的权衡,因为 JS 与众不同。

为什么需要集合?

任何熟悉 JS 的人都知道,语言中已经内置了一些类似哈希表的东西:对象。

毕竟,一个普通的 Object 几乎就是一个开放式的键值对集合。您可以获取、设置和删除属性,迭代它们——所有哈希表可以做的事情。那么为什么要添加一个新功能呢?

嗯,许多程序确实使用普通对象来存储键值对,对于这些程序来说,如果这能很好地工作,就没有特别的理由切换到 MapSet。不过,使用这种方式的对象有一些众所周知的问题。

  • 用作查找表的对象不能同时具有方法,除非存在一些冲突风险。

  • 因此,程序必须使用 Object.create(null)(而不是普通的 {}),或者小心避免将内置方法(如 Object.prototype.toString)误解为数据。

  • 属性键始终是字符串(或者,在 ES6 中,是符号)。对象不能作为键。

  • 没有有效的方法来询问对象有多少个属性。

ES6 添加了一个新的问题:普通对象不是 可迭代的,因此它们不会与 for-of 循环、... 运算符等合作。

再说一次,有很多程序并不关心这些问题,而普通对象将继续是正确选择。MapSet 适用于其他情况。

由于它们旨在避免用户数据与内置方法之间的冲突,因此 ES6 集合 _不会_ 将它们的数据暴露为属性。这意味着表达式如 obj.keyobj[key] 不能用于访问哈希表数据。您必须编写 map.get(key)。此外,与属性不同,哈希表条目 _不会_ 通过原型链继承。

优点是,与普通的 Object 不同,MapSet 确实有方法,并且可以添加更多方法,无论是在标准中还是在您自己的子类中,都不会发生冲突。

Set

Set 是一个值的集合。它是可变的,因此您的程序可以在运行时添加和删除值。到目前为止,这与数组非常相似。但是,集合和数组之间的差异和相似之处一样多。

首先,与数组不同,集合永远不会包含相同的值两次。如果您尝试向集合中添加一个已经存在的 value,则不会发生任何事情。

<pre>
> var desserts = new Set("🍪🍦🍧🍩");
> desserts.size
4
> desserts.add("🍪");
Set [ "🍪", "🍦", "🍧", "🍩" ]
> desserts.size
4
</pre>

此示例使用字符串,但 Set 可以包含任何类型的 JS 值。与字符串一样,多次添加相同对象或数字不会有任何额外影响。

其次,Set 会保持数据的组织,以使一项特定操作速度更快:成员资格测试。

<pre>
> // 检查 “zythum” 是否是一个单词。
> arrayOfWords.indexOf("zythum") !== -1 // 慢
true
> setOfWords.has("zythum") // 快
true
</pre>

您在 Set 中无法获得的是索引

<pre>
> arrayOfWords[15000]
"anapanapa"
> setOfWords[15000] // 集合不支持索引
undefined
</pre>

以下是集合的所有操作

  • new Set 创建一个新的空集合。

  • new Set(iterable) 创建一个新的集合,并用 任何可迭代值 中的数据填充它。

  • set.size 获取集合中的值数量。

  • set.has(value) 如果集合包含给定值,则返回 true

  • set.add(value) 向集合中添加一个值。如果该值已经存在于集合中,则不会发生任何事情。

  • set.delete(value) 从集合中删除一个值。如果该值不存在于集合中,则不会发生任何事情。.add().delete() 都返回集合对象本身,因此您可以将它们链接起来。

  • set[Symbol.iterator]() 返回一个集合中值的新的迭代器。您通常不会直接调用它,但此方法是使集合可迭代的原因。这意味着您可以编写 for (v of set) {...} 等等。

  • set.forEach(f) 最容易用代码解释。它就像以下代码的简写

    <pre>
    for (let value of set)
    f(value, value, set);
    </pre>

    此方法类似于数组上的 .forEach() 方法。

  • set.clear() 从集合中删除所有值。

  • set.keys()set.values()set.entries() 返回不同的迭代器。这些是为与 Map 保持兼容性而提供的,因此我们将在下面讨论它们。

在所有这些功能中,构造函数 new Set(iterable) 最为突出,因为它是在整个数据结构级别上运行的。您可以使用它将数组转换为集合,使用一行代码消除重复值。或者,将一个生成器传递给它:它将运行生成器直到完成,并将生成的 value 收集到一个集合中。此构造函数也是您复制现有 Set 的方式。

我上周承诺要抱怨 ES6 中的新的集合。我将从这里开始。Set 虽然很好,但缺少一些方法,这些方法将成为未来标准的很好的补充

  • 已经存在于数组上的函数助手,例如 .map().filter().some().every()

  • 非变异 set1.union(set2)set1.intersection(set2)

  • 可以同时对多个值执行操作的方法:set.addAll(iterable)set.removeAll(iterable)set.hasAll(iterable)

好消息是,所有这些都可以使用 ES6 提供的方法有效地实现。

Map

Map 是一个键值对的集合。以下是 Map 可以做的事情

  • new Map 返回一个新的空映射。

  • new Map(pairs) 创建一个新的映射,并用来自现有 [key, value] 对集合的数据填充它。_pairs_ 可以是现有的 Map 对象、一个包含两个元素的数组、生成两个元素的数组的生成器等。

  • map.size 获取映射中的条目数量。

  • map.has(key) 测试一个键是否存在(就像 key in obj 一样)。

  • map.get(key) 获取与一个键关联的值,如果不存在这样的条目,则返回 undefined(就像 obj[key] 一样)。

  • map.set(key, value) 向映射中添加一个条目,将 _key_ 与 _value_ 关联,覆盖任何具有相同键的现有条目(就像 obj[key] = value 一样)。

  • map.delete(key) 删除一个条目(就像 delete obj[key] 一样)。

  • map.clear() 从映射中删除所有条目。

  • map[Symbol.iterator]() 返回一个映射中条目的迭代器。迭代器将每个条目表示为一个新的 [key, value] 数组。

  • map.forEach(f) 的工作方式如下

    <pre>
    for (let [key, value] of map)
    f(value, key, map);
    </pre>

    奇怪的参数顺序同样是为了与 Array.prototype.forEach() 保持一致。

  • map.keys() 返回一个映射中所有键的迭代器。

  • map.values() 返回一个映射中所有值的迭代器。

  • map.entries() 返回一个映射中所有条目的迭代器,就像 map[Symbol.iterator]() 一样。实际上,它只是同一个方法的另一个名称。

有什么可抱怨的呢?以下是一些 ES6 中不存在但我认为有用的功能

  • 用于默认值的工具,例如 Python 的 collections.defaultdict

  • 一个辅助函数 Map.fromObject(obj),可以轻松地使用对象字面量语法编写映射。

再说一次,这些功能很容易添加。

好的。还记得我是如何用一些关于在浏览器中运行的独特问题如何影响 JS 语言特性设计的话来开始这篇文章的吗?这就是我们开始谈论这些问题的地方。我这里有三个例子。以下的前两个。

JS 与众不同,第一部分:没有哈希码的哈希表?

据我所知,ES6 集合类完全不支持一个有用的功能。

假设我们有一个包含 URL 对象的 Set

<pre>
var urls = new Set;
urls.add(new URL(location.href)); // 两个 URL 对象。
urls.add(new URL(location.href)); // 它们相同吗?
alert(urls.size); // 2
</pre>

这两个 URL 实际上应该被认为是相等的。它们具有所有相同的字段。但是在 JavaScript 中,这两个对象是不同的,并且无法重载语言的等式概念。

其他语言支持这一点。在 Java、Python 和 Ruby 中,各个类可以重载等式。在许多 Scheme 实现中,可以创建使用不同等式关系的单个哈希表。C++ 支持两者。

但是,所有这些机制都需要用户实现自定义哈希函数,并且所有机制都公开系统的默认哈希函数。委员会选择不在 JS 中公开哈希码,至少现在还没有,因为有关互操作性和安全性的问题尚待解决,这些问题在其他语言中并不那么紧迫。

JS 与众不同,第二部分:惊喜!可预测性!

你可能会认为计算机的确定性行为不可能令人感到惊讶。但当我说 MapSet 迭代按将条目插入集合的顺序访问条目时,人们经常会感到惊讶。它是确定的。

我们习惯了哈希表的某些方面是任意的。我们已经学会接受它。但是,有充分的理由尝试避免随意性。正如我在 2012 年所写

  • 有证据表明,一些程序员发现任意的迭代顺序最初令人惊讶或困惑。[1][2][3][4][5][6]
  • 属性枚举顺序在 ECMAScript 中未指定,但所有主要实现都不得不收敛到插入顺序,以与 Web 现状兼容。因此,人们担心,如果 TC39 不指定确定性迭代顺序,“网络将只为我们指定它”。[7]
  • 哈希表迭代顺序可能会公开对象哈希码的某些位。这给哈希函数实现者带来了惊人的安全问题。例如,对象地址不应从其哈希码的公开位中恢复。(向不受信任的 ECMAScript 代码公开对象地址,虽然本身不可利用,但将是 Web 上的严重安全漏洞。)

当所有这一切在 2012 年 2 月被讨论时,我争论支持任意的迭代顺序。然后,我着手通过实验表明,跟踪插入顺序会使哈希表速度过慢。我编写了一些 C++ 微基准测试。 结果让我吃惊。

这就是我们在 JS 中最终使用跟踪插入顺序的哈希表的原因!

使用弱集合的强有力理由

上周,我们讨论了一个涉及 JS 动画库的示例。我们希望为每个 DOM 对象存储一个布尔标志,如下所示

<pre>
if (element.isMoving) {
smoothAnimations(element);
}
element.isMoving = true;
</pre>

不幸的是,在 DOM 对象上设置扩展属性(如上所述)是个坏主意,因为原始文章中讨论了原因。

那篇文章展示了如何使用符号解决这个问题。但我们不能使用 Set 做同样的事情吗?它可能看起来像这样

<pre>
if (movingSet.has(element)) {
smoothAnimations(element);
}
movingSet.add(element);
</pre>

只有一个缺点:MapSet 对象对它们包含的每个键和值都保持强引用。这意味着,如果 DOM 元素从文档中删除并丢弃,垃圾回收无法回收该内存,直到该元素也从 movingSet 中删除为止。库通常在对用户施加复杂的自我清理要求方面取得了参差不齐的成功,充其量如此。因此,这可能会导致内存泄漏。

ES6 为此提供了一个令人惊讶的解决方法。将 movingSet 设为 WeakSet 而不是 Set。内存泄漏已解决!

这意味着可以使用弱集合或符号来解决这个特定问题。哪个更好?不幸的是,对权衡的全面讨论会使这篇文章过长。如果可以在网页的整个生命周期内使用单个符号,那么这可能没问题。如果你最终想要许多短暂的符号,这是一个危险的信号:考虑使用 WeakMap 而不是使用符号来避免内存泄漏。

WeakMapWeakSet

WeakMapWeakSet 被指定为行为与 MapSet 完全相同,但有一些限制

  • WeakMap 只支持 new.has().get().set().delete()

  • WeakSet 只支持 new.has().add().delete()

  • 存储在 WeakSet 中的值以及存储在 WeakMap 中的键必须是对象。

请注意,两种类型的弱集合都不能迭代。你无法从弱集合中获取条目,除非通过专门请求它们,传入你感兴趣的键。

这些精心设计的限制使垃圾回收器能够从活动的弱集合中收集死对象。效果类似于使用弱引用或弱键字典可以获得的效果,但 ES6 弱集合在不向脚本公开 GC 发生的事实的情况下获得了内存管理优势。

JS 与众不同,第三部分:隐藏 GC 非确定性

在幕后,弱集合被实现为短暂表.

简而言之,WeakSet 不会对它包含的对象保持强引用。当 WeakSet 中的对象被回收时,它将简单地从 WeakSet 中删除。WeakMap 类似。它不会对任何键保持强引用。如果键还活着,关联的值也还活着。

为什么要接受这些限制?为什么不直接将弱引用添加到 JS 中?

同样,标准委员会一直非常不愿意向脚本公开非确定性行为。跨浏览器兼容性差是 Web 开发的 bane。弱引用公开了底层垃圾回收器的实现细节,这是特定于平台的任意行为的定义。当然,应用程序不应依赖特定于平台的细节,但弱引用也使得很难知道你究竟在多大程度上依赖于你当前正在测试的浏览器的 GC 行为。它们难以推理。

相比之下,ES6 弱集合的功能集更加有限,但该功能集非常可靠。键或值已被回收的事实永远不会被观察到,因此应用程序不会意外地依赖它。

这是一种情况,其中特定于 Web 的问题导致了一个令人惊讶的设计决策,使 JS 成为一种更好的语言。

我什么时候可以在代码中使用集合?

所有四个集合类目前都在 Firefox、Chrome、Microsoft Edge 和 Safari 中发布。要支持旧版浏览器,请使用 polyfill,例如 es6-collections.

WeakMap 最初由 Andreas Gal 在 Firefox 中实现,他后来担任了 Mozilla 的 CTO 一职。Tom Schuster 实现了 WeakSet。我实现了 MapSet。感谢 Tooru Fujisawa 在这个领域贡献了多个补丁。

下周,ES6 深入探究将开始为期两周的夏季休假。本系列已经涵盖了许多内容,但 ES6 中一些最强大的功能尚未发布。因此,请在我们于 7 月 9 日发布新内容时加入我们。

关于 Jason Orendorff

更多 Jason Orendorff 的文章...


11 条评论

  1. Jordan

    为什么 JavaScript 花了这么长时间才获得如此基本的功能?我们已经在 C++ 中的 STL、Java、C#、Python、Perl、Ruby 和许多其他语言中使用了这些数据结构,时间已经很久了。为什么 JavaScript 总是落后于时代?

    你用 JavaScript 的朋友
    Jordan

    2015 年 6 月 20 日 下午 1:51

    1. Jason Orendorff

      在 Map 和 Set 的情况下,我认为缺乏紧迫感是因为普通对象在常见情况下也能正常工作。

      另一种看待问题的方式是:JS 已经拥有灵活的对象字面量表达式和函数,已经很多年了。为什么其他语言还没有赶上来?为什么 JS 比时代超前这么多?

      2015 年 6 月 24 日 上午 6:51

    2. Alexander Ivantsov

      因为 JS 已经是非常棒的语言。
      正如 Jason 所注意到的,JS 包含了许多其他语言没有的东西。JS 比 C++、C# 等更好,因为它更小更灵活。

      2015 年 6 月 29 日 上午 5:00

  2. simonleung

    has 比 indexOf 快约 10 倍。毫无疑问,如果你需要频繁查找数据,就应该使用 Set/Map。

    2015 年 6 月 23 日 下午 8:30

  3. verigais

    如果我没记错的话

    for (let value of set)
    f(value, value, set);

    应该是

    for (let value of set)
    f(KEY, value, set);

    2015 年 7 月 7 日 上午 3:45

    1. Igor Ovsiannikov

      @verigais

      我认为代码

      for (let value of set)
      f(value, value, set);

      是正确的,因为 Set 不是 Map,它保存值,但不是键值对。也就是说,没有 Key 和 Value 的概念,只有 Value。那么为什么要将相同的值作为两个不同的参数传递呢?

      Jason Orendorff 写道:“此方法类似于数组上的 .forEach() 方法。”
      因此,我认为签名是为了向后兼容。

      2015 年 7 月 8 日 上午 2:50

    2. Jason Orendorff

      Set 只是值的集合 - 没有键。

      Igor 说的没错:签名是为了与 Array.prototype.forEach() 兼容。

      forEach() 有点奇怪。如果可以的话,使用 for-of 循环!

      2015 年 7 月 8 日 下午 3:11

  4. verigais

    感谢 @Igor Ovsiannikov 和 Jason Orendorff。
    现在我明白了。

    2015 年 7 月 10 日 上午 8:56

  5. Qbe Root

    Set API 使其看起来很像一个 Map,其中每个值都是它自己的键。我不会感到惊讶,在某些实现中会发现 Set.prototype.get 或 Set.prototype.set...

    2015 年 7 月 13 日 上午 8:45

  6. karthick siva

    感谢 Jason。你的帖子总是让我学到更多,了解更多。“弱引用公开了底层垃圾回收器的实现细节,这是特定于平台的任意行为的定义。” - 你能解释一下这方面吗?

    2015 年 7 月 15 日 上午 9:11

    1. Jason Orendorff

      当然!

      弱引用是指不会保护被引用对象免遭 GC 回收的引用。

      那么,如果该对象确实被回收了会怎样?弱引用无法引用不再存在的对象。因此,它的值必须更改为 null 或类似的东西。

      但这将意味着普通的 JavaScript 代码可以检测到对象何时被收集!这很简单:创建一个指向该对象的弱引用。然后定期检查弱引用是否已被置空。到目前为止,还没有办法做到这一点。如果你试图使用普通(强)引用来做到这一点,你只会成功地保留那些本来会被收集的对象。

      通过这种方式暴露的实现细节包括(1)完全收集周期的计时,以及(2)分代收集器中世代晋升的细节。

      如果弱引用得到广泛支持,一些应用程序和框架可能会使用我刚刚描述的技术来实现终结。也就是说,如果你可以检测到对象何时被 GC,你可以在发生这种情况时进行一些清理(例如取消事件监听器)。但这些应用程序可能会观察到清理发生在不同浏览器中的不同时间,因为 GC 的工作不是尽快积极地收集所有对象!相反,GC 的工作是在没有暂停或性能降低的情况下降低内存使用量,这通常意味着尽可能长时间地推迟 GC。

      可能很多人都会同意(a)当 JS 错误只在一个浏览器中重现时很糟糕,以及(b)弱引用听起来很有用,我们应该在 JS 中使用它们。但是现在你对它了解更多了,相信这两件事就会让你陷入逻辑困境,对吧?知道一些事实真的会让人束手无策!真让人头疼!:)

      感谢你的问题。

      2015 年 7 月 15 日 下午 2:00

本文评论已关闭。