为 JavaScript 实现私有字段

这篇文章是从 Matthew Gaudet 的博客转载

在为 JavaScript 实现语言功能时,实现者必须决定如何将规范中的语言映射到实现中。有时这相当简单,规范和实现可以共享大部分相同的术语和算法。其他时候,实现中的压力会使它更具挑战性,要求或迫使实现策略偏离语言规范。

私有字段就是一个例子,说明规范语言和实现现实存在分歧的地方,至少在SpiderMonkey(为 Firefox 提供支持的 JavaScript 引擎)中是这样。为了更好地理解,我将解释什么是私有字段、思考它们的几种模型,以及解释为什么我们的实现与规范语言不同。

私有字段

私有字段是正在通过TC39提案流程添加到 JavaScript 语言中的语言功能,作为类字段提案的一部分,该提案在 TC39 流程中处于第 4 阶段。我们将在 Firefox 90 中发布私有字段和私有方法。

私有字段提案向语言添加了对“私有状态”的严格定义。在以下示例中,#x 只能被类 A 的实例访问

class A {
  #x = 10;
}

这意味着在类之外,不可能访问该字段。例如,与公有字段不同,如下例所示

class A {
  #x = 10; // Private field
  y = 12; // Public Field
}

var a = new A();
a.y; // Accessing public field y: OK
a.#x; // Syntax error: reference to undeclared private field

甚至 JavaScript 提供的用于检查对象的各种其他工具也被阻止访问私有字段(例如,Object.getOwnProperty{Symbols,Names} 不会列出私有字段;没有办法使用 Reflect.get 来访问它们)。

三种方式的功能

在谈论 JavaScript 中的功能时,通常会涉及三个不同的方面:心理模型、规范和实现。

心理模型提供了我们期望程序员主要使用的最高级思维。规范反过来提供了功能所需的语义细节。实现可以与规范文本截然不同,只要维护规范语义即可。

这三个方面不应该为人们推理事物产生不同的结果(尽管有时“心理模型”是速记,并且没有准确地捕获边缘情况下的语义)。

我们可以使用这三个方面来看待私有字段

心理模型

一个人对私有字段可以拥有的最基本的心理模型是:顾名思义,字段,但它是私有的。现在,JS 字段成为对象上的属性,因此心理模型可能是“无法从类外部访问的属性”。

但是,当我们遇到代理时,这种心理模型会有点崩溃;试图为“隐藏属性”和代理指定语义很具有挑战性(如果代理试图提供对属性的访问控制,而你又不应该看到代理的私有字段会发生什么?子类可以访问私有字段吗?私有字段是否参与原型继承?)为了保留所需的隐私属性,另一种心理模型成为委员会思考私有字段的方式。

这种替代模型称为“WeakMap”模型。在这个心理模型中,你可以想象每个类都有一个与每个私有字段关联的隐藏弱映射,这样你就可以假设“反糖化”

class A {
  #x = 15;
  g() {
    return this.#x;
  }
}

成类似以下内容

class A_desugared {
  static InaccessibleWeakMap_x = new WeakMap();
  constructor() {
    A_desugared.InaccessibleWeakMap_x.set(this, 15);
  }

  g() {
    return A_desugared.InaccessibleWeakMap_x.get(this);
  }
}

WeakMap 模型令人惊讶地不是在规范中编写功能的方式,但它是它们背后的设计意图的重要组成部分。我将在后面介绍这种心理模型如何在后面的一些地方出现。

规范

实际的规范更改由类字段提案提供,特别是对规范文本的更改。我不会介绍规范文本的每个部分,但我会指出特定方面以帮助阐明规范文本和实现之间的差异。

首先,规范添加了对[[PrivateName]]的概念,它是一个全局唯一的字段标识符。这种全局唯一性是为了确保两个类不能仅仅通过具有相同的名称来访问彼此的字段。

function createClass() {
  return class {
    #x = 1;
    static getX(o) {
      return o.#x;
    }
  };
}

let [A, B] = [0, 1].map(createClass);
let a = new A();
let b = new B();

A.getX(a); // Allowed: Same class
A.getX(b); // Type Error, because different class.

规范还添加了一个新的“内部槽”,它是在规范级别与规范中对象关联的内部状态的一部分,称为[[PrivateFieldValues]] 到所有对象。[[PrivateFieldValues]] 是一个记录列表,其形式为

{
  [[PrivateName]]: Private Name,
  [[PrivateFieldValue]]: ECMAScript value
}

为了操作此列表,规范添加了四个新算法

  1. PrivateFieldFind
  2. PrivateFieldAdd
  3. PrivateFieldGet
  4. PrivateFieldSet

