返回首页 深度解析 ECMAScript 6

容器

关于 ES6 的规范,官方命名为 ECMA-262 第六版本。采用的 ECMAScript 2015 语言规范,清除了最后的障碍并得到了 Ecma 组织的认可。祝贺 TC39 和每个为 ES6 规范做出贡献的人。这本书就讲解了 ES6 !

更好的消息:下一个版本更新将不会再是六年之后。标准协会计划在十二个月内产生一个新版本。关于第七版本已经在开发中。

现在是时候来庆祝关于 JS 的最新版本,并且我仍然认为 JS 的未来还有很大提升。

艰难地共同进化

JS 与其它编程语言不太一样,有时候它能以一种意想不到的方式在影响其他语言的发展。

ES6 模块就是一个很好的例子。其他语言也有模块系统。Racket 语言,Python 语言都有。当标准协会决定加入模块到 ES6 当中,为什么不直接复制已经存在的模块系统呢?

JS 是不一样的,因为它是直接在 web 浏览器上运行。I/O 操作将耗费很长的时间。这样, JS 需要一个提供异步支持的模块系统。它不能忍受在多个目录下搜索模块。显然,复制已有的系统是不可取的。这样 ES6 模块系统需要一些新的东西。

关于如何影响最终的设计是一个有趣的故事。但是这里我们不讨论。

这一部分关于 ES6 标准被称为 “键值的容器” :Set,Map,WeakSet,和 WeakMap。在大多数方面,这些 “键值容器” 的特征类似其他语言的哈希表。但是标准协做了一些有趣的事来权衡 “键值容器” 和哈希表,因为 JS 是与众不同的。

为什么会有容器?

任何一个对 JS 熟悉的人都知道 JS 中已经存在和哈希表类似的结构: objects 。

一个纯对象毕竟只是一个开放式的键-值对的集合。你可以获得,设置,和删除属性,或者迭代——所有哈希表可以做的操作。因此,为什么需要添加一个新的特征呢?

许多程序用纯对象来存储键-值对,并且在程序运行很好的情况下,没有特殊原因的选择 Map 或者 Set 。这里仍然存在一些关于使用对象(objects )所存在的众所周知的问题:

  • Objects 被用作查找表的时候,没办法在没有冲突的情况下同时拥有方法。
  • 这样程序要么用 Object.create(null) (而不是一个纯对象 { })或者需要努力避免将内置函数 (像 Object.prototype.toString)误以当作数据。
  • 键通常是字符串类型(或者,在 ES6 中,符号类型)。对象不能被作为键。
  • 没有有效的方法来获得对象的属性的个数。

ES6 增加了新的考虑:纯对象是不可迭代的,因此它们将无法用 for-of 循环或者 ... 等操作符。

大量的程序中都认为纯对象是一个不错的选择。 Map 和 Set 作为其他情况下的选择。

因为它们被用来避免用户数据和内置方法之间的冲突,在 ES6 容器中不用公开它们的数据属性。这就意味着像 obj.key 或者 obj[key] 将不能被用来访问哈希表数据。你就只有写 map.get(key) 了。通常,哈希表实体不像属性一样,不能通过原型链被继承。

不像纯对象一样, Map 和 Set 提供了方法,很多方法都能在标准类或之类当中被添加,而且没有冲突。

Set

一个 Set 是一组值的集合。该集合是可变的,所以你可以通过编程来增加或者删除值。到目前为止, Set 就像是一个数组。尽管 Set 和数组如此的相似,但是他们两者之间同样存在许多差异。

首先,不像数组,一个 Set不允许有重复的值。如果你试图增加一个 Set 中已经存在的值,那么你等于什么都没有做。

> var desserts = new Set("1","2","3","4");
> desserts.size
    4
> desserts.add("1");
    Set [ "1", "2", "3", "4" ]
> desserts.size
    4

这个例子用到了字符, Set 能够包含 JS 提供的任意类型值。 当只用字符串时,当你添加已经在 Set 中存在的字符串时,对于 Set 来说没有任何效果。

其次, 一个 Set 会有效的组织它的数据以便可以高效的进行操作:

> // Check whether "zythum" is a word.
> arrayOfWords.indexOf("zythum") !== -1  // slow
    true
> setOfWords.has("zythum")               // fast
    true

你不能根据索引获得 Set 中的值:

> arrayOfWords[15000]
    "anapanapa"
> setOfWords[15000]   // sets don't support indexing
    undefined    

