私有属性的实现

本文是系列文章,包括:

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

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

在tc39的提案中,这一特性被称为"private field",据说是为了避免与property这个传统的名字出现概念冲突。这简直是扎了裤脚放屁——还要称比脱了裤子文雅!为什么这么讲呢?因为一旦引入了所谓“private filed”,就预示着还会有“public field”等等之类,而这与传统的property又有什么不同吗?

还是叫“私有属性(private property)”吧,别再出妖了。

1. 私有成员语法的三个问题

目前,类与对象都存在两种性质的成员,一是属性,二是方法。——尽管在ES6以前“函数类型的属性”也被称为方法,但在ES6及其以后,它们不再是严格意义上的方法了。后者,亦即是ES6+的方法也是一种“(特殊的)函数类型的属性”,这种特殊性在于它必须通过声明语法来添加到类或对象。——也就是说,ES6中的方法是静态声明的,而不是动态添加的。

类是特定语法声明的函数。由于函数是对象,所以类也是普遍含义上的对象。所以——重要的是——类的成员与对象的成员在性质上并没有任何的不同。确切来讲,它们都是属性,是对象的自有属性表中的成员,或可以通过原型访问的父代类属性。

总之,所有现在能静态声明或动态添加的属性都是公开的(public)。在讨论“私有属性”的时候,有三点是必须要先确定的:

  • 私有属性是静态声明的,还是动态添加的?
  • 私有属性是(现有机制中的)自有属性表中的“标识为私有的”属性,还是在一个新表?
  • 私有属性是否支持继承(类似其它OOP语言的protected属性)?

注:本文中所谓“成员”,是在需要区分讨论属性与方法时使用的。此外,在提及“词法私有成员”时,因其与“属性”存在本质不同,所以暂用了“成员”这个概念。

2. tc39中的提案

之前我们已经讨论过这个提案的语法。很大程度上我是在尽量保证与现有语法设计上的一致性,而并没有讨论这个提案的实现方法。

简单地说,该提案认为:私有属性是一种特殊前缀表达的、自有的特殊属性。

由于私有属性是“一种...属性”,因此该属性总是在“自有属性表”中,并且也受自有属性表的机制来约束,例如属性描述符。比较简单的处理方法是:在属性描述符中增加新的属性(类似configurableenumerable等等)。因此:

class f() {
    private data: 100
}
x = f();

// 在语义上等义于:
Object.defineProperty(x, 'data', {
    value: 100,
    kind: 'private'  // default is 'public'
})

所有与“属性”相关的特性都可以用在这些新式的kind: 'private'的私有属性上。当然,如此一来,我们之前讨论的三个问题就有答案了:

  • 私有属性是静态声明的,不可以动态添加。
    私有属性不支持用Object.defineProperty()等来动态声明,这可以通过kind性质来限制之。

  • 私有属性使用可以使用一个新的属性表,也可以直接使用现有的自有属性表。
    两者的机制是完全一样的,只是kind性质的不同。但是,无论哪种方法,都需要考虑公开与私有属性同名问题:是可以同时存在,还是禁止重复。(注1、注2)

  • 私有属性可以支持继承。

    在这个方案中可以很方便的实现类似protected的语法,即在子类中可访问的父类私有属性(如果父类将之声明为protected)。

注1:建议允许重名。因为JavaScript允许动态添加公开属性名的,如果不允许重名,则发生与私有属性冲突的机率大增。——更严重的是,用户代码无从得知一个对象有哪些私有属性名。

注2:如果允许重名,则两张属性表更方便;如果禁止重名,则建议使用同一张表。

如上,我的确是建议“私有的与公开的属性名是可以重复的”。这带来了如下的问题(下例假设直接使用this.xxx来存取私有属性):

// 示例:一个不太可行的方案
class f{
    data = 100; // 假设这里是私有成员
    foo() {
        console.log(this.data);  // 假设这里的this.data指向私有成员
    }
}
x = new f;
x.data = '200';
f(); // 应该显示200还是100?