这些算法基本上按预期工作:PrivateFieldAdd 将一个条目附加到列表中(不过,为了尽力提供提前错误,如果列表中已经存在匹配的私有名称,它将抛出 TypeError。我将在后面展示如何发生这种情况)。PrivateFieldGet 通过给定的私有名称检索存储在列表中的值,依此类推。

构造函数覆盖技巧

当我第一次开始阅读规范时,我惊讶地发现 PrivateFieldAdd 可能会抛出异常。鉴于它只从正在构建的对象上的构造函数中调用,我完全期望该对象会是新创建的,因此你无需担心字段已经存在。

事实证明,这是可能的,规范处理构造函数返回值方式的一些副作用。更具体地说,以下是 André Bargull 提供给我的一个示例,它展示了这一点。

class Base {
  constructor(o) {
    return o; // Note: We are returning the argument!
  }
}

class Stamper extends Base {
  #x = "stamped";
  static getX(o) {
    return o.#x;
  }
}

Stamper 是一个可以将它的私有字段“盖印”到任何对象的类

let obj = {};
new Stamper(obj); // obj now has private field #x
Stamper.getX(obj); // => "stamped"

这意味着当我们将私有字段添加到对象时,我们不能假设它以前不存在。这就是 PrivateFieldAdd 中预先存在检查发挥作用的地方

let obj2 = {};
new Stamper(obj2);
new Stamper(obj2); // Throws 'TypeError' due to pre-existence of private field

这种将私有字段盖印到任意对象的能力也与 WeakMap 模型稍微交互。例如,鉴于你可以将私有字段盖印到任何对象,这意味着你也可以将私有字段盖印到密封对象

var obj3 = {};
Object.seal(obj3);
new Stamper(obj3);
Stamper.getX(obj3); // => "stamped"

如果你将私有字段想象成属性,这会让人感觉不舒服,因为这意味着你正在修改一个被程序员密封以防止未来修改的对象。但是,使用弱映射模型,这是完全可以接受的,因为你只是将密封对象用作弱映射中的键。

PS:仅仅因为你可以将私有字段盖印到任意对象,并不意味着你应该这样做:请不要这样做。

实现规范

在面对实现规范时,存在遵循规范字面意思和做一些不同的事情以在某些方面改进实现之间的矛盾。

在能够直接实现规范步骤的地方,我们更喜欢这样做,因为它使在进行规范更改时更容易维护功能。SpiderMonkey 在许多地方都这样做。你将看到代码部分是规范算法的转录,带有步骤号的注释。在规范非常复杂且微小的差异会导致兼容性风险的情况下,遵循规范的字面意思也可能会有所帮助。

然而,有时有充分的理由偏离规范语言。多年来,JavaScript 实现一直致力于高性能,并且已经应用了许多实现技巧来实现这一点。有时,将规范的一部分重新表达为已经编写的代码是正确的做法,因为这意味着新代码也能具有已经编写的代码的性能特征。

实现私有名称

私有名称的规范语言几乎已经与Symbols 周围的语义相匹配,Symbols 已经存在于 SpiderMonkey 中。因此,将 PrivateNames 添加为一种特殊类型的 Symbol 是一个相当容易的选择。

实现私有字段

查看私有字段的规范,规范实现将是为 SpiderMonkey 中的每个对象添加一个额外的隐藏槽,其中包含对 {PrivateName, Value} 对列表的引用。但是,直接实现这一点有许多明显的缺点

  • 它为没有私有字段的对象增加了内存使用量
  • 它需要侵入性地添加新的字节码或复杂性,以提高性能敏感的属性访问路径。

另一种选择是偏离规范语言,并仅实现语义,而不是实际的规范算法。在大多数情况下,你实际上可以将私有字段视为对象上的特殊属性,这些属性对类之外的反射或自省隐藏起来。

如果我们将私有字段建模为属性,而不是与对象一起维护的特殊边侧列表,我们就可以利用属性操作在 JavaScript 引擎中已经非常优化的这一事实。

但是,属性受反射的影响。因此,如果我们将私有字段建模为对象属性,我们需要确保反射 API 不会泄露它们,并且你不能通过代理获得对它们的访问权限。

在 SpiderMonkey 中,我们选择将私有字段实现为隐藏属性,以便利用引擎中已有的所有针对属性的优化机制。当我开始实现这个特性时,André Bargull(一位多年来一直为 SpiderMonkey 做贡献的开发者)给了我一系列补丁,其中已经完成了大部分私有字段实现的工作,我对此非常感谢。

使用我们特殊的私有名称符号,我们有效地将

class A {
  #x = 10;
  x() {
    return this.#x;
  }
}

转换为更接近

