[翻译] 加速展开元素

Speeding up spread elements

Hai Dong 在他于V8团队的三个月实习期间,致力于提升[...array][...string][...set][...map.keys()]以及[...map.values()](当展开的元素位于数组字面量的起始)。他甚至让Array.from(iterable)的速度大幅提升。这篇文章解释了部分他所做的更改的细节,这些改动会在 V8 7.2 版本提供。

展开元素

展开元素是具有...iterable形式的数组字面量的组成。这一特性在 ES2015 中提出,作为一种从可迭代对象创建数组的新方式。比如,数组字面量[1, ...arr, 4, ...b]会创建一个首先是1,随后是arr的各个元素,其次是4,最后是b的各个元素的数组:

const a = [2, 3];
const b = [5, 6, 7];
const result = [1, ...a, 4, ...b];
// → [1, 2, 3, 4, 5, 6, 7]

另一个例子,任何字符串都可以展开为一个包含其所有字符(Unicode 码位)的数组:

const str = 'こんにちは';
const result = [...str];
// → ['こ', 'ん', 'に', 'ち', 'は']

相似的,一个集合也可以被展开为它所包含的元素的数组,按照迭代顺序排列:

const s = new Set();
s.add('V8');
s.add('TurboFan');
const result = [...s];
// → ['V8', 'TurboFan']