这一示例说明:我们无法在语法上“限定在class f()类声明中使用this.data将访问私有属性。这个设计是行不通的。

如果我们的假设是“私有属性是属性表中的特殊项”,那么“使用this来访问属性表”是目前最合理的方式。又如果使用“this作为绑定给foo()的对象实例,并试图访问该实例的私有属性”,那么“必然的”,我们需要一个新的语法:用于存取私有属性。

——注意这与目前tc39提案的不同,亦即是“No prefix! operator is Ok!”

如下例:

class f() {
    private data: 100,
    private static data: 200,
    foo() {
        this#data = this#data + f#data;
    }
}

在这个设计中,#是限用于“类和对象的方法声明语法中”的,语义是:

  • 存取左运算元的私有属性表

很简单,很明确。

3. 使用属性表的核心问题

我们使用pravite作为限定词,核心目的是“访问在class f()声明之外访问私有属性。然而,如果我们使用属性表,那么没有任何”经济的“方法能做实现这个目标。为了解决这个问题,我们先讨论一个ECMAScript规范上的漏洞:能不能动态添加方法声明?

3.1 动态添加方法

JavaScript不能安全地抄写对象方法,因为方法会绑定源对象的super。例如:。

// 类f与对象x
ObjectX = function() {}
ObjectX.prototype.data = "ObjectX"
class f extends ObjectX { }
f.prototype.data = 100;
x = new f;

// 类f2与对象x2
MyObject = function() {}
MyObject.prototype.data = 'MyObject';
class f2 extends MyObject {
    foo() {
        console.log(super.data);
        console.log(this.data);
    }
}
f2.prototype.data = 200;
x2 = new f2;
x2.foo(); // "MyObject" 200

// 抄写x2的方法声明
// (super没变化,绑定在了f2()类上)
x.foo = x2.foo;
x.foo();  // "MyObject" 100

方法x.foo()显示了foo()在词法上的super,亦即是MyObject。这表明foo()方法是与class f2的继承关系是词法绑定的,并不会“抄写到”对象x。——当然,如我们一直在讨论的,this引用是动态绑定的,不受上述影响。

而所谓super本质上只是在访问一个方法的“内部槽[[HomeObject]]”的原型而已。考虑到这一点,一种简单的重置super的方法如下:

// 安全地动态添加方法x.foo()
Object.assign(x, Object.setPrototypeOf({
    foo() {
        console.log(super.data);
        console.log(this.data);
    }
}, Object.getPrototypeOf(f.prototype)));

x.foo(); // "ObjectX", "100"

根据这一过程,一个安全地动态添加方法的操作如下(注3):

// 工具函数
//  - add method to f.prototype
Object.addMethod = function(proto, methods) { 
  return Object.assign(proto, Object.setPrototypeOf(methods,
    Object.getPrototypeOf(proto)));
}

// 使用示例(在原型上添加方法)
Object.addMethod(f.prototype, {
    foo2() {
        console.log('Value:', super.data);
    }
})
x.foo2();  // "Value: ObjectX"

注3: Object.addMethod()在这里实现为“向f.prototype属性添加方法”,是因为通过class关键字声明的对象方法其实都是原型方法。因此所谓“动态添加方法”是应当通过污染类的.prototype属性来实现的。但是也可以用类似技术来实现不污染该原型属性的方法,例如Object.addOwnMethod(),因与本文主题无关,在这里就不给出具体代码了。

3.2 动态存取私有属性

Object.addMethod()方法的实现意味着“在类声明之外”为对象或类动态添加方法是安全的。因此,尽管我们认为下面的私有属性this#data只能通过foo()`来访问:

