物件與類別

上個章節中, 提到了原型鏈的概念

繼續深入類別之前, 先來看看ES 5之前的類別是如何實現的:

/* Class Example */
function Rect(w,h) {
  if(!(this instanceof Rect)) // No
    throw new Error('is constructor');
  this.w = w;
  this.h = h;
}

Rect.prototype.add = function (r) {
  if(!(r instanceof Rect))
    throw new TypeError('Only allow Rect-type');
  this.w += r.w;
  this.h += r.h;
}

Rect.prototype.print = function () {
  console.log(this.w + ', ' + this.h);
}

let r1 = new Rect(1,1);
let r2 = new Rect(2,2);
r1.add(r2);
r1.print(); // '3, 3'

這裡定義 Rect 型別, 並宣告了 add 與 print 方法的實作。

當宣告了一個物件時, 便可以同時定義他的prototype

已知, 當調用 function 的順序(例如 Rect.add)時, 會依序查找Rect.add Rect.prototype.add Object.prototype.add

Question

若回傳一個包含函式的物件, 其行為會與定義 Class 一樣嗎?

/* Class-like Example */
function Rect(w,h) {
  return {
    w: w,
    h: h,
    add: function(r) {
      this.w += r.w;
      this.h += r.h;
    },
    print: function() {
      console.log(this.w + ', '+ this.h);
    }
  }
}

let r1 = Rect(1,1);
let r2 = Rect(2,2);
r1.add(r2);
r1.print()

這個行為"看起來"會跟使用 new Rect 一樣, 但是有個非常嚴重的問題, 就是r1.add !== r2.add

這個問題的嚴重性在於, 假定定義了像是MyClass 這種類別, 並且有個方法 myFunction, 當建立了 1000 個實例

myFunction 也會被建立1000次, 這對於記憶體的處理是非常不健康的

邏輯上, 成員變數應該保持在自己的scope, 而方法(例如 MyClass.myFunction) 是一個獨立的 function, 由所有的MyClass共用該 function 位址

僅需要傳入自己的參考, MyClass 便會假設 this 是自己傳進來的參考;

再次使用C++來舉例, C++的class實作隱含了this參數, 比方說

class Point {
public:
  Point(int x, int y): x_(x), y_(y) {}

  void add(Point p) {
    this.x_ += p.x_;
    this.y_ += p.y_;
  }

private:
  int x_;
  int y_;
}

實際上, add 的簽章會包含一個隱含的參數 this

void Point::add(Point* this, Point p) {
  this.x_ += p.x_;
  this.y_ += p.y_;
}

如果要驗證這一點, 通過 std::bind 這個函式可以更好的觀察到

#include <iostream>
#include <functional>

class Point {
public:
  Point(int x, int y): x_(x), y_(y) {}

  void add(Point& p) {
    this->x_ += p.x_;
    this->y_ += p.y_;
  }

  int x_;
  int y_;
};

int main() {
  Point p1(1,1);
  Point p2(3,3);
  Point POINT(10,10);

  auto fn = std::bind(&Point::add, &POINT, std::placeholders::_1);
  fn(p1);
  std::cout << POINT.x_ << ", " << POINT.y_ << "\n";
  //11, 11
  fn(p2);
  std::cout << POINT.x_ << ", " << POINT.y_ << "\n";
  //14, 14
}

逐步拆解以上的過程:

  1. class Point 宣告了 add function, 允許兩個Point進行加法
  2. fn 通過 std::bind 繫結了 Point::add 這個函式, 並且把 this 的 Context 繫結在 POINT
  3. 透過 std::placeholders, 繫結 fn 的第一個引數與 Point::add 的第一個參數
  4. 調用 fn(p1) 時, 相當於調用了 POINT.add(p1)
  5. 調用 fn(p2) 時, 相當於調用了 POINT.add(p2)

Note

以筆者的理解來說明:

在Class的實現上, 可以拆解為 屬性 以及 方法

屬性是由實例自行維護的數據區塊

方法則是所有的實例共享同樣的函式宣告與實作