class A_desugared {
  constructor() {
    this[PrivateSymbol(#x)] = 10;
  }
  x() {
    return this[PrivateSymbol(#x)];
  }
}

然而,私有字段与属性的语义略有不同。它们旨在对预期为编程错误的模式发出错误,而不是默默地接受它。例如

  1. 访问一个对象上不存在的属性会返回 undefined。私有字段被指定为抛出一个 TypeError,这是 PrivateFieldGet 算法 的结果。
  2. 在一个对象上设置一个不存在的属性会简单地添加该属性。私有字段将在 PrivateFieldSet 中抛出一个 TypeError
  3. 在已经存在该字段的对象中添加一个私有字段也会在 PrivateFieldAdd 中抛出一个 TypeError。请参见上面的“构造函数覆盖技巧”,了解这种情况是如何发生的。

为了处理不同的语义,我们修改了私有字段访问的字节码生成。我们添加了一个新的字节码操作码 CheckPrivateField,它会验证一个对象对于给定的私有字段是否具有正确的状态。这意味着在适当的情况下针对 Get/Set 或 Add 抛出异常(如果属性丢失或存在)。CheckPrivateField 在使用常规的“计算属性名”路径(用于 A[someKey] 的路径)之前被生成。

CheckPrivateField 的设计使得我们可以轻松地使用 内联缓存CacheIR。由于我们将私有字段存储为属性,我们可以使用对象的 Shape 作为保护,并简单地返回相应的布尔值。SpiderMonkey 中对象的 Shape 决定了它具有哪些属性,以及这些属性在该对象的存储中的位置。具有相同 Shape 的对象保证具有相同的属性,并且它是 CheckPrivateField 的 IC 的完美检查。

我们所做的其他修改包括从属性枚举协议中省略私有字段,以及在添加私有字段时允许扩展密封对象。

代理

代理为我们带来了一个新的挑战。具体来说,使用上面的 Stamper 类,您可以直接将私有字段添加到代理中

let obj3 = {};
let proxy = new Proxy(obj3, handler);
new Stamper(proxy)

Stamper.getX(proxy) // => "stamped"
Stamper.getX(obj3)  // TypeError, private field is stamped
                    // onto the Proxy Not the target!

一开始我确实觉得这很奇怪。我感到惊讶的原因是我预计,与其他操作一样,私有字段的添加会通过代理传递到目标对象。然而,一旦我能够理解 WeakMap 的思维模型,我就能够更好地理解这个例子。诀窍在于,在 WeakMap 模型中,是 Proxy 而不是目标对象作为 #x WeakMap 中的键。

这些语义对我们选择将私有字段建模为隐藏属性的实现选择提出了挑战,因为 SpiderMonkey 的代理是高度专业化的对象,没有空间容纳任意属性。为了支持这种情况,我们为“expando”对象添加了一个新的保留槽。expando 是一个懒加载分配的对象,它充当代理上动态添加属性的持有者。这种模式已经用于 DOM 对象,它们通常被实现为没有额外属性空间的 C++ 对象。因此,如果您写下 document.foo = "hi",这将为 document 分配一个 expando 对象,并将 foo 属性和值放在其中。回到私有字段,当在代理上访问 #x 时,代理代码会知道去 expando 对象中查找该属性。

结论

私有字段是实现 JavaScript 语言特性的一个例子,其中直接实现规范中所写的内容的性能将低于将规范重新转换为已优化的引擎原语。然而,这种重新转换本身可能需要一些规范中没有的解决问题。

最后,我对我们实现私有字段所做的选择感到相当满意,并期待它最终出现在世界中!

致谢

我再次要感谢 André Bargull,他提供了第一组补丁,并为我留下了很棒的路线。他的工作使完成私有字段变得容易得多,因为他已经对决策进行了很多思考。

Jason Orendorff 在我完成这个实现的过程中一直是一位出色且耐心的导师,包括对私有字段字节码的两次独立实现,以及对代理支持的两次独立实现。

感谢 Caroline Cullen 和 Iain Ireland 帮助阅读本文的草稿,并感谢 Steve Fink 帮助修正了许多错别字。

关于 Matthew Gaudet

Matthew Gaudet 的更多文章...


2 条评论

  1. A I

    感谢您的文章。了解底层机制非常有趣。

    我以前不知道构造函数技巧是可能的。我想知道有多少人真正使用它,以及为什么这不是简单地改变规范以更好地符合最可能使用情况的案例。

    2021 年 6 月 11 日 下午 5:18

    1. Matthew Gaudet

      当我发现它时,我深入研究了它存在的原因,并在 这里 写了一篇关于它的文章。本质上,这都归结于试图与一些类之前的模式保持兼容,这是我的结论。

      2021 年 6 月 15 日 上午 9:25

本文的评论已关闭。