JS 的私有成員為什麼欽定了 #
翻譯自
譯者按:社群一直以來有一個聲音,就是反對使用
#
宣告私有成員。但是很多質疑的聲音過於淺薄,純屬人云亦云。其實 TC39 早就對此類呼聲做過迴應,並且歸納了一篇 FAQ。翻譯這篇文章的同時,我會進行一定的擴充套件(有些問題的描述不夠清晰),目的是讓大家取得一定的共識。我認為,只有你知其然,且知其所以然,你的質疑才是有力量的。
譯者按:首先要明確的一點是,委員會對於私有成員很多設計上的抉擇是基於 ES 不存在型別檢查,為此做了很多權衡和讓步。這篇文章在很多地方也會提及這個不同的基本面。
#
是怎麼回事?
#
是 _
的替代方案。
class A { _hidden = 0; m() { return this._hidden; } }
之前大家習慣使用 _
建立類的私有成員,但這僅僅是社群共識,實際上這個成員是暴露的。
class B {
#hidden = 0;
m() {
return this.#hidden;
}
}
現在使用 #
建立類的私有成員,在語言層面上對該成員進行了隱藏。
由於相容性問題,我們不能去改變 _
的工作機制。
譯者按:如果將私有成員的語義賦予
_
,之前使用_
宣告公共成員的程式碼就出問題了;而且就算你之前使用_
是用來宣告私有成員的,你能保證你心中的語義和現階段的語義完全一致麼?所以為了慎重起見,將之前的一種錯誤語法(之前類成員以 # 開頭會報語法錯誤,這樣保證了以前不存在這樣的程式碼)加以利用,變成私有成員語法。
為什麼不能通過 this.x
訪問?
譯者按:這個問題的意思是,如果類 A 有私有成員 #x(其中 # 是宣告私有,x 才是成員名),為什麼內部不能通過 this.x 訪問該成員,而一定要寫成 this.#x?
譯者按:以下是一系列問題,問題 -> 解答 -> 延伸問題 -> 解答 ...
有 x
這個私有成員,不意味著不能有 x
這個公共成員,因此訪問私有成員不能是一個普通的查詢。
這是 JS 的一個問題,因為它缺少靜態型別。靜態型別語言使用型別宣告區分外部公共/內部私有的情況,而不需要識別符號。但是動態型別語言沒有足夠的靜態資訊區分這些情況。
延伸問題 1:那麼為什麼這個提案允許一個類同時存在私有成員 #x 和公共成員 x ?
如果私有成員和公共成員衝突,會破壞其“封裝性”。
私有成員很重要的一點是子類不需要知道它們。應該允許子類宣告成員
x
,即使父類有一個同名的私有成員。
譯者按:感覺第二點有點文不對題。
其他支援私有成員的語言通常是允許的。如下是完全合法的 Java 程式碼:
class Base {
private int x = 0;
}
class Derived extends Base {
public int x = 0;
}
譯者按:所謂的“封裝性”(encapsulation / hard private)是很重要的概念,最底下會有說明。最簡單的解釋是,外部不能以任意方式獲取私有成員的任何資訊。假設,公共成員和私有成員衝突,而
x
是obj
的私有成員,這時候外部存在obj.x
。如果公私衝突,這裡將會報錯,外部就嗅探到了obj
存在x
這個私有成員。這就違背了“封裝性”。
屬性訪問的語義已經很複雜了,我們不想僅僅為了這個特性讓每次屬性訪問都更慢。
它(執行時檢測)還可能讓類的方法被非例項(比如普通物件)欺騙,使其在非例項的欄位上進行操作,從而造成私有成員的洩漏。這條評論 是一個例子。
譯者按:如果不結合以上的例子,上面這句話其實很難理解。所以我覺得有必要擴充套件一下,雖然有很多人認為這個例子沒有說服力。
首先我希望你瞭解 Java,因為我會拿 Java 的程式碼做對比。
其次我再明確一下,這個問題的根本在於 ES 沒有靜態型別檢測,而 TS 就不存在此類煩惱。
public class Main { public static void main(String[] args){ A a1 = new A(1); A a2 = new A(2); a1.swap(a2); a1.say(); a2.say(); } } class A { private int p; A(int p) { this.p = p; } public void swap(A a) { int tmp = this.p; this.p = a.p; a.p = tmp; } public void say() { System.out.println(this.p); } }
以上的例子是一段正常的 Java 程式碼,它的邏輯很簡單:宣告類 A,A 存在一個公共方法,允許例項和另一個例項交換私有成員 p。
把這部分邏輯轉換為 JS 程式碼,並且使用 private 宣告
class A { private p; constructor(p) { this.p = p } swap(a) { let tmp = a.p; a.p = this.p; this.p = tmp; } say() { console.log(this.p); } }
乍一看是沒有問題的,但 swap 有一個陷阱:如果傳入的物件不是 A 的例項,或者說只是一個普通的物件,是不是就可以把私有成員 p 偷出來了?
JS 是不能做型別檢查的,那我們怎麼宣告傳入的 a 必須是 A 的例項呢?現有的方案就是檢測在函式體中是否存在對入參的私有成員的訪問。比如上例中,函式中如果存在 a.#p,那麼 a 就必須是 A 的例項。否則就會報
TypeError: attempted to get private field on non-instance
這就是為什麼對私有成員的訪問必須在語法層面上體現,而不能是簡單的執行時檢測。
延伸問題 3:當類中聲明瞭私有成員 x
時,為什麼不讓 obj.x
總是代表對私有成員的訪問?
譯者按:這個問題的意思是當某個類聲明瞭私有成員
x
,那麼類中所有的成員表示式sth.x
都表示是對sth
的私有成員x
的訪問。我覺得這是一個蠢問題,誰贊成?誰反對?
類方法經常操作不是例項的物件。當 obj
不是例項的時候,如果 obj.x
突然間不再指的是 obj
的公共欄位 x
,僅僅是因為在類的某個地方聲明瞭私有成員 x
,那就太奇怪了。
延伸問題 4:為什麼不賦予 this
關鍵字特殊的語義?
譯者按:這個問題針對前一個答案,你說
obj.x
不能做這種簡單粗暴的處理,那麼this.x
可以咯?
this
已經是 JS 混亂的原因之一了;我們不想讓它變的更糟。同時,這還存在一個嚴重的重構風險:如果 const thiz = this; thiz.x
和 this.x
存在不同的語義,將會帶來很大的困擾。
而且除了 this
,傳入的例項的私有成員將無法訪問(比如延伸問題 2 的 js 示例中傳入的 a)。
延伸問題 5:為什麼不禁止除 this
之外的物件對私有成員的訪問?舉個栗子,這樣一來甚至可以使用 x
替代 this.x
表示對私有屬性的訪問?
譯者按:這個問題再做了一次延伸,上面提到傳入的例項的私有成員不能訪問,這個問題是:不能訪問就不能訪問唄,有什麼關係?
這個提案的目的是允許同類例項之間私有屬性的互相訪問。另外,使用裸識別符號(即使用 x
代替 this.x
)不是 JS 的常見做法(除了 with
,而 with
的設計也通常被認為是一個錯誤)。
譯者按:一系列延伸問題到此結束,這類問題弄懂了基本上就掌握私有成員的核心語義和設計原則了。
為什麼 this.#x
可以訪問私有屬性,而 this[#x]
不行?
這會讓屬性訪問的語義更復雜。
動態訪問違背了
私有
的概念。舉個栗子:
class Dict extends null {
#data = something_secret;
add(key, value) {
this[key] = value;
}
get(key) {
return this[key];
}
}
new Dict().get("#data"); // 返回了私有屬性
延伸問題 1:賦予 this.#x
和 this[#x]
不同的語義是否破壞了當前語法的穩定性?
不完全是,但這確實是個問題。不過從某個角度上來說,this.#x
在當前的語法中是非法的,這已經破壞了當前語法的穩定性。
另一方面,this.#x
和 this[#x]
之間的差異比你看到的還要大,這也是當前提案的不足。
為什麼不能是 this#x
,把 .
去掉?
這是可行的,但是如果我們再簡化為 #x
就會出問題。
譯者按:這個說法很簡單,我直接列在下面
栗子:
class X { #y z() { w() #y() // 會被解析為w()#y } }
泛言之,因為 this.#
的語義更為清晰,委員會基本都支援這種寫法。
譯者按:這也是被認為沒有說服力的一個說辭,因為委員會把
this#x
極端化成了#x
,然後描述#x
的不足,卻沒有直接給出this#x
的不足。
為什麼不是 private x
?
這種宣告方式是其他語言使用的(尤其是 Java),這意味著使用 this.x
訪問該私有成員。
假設 obj
是類例項,在類外部使用 obj.x
表示式,JS 將會靜默地建立或訪問公共成員,而不是丟擲一個錯誤,這將會是 bug 的主要潛在來源。
它還使宣告和訪問對稱,就像公共成員一樣:
class A {
pub = 0;
#priv = 1;
m() {
return this.pub + this.#priv;
}
}
譯者按:這裡說明了為什麼使用
#
不使用private
的主要原因。我們理一下:如果我們使用
private
class A { private p; say() { console.log(this.p); } } const a = new A; console.log(a.p); a.p = 1;
例子當中,對屬性的建立如果不拋錯,是否就會建立一個公共欄位? 如果建立了公共欄位,呼叫
a.say()
列印的是公共欄位還是私有欄位?是不是列印哪個都感覺不對? 可能你會說,那就拋錯好了?那這樣就是執行時檢測,這個問題在上面有過描述。
因為這個功能非常有用,舉個栗子:判斷 Point
是否相等的 equals
方法。
實際上,其他語言由於同樣的原因也是這樣設計的;舉個栗子,以下是合法的 Java 程式碼
class Point {
private int x = 0;
private int y = 0;
public boolean equals(Point p) { return this.x == p.x && this.y == p.y; }
}
Unicode 這麼多符號,為什麼恰恰是 #
?
沒人說 #
是最漂亮最直觀的符號,我們用的是排除法:
@
是最初的選擇,但是被decorators
佔用了。委員會考慮過交換decorators
和private
的符號(因為它們都還在提案階段),但最終還是決定尊重社群的習慣。_
對現有的專案程式碼存在相容問題,因為之前一直允許_
作為成員變數名的開頭。- 其他之前用於中綴運算子,而非字首運算子的。假設是可以的,比如
%
,^
,&
,?
。考慮到我們的語法有點獨特 ——x.%y
當前是非法的,所以不存在二義性。但無論如何,簡寫會帶來問題。舉個栗子,以下程式碼看上去像是將符號作為中綴運算福:
class Foo {
%x;
method() {
calculate().my().value()
%x.print()
}
}
如上,開發人員看上去像是希望呼叫 this.%x
上的 print
方法。但實際上,將會執行取餘的操作!
- 其他不屬於 ASCII 或者 IDStart 的 Unicode 字元也可以使用,但對於許多使用者來說,他們很難在普通的鍵盤上找到對應的字元。
最後,唯一的選項是更長的符號序列,但比起單個字元似乎不太理想。
譯者按:委員會還是舉了省略分號時的例子,可是上面也說了,就算是
#
,也同樣存在問題。
為什麼這個提案不允許外部通過一些機制用於反射/訪問私有成員(比如說測試的時候)?其他語言也是這樣的嗎?
這樣做會違反“封裝性”。其他語言允許並不是一個充分的理由,尤其是在某些語言(例如 C++)中,是通過直接修改記憶體實現的,而且這也不是一個必需的功能。
意味著私有成員是完全內部的:沒有任何類外部的 JS 程式碼可以探測和影響到它們的存在,它們的成員名,它們的值,除非類自己選擇暴露他們。(包括子類和父類之間也是完全封裝的)。
意味著如果一個類有一個私有成員 x
,在類外部例項化類物件 obj
,這時候通過 obj.x
訪問的應該是公共成員 x
,而不是訪問私有成員或者丟擲錯誤。注意這裡的現象和 Java 並不一致,因為 Java 可以在編譯時進行型別檢查並且禁止通過成員名動態訪問內容,除非是反射介面。
為什麼這個提案會將封裝性作為目的?
庫的作者們發現,庫的使用者們開始依賴任何介面的公開部分,而非文件上的那部分(即希望使用者們關注的部分)。一般情況下,他們並不認為他們可以隨意的破壞使用者的頁面和應用,即使使用者沒有參照他們的建議作業。因此,他們希望有真正的私有化可以隱藏實現細節。
雖然使用例項閉包或者
WeakMaps
已經可以模擬真實的封裝(如下),但是兩種方式和類結合都過於浪費,而且還涉及了記憶體使用的語義,也許這很讓人驚訝。此外, 例項閉包的方式還禁止同類的例項間共享私有成員([如上]](#share)),而WeakMaps
的方式還存在一個暴露私有資料的潛在風險,並且執行效率更低。隱藏但不封裝也可以通過使用
Symbol
作為屬性名實現(如下)。
當前提案正在努力推進硬隱私,使 decorators 或者其他機制提供給類一個可選的逃生通道。我們計劃在此階段收集反饋,以確定這是否是正確的語義。
檢視這個 issue 瞭解更多。
const Person = (function() {
const privates = new WeakMap();
let ids = 0;
return class Person {
constructor(name) {
this.name = name;
privates.set(this, { id: ids++ });
}
equals(otherPerson) {
return privates.get(this).id === privates.get(otherPerson).id;
}
};
})();
let alice = new Person("Alice");
let bob = new Person("Bob");
alice.equals(bob); // false
然而這裡還是存在一個潛在的問題。假設我們在構造時新增一個回撥函式:
const Person = (function() {
const privates = new WeakMap();
let ids = 0;
return class Person {
constructor(name, makeGreeting) {
this.name = name;
privates.set(this, { id: ids++, makeGreeting });
}
equals(otherPerson) {
return privates.get(this).id === privates.get(otherPerson).id;
}
greet(otherPerson) {
return privates.get(this).makeGreeting(otherPerson.name);
}
};
})();
let alice = new Person("Alice", name => `Hello, ${name}!`);
let bob = new Person("Bob", name => `Hi, ${name}.`);
alice.equals(bob); // false
alice.greet(bob); // === 'Hello, Bob!'
乍看好像沒有問題,但是:
let mallory = new Person("Mallory", function(name) {
this.id = 0;
return `o/ ${name}`;
});
mallory.greet(bob); // === 'o/ Bob'
mallory.equals(alice); // true. 錯了!
const Person = (function() {
const _id = Symbol("id");
let ids = 0;
return class Person {
constructor(name) {
this.name = name;
this[_id] = ids++;
}
equals(otherPerson) {
return this[_id] === otherPerson[_id];
}
};
})();
let alice = new Person("Alice");
let bob = new Person("Bob");
alice.equals(bob); // false
alice[Object.getOwnPropertySymbols(alice)[0]]; // == 0,alice 的 id.
譯者按:FAQ 到此結束,可能有的地方會比較晦澀,多看幾遍寫幾個 demo 基本就懂了。我覺得技術存在
看山是山 -> 看山不是山 -> 看山還是山
這樣一個漸進的過程,翻譯這篇 FAQ 也並非為#
辯護,只是現在很多質疑還停留在看山是山
這樣一個階段。我希望這篇 FAQ 可以讓你看山不是山
,最後達到看山還是山
的境界:問題還是存在問題,不過是站在更全面和系統的角度去思考問題。