关于废止proposal-class-fields提案的建议

本文是系列文章,包括:

  • "Field提案"是什么东东 - 在这里
  • 关于废止proposal-class-fields提案的建议(本文)
  • 私有属性的实现 - 在这里
  • No prefix! operator is Ok! - 在这里
  • (未完待续)

本文是对一份tc39提案的讨论。原有提案:https://github.com/tc39/proposal-class-fields

在本文中,我会仔细分析新提案中field的概念矛盾,并揭示它实质上是作用域设计上的倒退。并且,该提案的错误实现,将不可避免地导致灾难。

Reject it! No more choices!

1. 概念:“Not Fields”!

对象在定义上是“属性集(object is collection of properties[1]”。因此如果Field不是属性,则它必然不属于“对象成员(collection elements of object)”这一概念集;如果Filed是属性,则public field必然与property这一现有概念冲突。

这就是现有一切矛盾的根源。

"proposal-class-fields"提案在根本上与ECMAScript对象核心概念是矛盾的,它试图说明[2]:对象是通过类成员来定义的一个结构,而类成员包括属性名与私有名;属性名定义的就是属性。

在这个定义中,对象是一个“类成员(字段)的映像实例”,每个字段是一个“名字*(field define by his name)*”。亦即是说,对象是名字集object is collection of names),是字段定义的实例instance of field definitions by class)。

该提案对概念的偷换并非只停留在叙述层面,它在实现上确实就是这么做的。如下[3]

2.7 DefineField(receiver, fieldRecord)
...
  8. If fieldName is a Private Name,
    ... // add as private filed
  9. Else, // public field as property
    Assert: IsPropertyKey(fieldName) is true.
    Perform ? CreateDataPropertyOrThrow(receiver, fieldName, initValue).

我们已经明显地看到,该提案的提出是对现有对象核心概念的破坏。而 “Not Fields”,是阻止它的唯一手段。

2. 实现:再造了一次var!

对于如下定义:

class f {
    #data = 100;
    foo() {
        this.#data = 200;
    }
}

该提案试图实现的效果与如下类似:

clas f {
    constructor() {
        ... // call super, etc
        var data = 100;
        this.foo = function() {
           data = 200;
        }
    }
}

注意,该提案本质上并没有“打算”为对象实例this提供私有的字段,而是通过词法环境为该对象提供一个在特定上下文中可访问的名字集。

但是,如上例所示,如果用这种方法来为this.foo()提供私有名字data,那么foo()将会是有非常多个函数实例(Function Instances)。为了避免单个对象重复绑定多个函数实例(来作为方法),该提案将上述“名字集(collection of names)”作为一个内部槽放在类中,并在创建对象时为this提供一个[[PrivateFieldValues]]的槽来作为该名字集的一个“映像(fork)”。

于是事情又绕回来了:该提案“似乎”为每个对象实例提供了这样的一个字段集?

不。真相不是这样的。对于该提案来说,这只是权宜之计,用以避免为每个对象、每个方法来创建各自的实例罢了。真实的情况是:它创建了每个类的、每个函数的、以及每个对象的“私有名”作用域(PrivateNameScope),然后在词法环境中维护这个作用域。由于每个对象都“可能有”私有域,因此在这个方案中,每个函数调用都需要负担起检查这个私有域的代价:每一次、每一处,以及每一个函数相关的特性。

于是整个提案暴涨了!大多数内部调用的方法都需要在界面上加上PrivateNameScope这个参数,且每一个运行上下文的环境变量中都需要加上PrivateNameEnvironment[4]——有点熟悉的感觉了吗?上一个如此大范围影响了ECMAScript规范的东西是什么?

没错,就是VariableEnvironment!就是那个制造了与“块级作用域”冲突的var声明,导致了新语法关键字let被启用的历史设计。Private Fields的蹩脚设计如此明显地与VariableEnvironment放在一起,却似乎没有任何一个规范审阅者看到:

3.9.9 PrepareForOrdinaryCall ( F, newTarget )
...
  8. Let localEnv be NewFunctionEnvironment(F, newTarget).
  9. Set the LexicalEnvironment of calleeContext to localEnv.
  10. Set the VariableEnvironment of calleeContext to localEnv.
  11. Set the PrivateNameEnvironment of calleeContext to F.[[PrivateNameEnvironment]].

他们重新发明轮子,又一次。并且,是方的轮子,又一次。

两个方轮子!

3. 低效

注:该规范将创建每个私有名称的映射,其中包含唯一键和设置名称作为描述。这似乎可用,但效率很低。谢谢@bakkot。
(我有一点遗漏,但我不会出于这个原因而撤回本建议。)

接下来每个函数都有了privateNameScope,如此等等。那么既然有PrivateNameEnvironmentprivateNameScope,又为什么要每个对象都有[[PrivateFieldValues]]内部槽呢?

答案是:对象的内部槽用来存放名字对应的字段值[5],而环境与作用域组件用来检测是否能访问该名字[6]

然而这是无效的。例如:

但是有一个问题。例如:

class f {
    #data = 100;   
}

class faked {
    #data;
    foo() {
        console.log(this.#data);
    }
}

// access private with a faked class
x = new f;
x.foo = faked.prototype.foo;
x.foo();  // 100

~~我仔细读过完整的提案,是的,上述的BUG无可避免。~~因为这个过程无异于:

function privateScope(name) {
    var privated_data = 100;
    return eval(name);
}
console.log(privateScope('privated_data'));

只要你允许对私有域做“读变量名”操作,那么所有的privated data都不可能安全。

注: 所以这个方案需要一个map来使用一个唯一key来登记每一个字段名字。

4. 结论

在抽象层面,(就抽象概念对使用者的影响来说)这个提案破坏或偷换了ECMAScript中“对象”的概念;在实现上,它耗用极大却又不能提供private这个概念应当提供的安全性。我不得不说,这是一个失败的、不可用的、设计粗糙、思想落后的提案。

建议:废止proposal-class-fields提案。


  1. "an object is a collection of zero or more properties." ECMAScript Overview part. ↩︎

  2. The FieldDefinition in proposal-class-fields, New Productions part. ↩︎

  3. The DefineField() algorithms in proposal-class-fields, Modified algorithms part. ↩︎

  4. Every call in proposal-class-fields, 3.9.9 PrepareForOrdinaryCall. ↩︎

  5. Access field value in proposal-class-fields, 3.5 PrivateFieldGet. ↩︎

  6. Check name binding status in proposal-class-fields, 3.8.1 GetValue. ↩︎