总而言之,数组字面量中的展开元素语法...x假定x提供一个迭代器(通过x[Symbol.iterator()]访问。然后通过该迭代器获取元素插入到结果数组。

将数组展开到一个新的数组,前后不添加其他元素的简单使用情景,即[...arr],被认为是 ES2015 中一种简洁、管用的浅拷贝方法。不幸的是,在 V8 中,这一操作的性能远低于 ES5 中的其他写法。Hai 的目标就是改变这一现状。

为什么展开元素(以前)这么慢?

有许多浅拷贝数组arr的方法。例如,你可以使用arr.slice(),或者arr.concat(),或者[...arr]。或者,你可以自己写一个clone函数,通过一个标准的for循环进行浅拷贝:

function clone(arr) {
  // Pre-allocate the correct number of elements, to avoid
  // having to grow the array.
  const result = new Array(arr.length);
  for (let i = 0; i < arr.length; i++) {
    result[i] = arr[i];
  }
  return result;
}

在理想情况下,所有这些方法应该具有接近的性能。不幸的是,如果你选择了[...arr],在 V8 中它将会比clone函数慢。其原因在于 V8 将[...arr]转译为类似这样的代码:

function(arr) {
  const result = [];
  const iterator = arr[Symbol.iterator]();
  const next = iterator.next;
  for ( ; ; ) {
    const iteratorResult = next.call(iterator);
    if (iteratorResult.done) break;
    result.push(iteratorResult.value);
  }
  return result;
}

这段代码比clone慢的几个主要原因是:

  1. 它需要在一开始通过读取和检查Symbol.interator属性来创建一个iterator
  2. 它在每次循环都要创建和查询iteratorResult对象。
  3. 它在每次循环中通过调用push增大result数组,导致空间的不断重新分配。

使用这一实现的原因是因为,正如之前所提到的,展开才做不仅可以被用于数组,实际上可以用于任何可迭代对象,而且必须遵循迭代规范。不过,V8 应该聪明地知道被展开的对象是不是一个数组,来在更底层提取元素,从而:

  1. 避免创建迭代器对象
  2. 避免创建迭代器结果对象
  3. 避免反复增长并重分配结果数组(我们已经提前知道元素的个数)

我们将这一简单的想法使用CSA实现,针对快速数组,例如拥有最常见的六种元素的数组。这一优化,应用于在数组字面量开始使用展开对象的真实世界情景,例如[...foo]。如下图所示,新的捷径在展开一个长度为 100,000 的数组时拥有获得了三倍的性能提升,比手写的clone循环快了 25%。

注意:虽然没有在此显示,但是这一捷径同样对后跟其他元素的用法有效(例如[...arr, 1, 2, 3],但前面有其他元素无效(例如[1, 2, 3, ...arr])。

仔细检查这一捷径

这一方法是令人心动的速度提升,但是我们必须仔细使用这一捷径是否正确:JavaScript 允许程序员以多种方法修改对象(甚至数组)的迭代表现。由于展开元素使用迭代规范,我们通过在原始迭代机制被修改时不使用捷径来确保这一修改符合规范。例如以下情景:

自身的Symbol.iterator属性

一般来说一个数组arr不会拥有它自己的Symbol.iterator属性,所以当我们查找该 symbol 时会在数组的原型上找到。下方的例子中,通过直接在arr上定义Symbol.iterator绕过了原型。在做这样的修改之后,在arr上查找Symbol.iterator会得到一个空迭代器,所以展开arr不会获得元素,展开的数组字面量会是一个空数组。

const arr = [1, 2, 3];
arr[Symbol.iterator] = function() {
  return { next: function() { return { done: true }; } };
};
const result = [...arr];
// → []

修改过的 %ArrayIteratorPrototype%

next方法也可以直接通过%ArrayIteratorPrototype%来修改,这是数组迭代器的原型(会影响所有数组)。

Object.getPrototypeOf([][Symbol.iterator]()).next = function() {
  return { done: true };
}
const arr = [1, 2, 3];
const result = [...arr];
// → []

处理稀疏数组

另一需要注意的点是复制稀疏数组(例如['a', , 'c'])这样缺失部分元素的数组。展开这样的数组,由于遵守迭代规范,展开这样的数组不会保留空洞,而是用相应索引处的数组原型中的值填充它们。默认情况下数组的原型中没有元素,意味着空洞将会被undefined填充。例如,[...['a', , 'c']]将会获得['a', undefined, 'c']

我们的捷径在这种默认情景下足够聪明来处理空洞。它不会盲目地复制输入数组的存储空间,而是会监视这些空洞并把它们小心的转换成undefined值。下图展示了展开一个有 100,000 个元素但只有 600 个整数其他均是空洞的数组的性能。展开这样一个充满空洞的稀疏数组现在比clone函数快四倍。(它们过去的性能大致相同,但在图中未显示)。

注意,虽然图中包含了slice方法的结果,但是与它比较是不公平的因为slice对稀疏数组拥有不同的语义,它会保留所有的空洞,所以少做了很多工作。

我们的捷径必须把空洞填充为undefined,这一操作并不跟它听起来一样简单:他可能需要把整个数组转换成另一种元素类型。下一张图片度量了这种情景。初始化和前文一样,除了这一次我们的 600 个数组元素是未拆封的 double 类型,数组的元素类型是HOLEY_DOUBLE_ELEMENTS。因为这一元素类型无法承载类似undefined的标记值,展开这一数组需要执行代价高昂的元素类型转换,这就是为什么[...a]的分数比上一张图低许多。不过,还是比clone(a)要快许多。

展开字符串、集合以及映射

跳过迭代器对象并避免增长结果数组的想法同样适用于传播其他标准数据类型。实际上,我们为原始字符串,集合和映射实现了类似的快速路径,每次在存在修改的迭代行为时都要小心地绕过它们。

关于集合,这一捷径不仅支持直接展开一个集合([...set]),还支持展开它的键迭代器([...set.keys()])和他的值迭代器([...set.values()])。在我们的微基准测试中,这些操作现在比以前快了 18 倍。

对于映射的捷径也是类似的,但是并不支持直接展开一个映射([...map]),因为我们认为这是一个不常用的操作。由于某些原因,这一捷径也不支持.entries()迭代器。在我们的微基准测试中,这些操作现在比以前快了大约14倍。

对于展开字符串([...string]),我们测得了大约五倍的性能提升,在下图中以紫色和绿色线条表示。注意,这甚至比在下图中以蓝色和粉色显示的 TurboFan 优化的 for-of 循环要快。(TurboFan 可以理解字符串迭代并为其生成优化后的代码)。在每种情况下具有两个图标的原因是微基准测试在在两个不同的字符串表示上操作(单字节字符串和双字节字符串)。

提升Array.form的性能

幸运的是,我们的展开元素的捷径同样可以重用于Array.from,只要传入Array.from的是一个可迭代对象并且不包含映射函数。(例如Array.from([1, 2, 3]))。可以重用的原因是在这种情况下,Array.from的表现与展开操作一致。它导致了巨大的性能提升,如下所示,对于具有100个双精度数的数组。

结论

V8 v7.2 / Chrome 72 大幅提升了展开元素的性能,当他们在数组字面量的最前使用,例如[...x]或者[...x, 1, 2]。这一提升应用于展开数组、原始字符串、映射的键、映射的值,以及Array.from(x)