封裝及私有是傳統物件導向程式設計的重要特性之一,然而JavaScript語言並沒有private
關鍵字,也沒有內建的private成員機制。若要在JavaScript類別中實現私有屬性或方法,有幾種解決方案,以下我們就分別探討幾種常見的做法。
[9/13更新]:增加「使用Symbol」及「使用Math.random」兩種做法。
Photo credit Ravi@flickr CC BY-SA 2.0
1. 用底線開頭命名
第一種做法,也是最簡單的做法,就是用變數命名的方式區分。針對private成員,我們一律以底線開頭命名,例如this._x
、this._counter
、this._reset()
等等。
舉個實際例子:假設我們要實做一個特殊的堆疊,當push的值與堆疊頂端的值相同時,則忽略此次push。以ES6 class語法實做的程式碼如下:
class MyStack {
constructor() {
this._data = [];
}
push(v) {
if (this._data.length === 0 || v !== this._data[this._data.length - 1]) {
this._data.push(v);
}
}
pop() {
return this._data.pop();
}
}
以下是一段簡單的測試程式:
let s = new MyStack();
s.push(1);
s.push(2);
s.push(2); // will be ignored
console.log(s._data); // logs "[1, 2]" -- we can access private property!
console.log(s.pop()); // logs "2"
console.log(s.pop()); // logs "1"
console.log(s.pop()); // logs "undefined"
這種做法雖然方便,但顯而易見的,它對保護私有屬性並沒有強制力。由上例中我們可以看到,類別外部的程式碼可以直接存取、甚至修改s._data
的內容,畢竟這種變數命名方式,只是一種coding convention。它不能有效防止私有屬性被類別外部的程式碼存取,只能算「柔性勸導」。
- 優點:方便,簡潔
- 缺點:沒有強制力
2. 使用closure
利用JavaScript語言的closure特性,我們可以把私有屬性放在物件方法的closure裡,達到保護私有屬性的目的。以前面的堆疊程式碼為例,我們用closure改寫如下:
class MyStack {
constructor() {
let _data = [];
this.push = function (v) {
if (_data.length === 0 || v !== _data[_data.length - 1]) {
_data.push(v);
}
}
this.pop = function () {
return _data.pop();
}
}
}
以上的_data
物件,因為是放在this.push
及this.pop
這兩個函式的closure裡,外界無法存取,因此可達到保護物件private成員的效果。缺點則是程式碼較不簡潔、類別方法必須定義在constructor中而不是prototype裡、且所有物件方法必須共用同一個closure。
- 優點:
- 有強制力,能保護私有成員
- ES3/ES5即支援closure
- 缺點:
- 程式碼不簡潔
- 類別方法不能在prototype裡宣告
- 所有物件方法必須共用同一個closure
3. 使用WeakMap
相對於傳統JavaScript的Object只能用字串當作key,ES6的Map/WeakMap可以用任何物件當作key。WeakMap與Map不同之處在於:以物件當作WeakMap的key時,這個key的使用不會算在該物件的reference count頭上,因此只要其它地方不再參考到該物件,該物件就可以被garbage collect掉。
我們可以利用WeakMap的這些特點,來實現物件的私有屬性:
let _data = new WeakMap();
class MyStack {
constructor() {
_data.set(this, []);
}
push(v) {
let data = _data.get(this);
if (data.length === 0 || v !== data[data.length - 1]) {
data.push(v);
}
}
pop() {
return _data.get(this).pop();
}
}
以上的_data
便是一個WeakMap物件,我們利用它來存放所有MyStack instances的私有堆疊資料。當我們要存取堆疊資料時,就以this
當作key,用_data.get(this)
取得私有堆疊資料;這同時解決了前面closure做法中,類別方法不能宣告在prototype裡的問題。不過採用這種做法時,每次取用私有成員都必須透過WeakMap的x.get(this)
函式呼叫,是較為不便之處。
- 優點:
- 有強制力,能保護私有成員
- 類別方法可以宣告在prototype裡
- 缺點:
- 存取私有成員時,多一個函式呼叫
- ES6才支援WeakMap
4. 使用Symbol
除了字串,ES6的Symbol也可以當作Object的key。(如果你還不知道Symbol是甚麼,可以參考Mozilla Developer Network這篇說明文件;基本上,你可以把Symbol當作一種絕不重覆的unique identifier。)因為用Symbol當作key的隱密性較高,外部程式要存取比較麻煩,因此可以提供比第一種方法(以底線開頭命名變數)更佳的對私有屬性的保護,使用上又比第二及第三種方法方便。
以下是用Symbol改寫的程式碼:
let _data = Symbol('data');
class MyStack {
constructor() {
this[_data] = [];
}
push(v) {
if (this[_data].length === 0 || v !== this[_data][this[_data].length - 1]) {
this[_data].push(v);
}
}
pop() {
return this[_data].pop();
}
}
我們在存取私有屬性時,語法只需從this._data
改為this[_data]
,程式變動幅度很小。然而要注意的是,Symbol並非百分之百隱密,外部程式碼仍然可以透過Reflect.ownKeys(x)
取得物件包含Symbol在內的所有key,進而存取私有屬性。例如以下這段測試程式:
let s = new MyStack();
s.push(1);
s.push(2);
let keys = Reflect.ownKeys(s);
let key = keys.filter(k => typeof(k) === 'symbol')[0];
console.log(s[key]); // logs "[1, 2]" -- we can still access the private property!
但以上存取私有屬性的程式碼頗為麻煩,因此比起第一種方法來,Symbol對私有屬性提供的保護要好很多。
- 優點:
- 方便,簡潔
- 對私有成員能提供合理保護(但非百分之百)
- 缺點:
- 提供的保護並非百分之百
- ES6才支援Symbol
5. 使用Math.random
既然前一種方法的Symbol只是為了取得一個相對隱密的unique key,我們也可以利用亂數來產生一個unique key。使用亂數的好處,是它在ES3/ES5就有支援了,不像Symbol需要到ES6才有支援。
用亂數改寫的程式碼如下:(後面的程式碼都一樣,就不再重覆)
let _data = 1 + Math.random();
...
把亂數加上1,是為了避免與其它key重覆(例如第二個私有屬性便可用2 + Math.random()
當作key)。與前一種方法一樣,外部程式碼仍能取得私有屬性成員的key,因此也不能提供百分之百保護。為甚麼會這樣?道理很簡單,因為4、5這兩種方法都把私有成員放在物件裡面,不可能做到真正安全;而2、3這兩種方法是把私有成員放在物件以外的地方(也就是closure),所以是安全的。
- 優點:
- 方便,簡潔
- 對私有成員能提供合理保護(但非百分之百)
- ES3/ES5即支援Math.random
- 缺點:
- 提供的保護並非百分之百
結語
以上就是實現JavaScript private屬性的幾種常見做法。這些方法各有優缺點,可根據專案實際需求運用。我個人認為,第一種方法通常就夠用了。雖然乍看之下,仰賴coding convention及團隊自律並不可靠,但如果專案團隊有落實code review,這應該不是問題。(話說回來,如果專案沒在做code review,表示code quality對這個專案不是那麼重要,那保不保護私有成員也就無所謂了。)但在某些情況下,保護類別私有成員確有其必要;例如:寫出來的程式碼會被團隊以外的開發人員使用(其他部門或第三方開發者),此時便可考慮採用後面幾種做法。
至於將來的JavaScript會不會支援private
關鍵字?這還很難說。雖然目前private
已經是JavaScript的保留字(reserved keywords),但短期之內看不出有被納入標準的跡象。畢竟JavaScript是精簡靈活的腳本語言,需要在各種瀏覽器及行動裝置上快速編譯執行,因此在語言設計上必須有所取捨。如果把大型程式語言的一堆東西都照搬進來,很快JavaScript語言就會變肥胖了。
希望這篇文章,對大家了解如何實現JavaScript物件導向的private成員,能有一些幫助!
本文是為Arthur's Coding Workshop「JavaScript程式設計進階」課程的「物件導向程式設計」單元所寫的補充教材。
在FB社團的討論串中,有人提到TypeScript對private有完整支援:
https://www.typescriptlang.org/docs/handbook/classes.html
若是專案採用TypeScript開發,就可以直接用
private
關鍵字了。