如何實現JavaScript物件導向的「private」成員

封裝及私有是傳統物件導向程式設計的重要特性之一,然而JavaScript語言並沒有private關鍵字,也沒有內建的private成員機制。若要在JavaScript類別中實現私有屬性或方法,有幾種解決方案,以下我們就分別探討幾種常見的做法。

[9/13更新]:增加「使用Symbol」及「使用Math.random」兩種做法。


Photo credit Ravi@flickr CC BY-SA 2.0

1. 用底線開頭命名

第一種做法,也是最簡單的做法,就是用變數命名的方式區分。針對private成員,我們一律以底線開頭命名,例如this._xthis._counterthis._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.pushthis.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程式設計進階」課程的「物件導向程式設計」單元所寫的補充教材。

1筆討論 回應文章