下面给出关于 Set 的所有操作:

  • new Set 创建一个新的空 Set。
  • new Set(iterable) 创建一个新的 Set 并用任意的迭代值来填充。
  • set.size 获得 Set 的长度。
  • set.has(value) 当 Set 中有这个值返回 true 。
  • set.add(value) 添加一个值到 Set 中。如果 Set 中已经存在这个值了,那么什么也不会发 生。
  • set.delete(value) 删除 Set 中的一个值。如果 Set 中不存在这个值,同样什么也不会发生。 add( ) 和.delete( ) 都会返回 Set 本身,所以你必须将这些操作串起来。
  • set[Symbol.iterator]( ) 返回一个 Set 上的迭代器。你通常不会直接调用,但是这个方法会使得 Set 可迭代。这就意味着你可以直接这么写 for (v of set) {...} 。
  • set.forEach(f) 这个代码太简单了,你可以看看下面的例子:
   for (let value of set)
       f(value, value, set);

    这个方法和数组的  .forEach( )  类似。
  • set.clear( ) 去掉 Set 中的所有值。
  • set.keys( ),set.values( ),和 set.entries( ) 返回各种迭代器。这是为了提供和 Map 类型的兼容性。关于 Map 类型将会在后续进行讲解。

在所有的这些特性中,new Set(iterable) 构造器就像一个发电室一样,因为它在整个数据结构上操作。你可以用它将一个数组转换为一个 Set, 用一行代码就可以删除重复的值。或者,它可以收集产生的值来构造 Set 。这个构造器也复制了现有的 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 返回一个新的空 Map 。
  • new Map(pairs) 创建一个新的 Map , 并且从已有的 [key, value] 键值对来填充该 Map。键值对可以来自已经存在的 Map 对象,也可以是一个含有两个元素的数组,还可以是一个产生两个元素的数组生成器,等等。
  • map.size 获得 Map 中实体的数量。也指的是 Map 的长度。
  • map.has(key) 获得 Map 中键为 key 对应的那个值。如果没有存在这个 key 键,得到一个未定义符。
  • map.set(key, value) 添加一个键值对到 Map 中,如果该 Map 已经存在同样的键,那么就把该键对应的值更新为现在的值(就像 obj[key] = value)。
  • map.delete(key) 删除 Map 中 key 键和对应的值( 就像 delete obj[key] )。
  • map.clear( ) 删除 Map 中的所有键值对。
  • map[Symbol.iterator]( ) 返回 Map 键 值对的一个迭代器。该迭代器像是一个数组一样表示每个条目 [key, value] 。
  • map.forEach(f) 以下面的方式工作:
for (let [key, value] of map)
  f(value, key, map);

临时的参数顺序可以类比于 Array.prototype.forEach( ) 方法。

  • map.keys( ) 返回 Map 中所有键的迭代器。
  • map.values( ) 返回 Map 中所有值的迭代器。
  • map.entries( ) 返回 Map 中所有条目的迭代器,就像之前介绍的 map[Symbol.iterator]( ) 方法。事实上,就是 map[Symbol.iterator]( ) 方法的另外一个名字。

这里我们又抱怨什么呢?这里有些我认为有用的特性,但是在 ES6 中没有提到:

  • 一个设置默认值的工具,就像 Python 中的 collections.defaultdict 。
  • 一个辅助函数,Map.fromObject(obj) 使得用对象语法来构建 Map 更简单。

这些特性都很容易添加。

OK。记得这一章节的时候我们就就埋下一个疑问,在浏览器中运行这种独特的形式到底是如何影响 JS 语言特性的设计呢?这里,我们将来揭开谜底。我准备了三个例子。这里先看前面两个。

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

我可以告诉你,ES6 中有个有用的特性,那就是容器类不支持所有的类型。

假设我们有一个关于 URL 对象的 Set。

var urls = new Set;
urls.add(new URL(location.href));  // two URL objects.
urls.add(new URL(location.href));  // are they the same?
alert(urls.size);  // 2

上面的两个 URL 对象都应该被视为等价的。它们有着同样的域。但是在 JavaScript 中,这两个对象是有区别的,并且没有任何方式可以重载语言概念上的相等。所以上面的例子中最后输出的 alert(urls.size) 是 2。

其他的语言支持这种语言概念上的相等性。 Java ,Python,和 Ruby,每个类都能支持重载相等。在许多方案的实现中,哈希表可以使用不同的相等关系来创建。 C++ 支持两者。

然而,上面的所有机制都需要用户实现传统的哈希函数和所有显示系统的默认哈希函数。标准协会选择不公开 JS 的哈希代码——至少,不是现在——由于关于互用和安全的开放性问题,暂时没有公开 JS 的哈希代码。

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

你可能认为来自计算机的确定性行为很难让人感到惊奇。但是当我告诉开发者后,Map 和 Set 迭代访问条目的顺序是你们插入到容器的顺序,他们通常会感到惊奇。访问容器的顺序是确定性的。

