超快的 super 属性访问

发布时间 · 标签: JavaScript

super 关键字可用于访问对象的父对象的属性和函数。

以前,访问 super 属性(如 super.x)是通过运行时调用实现的。 从 V8 v9.0 开始,我们在未优化的代码中重用了内联缓存 (IC) 系统,并生成了用于 super 属性访问的应有的优化代码,而不必跳到运行时。

从下图中可以看到,由于运行时调用,super 属性访问曾经比普通属性访问慢一个数量级。现在,我们已经接近同等水平。

将 super 属性访问与常规属性访问进行比较(已优化)
将 super 属性访问与常规属性访问进行比较(未优化)

Super 属性访问很难进行基准测试,因为它必须发生在函数内部。我们无法对单个属性访问进行基准测试,而只能针对更大范围的工作。因此,函数调用开销包含在测量中。上面的图表在某种程度上低估了 super 属性访问和普通属性访问之间的区别,但它们的准确性足以说明新旧 super 属性访问之间的区别。

在未优化(解释)模式下,super 属性访问将总是比普通属性访问慢,因为我们需要做更多的工作(从上下文读取主对象,并从主对象读取 __proto__)。在优化的代码中,我们已经尽可能将主对象(home object)作为常量嵌入。也可以通过将其 __proto__ 嵌入为常量来进一步改进。

原型继承和 super #

让我们从基础开始 - super 属性访问真正意味着什么?

class A { }
A.prototype.x = 100;

class B extends A {
m() {
return super.x;
}
}
const b = new B();
b.m();

现在,AB 的超类,b.m() 会按预期返回 100

类继承图

现实是 JavaScript 的原型继承更加复杂:

原型继承图

我们需要仔细区分 __proto__prototype 属性 - 它们并不代表同样的含义!让人更困惑的是,对象 b.__proto__ 通常被称为“b 的原型”。

b.__proto__ 对象是 b 的继承属性。B.prototype 作为使用 new B() 创建的对象的 __proto__ 对象,即 b.__proto__ === B.prototype

反过来,B.prototype 具有自己的 __proto__ 属性,该属性等于 A.prototype。这些共同构成了所谓的原型链:

b ->
 b.__proto__ === B.prototype ->
  B.prototype.__proto__ === A.prototype ->
   A.prototype.__proto__ === Object.prototype ->
    Object.prototype.__proto__ === null

通过该链,b 可以访问在任何这些对象中定义的所有属性。方法 mB.prototype的属性(即 B.prototype.m),这就是 b.m() 起作用的原因。

现在,我们可以将 m 内的 super.x 定义为属性查找(property lookup),在该属性查找中,我们开始在主对象(home object)__proto__ 中查找属性 x,并沿着原型链向上移动直到找到它。

主对象(home object)是定义方法的对象 - 在这种情况下,m 的主对象是 B.prototype。它的 __proto__A.prototype,因此我们从这里开始寻找属性 x。 我们将 A.prototype 称为查找起始对象(lookup start object)。在这种情况下,我们可以在查找起始对象中立即找到属性 x,但通常它也可能位于原型链的更远处。

如果 B.prototype 具有一个名为 x 的属性,我们将忽略它,因为我们开始在原型链中的上方寻找它。另外,在这种情况下,超级属性查询不依赖于接收者(receiver),即调用该方法时 this 值指向的对象。

B.prototype.m.call(some_other_object); // still returns 100

如果该属性具有读取器(getter),则接收者(receiver)将作为 this 值传递给读取器。

总结一下:在 super 属性访问 super.x 中,查找起始对象是主对象的 __proto__,接收者是发生 super 属性访问的方法的接收者。

在普通的属性访问 o.x 中,我们开始在 o 中寻找属性 x,然后到原型链上。如果 x 恰好有一个读取器,我们还将使用 o 作为接收者 - 查找起始对象和接收者是同一对象(o)。

Super 属性访问就像常规属性访问一样,其中查找起始对象(lookup start object)和接收者(receiver)不同。

实现更快的 super #

以上的分析结论也是实现快速 super 属性访问的关键。V8 已经过设计,可以快速进行属性访问 - 现在,我们针对接收者和查找起始对象不同的情况将其通用化。

V8 的数据驱动的内联缓存系统是实现快速属性访问的核心部分。你可以在高级介绍中阅读相关内容,也可以了解 V8 的对象表示以及 V8 的数据驱动的内联缓存系统是如何实现的 更多的细节描述。

为了提高 super 性能,我们添加了一个新的 Ignition 节代码 LdaNamedPropertyFromSuper,它使我们能够以解释模式插入 IC 系统,并生成用于 super 属性访问的优化代码。

使用新的字节码,我们可以添加新的 IC,LoadSuperIC,以加速 super 属性加载。与处理正常属性加载的 LoadIC 相似,LoadSuperIC 会跟踪已看到的查找起始对象的形状(shapes),并记住如何从具有这些形状之一的对象中加载属性。

LoadSuperIC 将现有的 IC 机制重新用于属性加载,只是具有不同的查找起始对象。由于 IC 层已经在查找起始对象和接收者之间进行了区分,因此实现起来应该很容易。但是,由于查找起始对象和接收者始终是相同的,因此即使我们指的是接收者,也存在一些使用查找起始对象的错误,反之亦然。这些错误已得到修复,现在我们可以正确的支持查找起始对象和接收者不同的情况。

用于 super 属性访问的优化代码由 TurboFan 编译器的 JSNativeContextSpecialization 阶段生成。该实现概括了现有的属性查找机制(JSNativeContextSpecialization::ReduceNamedAccess),以处理接收者和查找起始对象不同的情况。

当我们将主对象从存储它的 JSFunction 中移出时,优化的代码变得更加优化。现在,它存储在类上下文中,这使 TurboFan 尽可能将其作为常量嵌入到优化的代码中。

super 的其它用法 #

super 在对象字面(literal)方法内部的工作方式与类方法内部相同,并且进行了类似的优化。

const myproto = {
__proto__: { 'x': 100 },
m() { return super.x; }
};
const o = { __proto__: myproto };
o.m(); // returns 100

当然,有些情况我们没有进行优化。例如,写入 super 属性(super.x = ...)并未得到优化。此外,使用 mixins 会使 access site 变成 megamorphic,从而导致 super 属性访问速度变慢:

function createMixin(base) {
class Mixin extends base {
m() { return super.m() + 1; }
// ^ this access site is megamorphic
}
return Mixin;
}

class Base {
m() { return 0; }
}

const myClass = createMixin(
createMixin(
createMixin(
createMixin(
createMixin(Base)
)
)
)
);
(new myClass()).m();

要确保所有面向对象的模式都尽可能快,还有很多工作要做 - 敬请期待进一步的优化!