class f {
    private data: 100,
    foo() {
        console.log(this#data);
    }
}
x = new f;
x.foo();

然而事实上下面的示例可以随时访问之:

Object.addMethod(f.prototype, {
    setData(v) {
        this#data = v;
    }
})

// setData()是动态添加的可以存取私有属性的方法
x.setData(300);
x.foo(); // 300

然而这样一来,在ECMAScript中没有办法“安全地实现私有属性”了。

4. 存在可行的解决方案吗?

首先我们得知道为什么。

ECMAScript的方法本质上是“访问this绑定的函数”——无论是传统的方法,还是ES6之后的方法,皆是如此。在所谓方法中,如果访问的是this.xxx这样的公共属性自然是不成问题,因为本质上对象就是属性表;类似的,如果属性包支持“private这样的访问域特性”,那么自然也是可以在支持私有属性访问的。——所以,最新的提案是建议用this.#xxx这样的语法来访问它,亦即是在本质上仍然是在访问属性表。

所以,这就是根本的问题:如果一个方法就是在访问属性表,且它总是能动态地绑定this,那么它必然也能够跨类(和跨对象)地访问私有的属性表——以及那些私有属性。

有两个途径来解决这个问题。

其一,是采用“词法私有成员”这样一种潜在实现机制。由于这种实现机制是利用作用域来实现的,与对象的自有属性表无关,因此也不会受addMethod()这样的方法影响 。

其二,是在方法声明(例如f.prototype.foo())中对this#xxx中的运算符#做进一步限制。即foo()方法

  • 仅能在f.prototypethis对象的原型相同时(即private限定词),以及
  • 仅能在f.prototypethis对象的原型的原型链上时(即protected限定词),

才能使用#运算。而这一点恰恰是能做到的,因为——正好——每一个ES6之后的方法,都会有一个[[HomeObject]]内部槽用于存放**“声明时的”**类原型(f.prototype)或对象本身(obj)。如下例:

class f {
    foo() {}
}
obj = {
    foo() {}
}
x = new f;

// 如果能访问[[HomeObject]]内部槽,则:
console.log(x.foo[[HomeObject]] === f.prototype); // true
console.log(obj.foo[[HomeObject]] === obj); // true

那么,上述对#运算符的限制无非是在说(注4、注5):

// 在foo()方法内实现`#`运算符的伪代码逻辑
foo() {
    // source: `this#data`
    homeObject = activeFunction[[HomeObject]];
    descriptor = Object.getPrivatePropertyDescriptor(this, 'data');
    permission = (
       (descriptor.kind == 'private' &&
        homeObject == Object.getPrototypeOf(this)) ||
       (descriptor.kind == 'protected' &&
        homeObject.isPrototypeOf(this)));
    if (permission) {
        result = descriptor.value; // return result of `#` op
    }
}

注意这里有一个伪代码说明的变量名activeFunction,这是指“当前活动的、正在调用的函数,也就是foo()函数本身。类似在ECMAScript中“需要判断当前函数”的情况并不是没有——也就是说,以前就有存在这样这种方案实现的语言特性了。

是哪种语言特性呢?这就是闻名已久的super()——注意这里仅指在constructor()调用父类构造方法的操作,在ECMAScript中称为SuperCall。这个操作很特殊,因为它需要从当前上下文的环境记录中取activeFunction并进一步“查找所谓父类(即super)”。

注4: 在ECMAScript中有两个操作是会取activeFunction的,另一个是eval()。SuperCall()需要取activeFunction的原因是它无法直接使用constructor()的[[HomeObject]]内部槽。

注5: Object.getPrivatePropertyDescriptor()Object.getOwnPropertyDescriptor()并没有功能的区别,只是这里假设前者需要直接访问私有属性表(关于建议使用两个属性表的问题,参见本文第2节)。

亦即是说,所有实现这一特性所需的技术组件,在现有的ECMAScript中都是充备的。

^^.

n. 终极问题:必要性?

看起来还不错哟。看起来很有希望哟。要不,下手来搞搞?

然而,历史中,我们程序员犯得最多的错误就是:

一件事看起来能做,于是就做了。

“私有属性”这个特性真的是必须的么?——常读我写文章的读者,应该知道我总是在最后一步来考虑最原始的问题。——然而关于这个问题,我想要等到下一篇再讨论了。

现在这篇,已经很长了,不能再长了。:)