我们习惯于认为哈希表是任意的。我们已经习惯接受这样的认识。但是避免这种任意性是有原因的。正如我在2012年所写的一样:

  • 已经有证据表明程序员当发现任意迭代顺序时是惊奇的或者最初是迷惑的。
  • 属性枚举在 ECMAScript 中未指明,为了 Web 的兼容性,大量的实现都强制覆盖插入顺序。这样,如果 TC39 没有明确一个确定性的迭代顺序, “ web 将会为我们指定一个顺序”。
  • 哈希表的迭代顺序能够公开一些对象的哈希代码。这将会要求哈希函数的实现者在实现哈希函数的时候考虑到完全性的因素。举个例子,一个对象的地址不能从公开的哈希代码中恢复。(把对象地址展示给 ECMAScript 代码,如果不是自身所用,将会是 Web 上的一个糟糕的安全漏洞。)

这些都在 2012 年 2 月被讨论到,我赞成任意迭代顺序。然后我用实验来展示了保持插入顺序将会使得哈希表操作很慢。我写了少量的 C++ 微基准。结果令我很吃惊。

这样,我们最终在 JS 中跟踪哈希表的插入顺序。

强烈建议使用弱容器

上周,我们讨论一个涉及到 JS 动画库的例子。我们试图在每个 DOM 对象中存储一个布尔类型的 flag ,就像下面这样:

if (element.isMoving) {
  smoothAnimations(element);
}
element.isMoving = true;

不幸的是,在一个 DOM 对象上设置属性是一个糟糕的想法,原稿中有讨论原因。

原稿展示了如何用符号( symbols )来解决这个问题。但是我们不能用 Set 来解决么?可以像下面这样:

if (movingSet.has(element)) {
  smoothAnimations(element);
}
movingSet.add(element);

这样做的方法有个缺点:Map 和 Set 对象会对每个键和值保持一个强引用。这就意味着如果一个 DOM 元素从文档中除去,垃圾收集器不能恢复内存直到从 movingSet 中除去。

库函数虽然取得了成功,但强加给用户使用后要清除的操作。所以这样如果用户忘了清除,容易造成内存溢出。

ES6 对此提供了一个令人惊讶的修复。把 movingSet 变成一个 WeakSet ,而不是一个 Set。内存溢出就会解决!

这意味着可以用弱容器或者是符号来解决这一问题。那么哪一个更好呢?关于讨论哪一种方法更好,需要很长的篇幅。如果你在 web 页面都只用一种符号完成所有操作,那么这样做也可行。如果你需要用多个符号来完成,那么这样做是有危险的。你最好考虑用弱容器来完成。

WeakMap 和 WeakSet

WeakMap 和 WeakSet 在行为上有点像 Map 和 Set , 但是有些限制:

  • WeakSet 只支持 new,.has( ),.add( ),和 .delete( ) 操作。
  • WeakSet 中的值和 WeakMap 中的键一定要是对象。

注意到弱容器是不能迭代的。

这些精心设计的限制使得垃圾收集器能收集到死亡的对象。效果类似于你获得弱引用或者一个弱键字典,但是 ES6 的弱容器使得内存管理可以在不揭示 GC 情况下完成,这是大有益处的。

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

简而言之, 一个 WeakSet 对于它所包含的对象没有一个强引用。当你的 WeakSet 收集到一个对象的时候,该对象很容易被 WeakSet 删除。WeakMap 是同样的道理。它也不需要维护一个键的强引用。如果一个键时可用的,引用就是可用的。

为什么要接受这样的限制呢?为什么要在 JS 中增加弱引用呢?

标准协会很不情愿揭示脚本的不确定性。性能不好的浏览器是 Web 发展的障碍。弱引用揭示了潜在垃圾收集器的实现细节——平台特有的任意行为。当然,应用不依赖于平台的实现细节,但是弱引用使得我们很难知道我们对于浏览器有多依赖。这里很难解释清楚。

与之相反的是, ES6 的弱容器有一些限制的特征集,但是这些特征固定的。事实上一个键或者一个值被收集是很难被观察到的,因此这些应用不能依赖于浏览器。

这是一个基于浏览器考虑的例子,从而引出了一个令人惊讶的设计决策,使 JS 成为更好的语言。

我们什么时候能够在代码中用到容器?

四种容器类目前已在 Firefox ,Chrome, Microsoft Edge 和 Safari 中实现。为了支持老的浏览器,需要用 es6-collections 工具来辅助。

在 Firefox 中, WeakMap 最早由其首席技术官 Andreas Gal, 实现。 Tom Schuster 实现了 WeakSet 。我实现了 Map 和 Set 。感谢 Tooru Fujisawa 提供的补丁。

上一篇: 符号 下一篇: 继续迭代器