所有的方法雖然共用相同的function 區段, 但是因為隱含了 *this, 所以不同實例調用方法才會呈現不同的結果

可以參考 MSDN C++ 上的 __thiscall

部分程式語言, 如 Rustlang, 則要求在成員的方法實作, 顯式宣告第一個參數為 &self

在 JavaScript 上, 早期的 Class 實作要求使用 function 來宣告, 在 Rect 該例中

早期開發人員通過 if(!(this instanceof Rect)) 判斷 Rect 是通過建構式被調用, 還是通過一般函式被調用

因為一般函式與建構式的調用, this 的數值是不相同的(在下個章節進行討論)

function 的定義統一被移到 <Class Name>.prototype 這個區段, 而屬性則由實例自行維護

因此前兩個例子中:

  1. Class Example 所有的 Rect 實例, 會共享 Rect.prototype.addRect.prototype.print 的實現
  2. Class-like Example 所有的 Rect 實例, 不會共享 Rect.prototype.addRect.prototype.print 的實現, 相當於 addprint 的實現每次在 Rect() 調用時, 都被重新宣告/實現一次。

在例子2中, 使用的實例越多, 記憶體的使用則越劇烈

ES6 底下的類別

幸好在 ES6 (ECMA 2016)以後的標準, 提供了 classextends 關鍵字, 上方的 Rect 可寫為:

class Rect {
  constructor(w, h) {
    this.w = w;
    this.h = h;
  }

  add(r) {
    this.w += r.w;
    this.h += r.h;
  }

  print() {
    console.log(this.w + ', ' + this.h);
  }
}

let r1 = new Rect(1,1);
let r2 = new Rect(2,2);
r1.add(r2);
r1.print(); // '3, 3'

倘若多定義了 Square 正方形類別, 只需要透過 extends 關鍵字即可

class Square extends Rect {
  constructor(w) {
    super(w, w);
  }

  getArea() {
    return this.w * this.h;
  }
}

let s1 = new Square(10);
s1.getArea(); // 100

super 關鍵字會依照不同context, 決定super的數值。在此處中, 是調用 Parent Class 的建構式

在以前, 需要手動處理 prototype 的指向來模擬繼承敘述:

// Rect - 父類別
function Rect(w,h) {
  if(!(this instanceof Rect))
    throw new Error('is constructor');
  this.w = w;
  this.h = h;
}

// 父類別的方法
Rect.prototype.add = function (r) {
  if(!(r instanceof Rect)) // 可以通過 "instanceof" 判斷傳入的原型
    throw new TypeError('Only allow Rect-type');
  this.w += r.w;
  this.h += r.h;
}

Rect.prototype.print = function () {
  console.log(this.w + ', ' + this.h);
}


// Square - 子類別
function Square() {
  Rect.call(this); // call super constructor.
}

// 子類別擴展(extends)父類別
Square.prototype = Object.create(Rect.prototype);
Square.prototype.constructor = Square;

var rect = new Square();

console.log('Is rect an instance of Square?', rect instanceof Square); // true
console.log('Is rect an instance of Rect?', rect instanceof Rect); // true
rect.move(1, 1); // Outputs, 'Shape moved.'

倘若加上了 static 關鍵字, 其行為如同 C++的靜態方法:

class Square extends Rect {
  static internal = -1;

  constructor(w) {
    super(w, w);
  }

  getArea() {
    return this.w * this.h;
  }
}

let s1 = new Square(10);
s1.getArea(); // 100
Square.internal // -1

// ES 5 以前:
function Square(w) {
  if(!(this instanceof Square))
    throw new Error('');
}

Square.prototype.getArea = function(){ /* impl */ }
Square.internal = -1;

此外, 類別可以使用 new Square 或是 new Square() 的方式初始化(假設建構式不需要參數), 他們的差異是運算子優先順序

因為成員存取算運子.的優先度比較高, 所以使用

new Square.getArea 會導致錯誤, 因為 Square 不存在 getArea 這個靜態方法, 但是使用

new Square().getArea 則不會出錯, 因為他實際上調用了 (new Square).getArea

Tip

new Class().method() 等同於 (new Class).method();