JavaScript 物件導向式程式設計 (Object-oriented programming, OOP)

本篇來介紹在 JavaScript 中如何實作物件導向 (OOP)

物件導向程式使用「物件」來做設計,有兩個主要的概念:

  1. 類別 (class):將一件事物的特性封裝成一個抽象的類別,類別中會包含資料的形式以及操作資料的方法。
  2. 物件 (object):根據一個類別為模板,建立出來的類別實例。

宣告一個類別 (Defining a Class) - Constructor Function

JavaScript 是基於 Prototype-based 的語言,不像 C++ 或 Java 等 Class-based 的語言有 class 語法來宣告一個類別,JavaScript 使用函數 (function) 做為類別 (class) 的建構子 (constructor) 來定義一個類別。

var Person = function () {
    // ...
};

上面我們宣告了一個 Person 類別的建構子 (constructor function)。

constructor 在其他語言是 class,在 JavaScript 是 function。

定義類別的屬性 (Property) - this

你可以用 this 在建構函數中定義類別的屬性:

var Person = function (nickname) {
    this.nickname = nickname;
};

上面我們定義了 Person 類別的 nickName 屬性。

constructor function 或類別方法 (method) 中的關鍵字 this 用來引用物件本身。

定義類別的方法 (Method) - prototype

我們將一個函數加到類別的 prototype object 上,即定義一個類別方法:

Person.prototype.sayHello = function() {
    alert('Hello, I\'m ' + this.nickname);
};

上面我們對 Person 類別定義了一個 sayHello 方法。

類別的實例 (Instance),物件 - new

我們用關鍵字 new 一個 constructor function 來建立 (instantiate) 一個新的物件實例 (instance):

var mike = new Person('Mike');

操作物件:

// 用 "." 點號存取物件的屬性
mike.nickname; // Mike

// 或用 [] 的語法,也可以存取物件的屬性
mike['nickname']; // Mike

// 執行物件的方法
mike.sayHello(); // 顯示 Hello, I'm Mike

// 屬性可以讀,也可以寫
mike.nickname = 'Brad';

mike.sayHello(); // 顯示 Hello, I'm Brad

Prototype-based Inheritance (基於原型的繼承)

什麼是繼承 (inheritance)?就是一個類別可以繼承其他類別的屬性和方法,然後再延伸增加自己的。

在 C++ 或 Java 中,分別有 class 和 object,而一個 class 繼承自其它 class,我們稱做 class-based inheritance。而在 JavaScript 的繼承是 prototype-based,意思就是在 JavaScript 中沒有 class,所有的 object 都繼承自其它的 object

其實呢,在 JavaScript 中,幾乎所有的東西都是物件 (object),包含 (constructor) function 也是一個 object!

先再來介紹幾個觀念:

constructor

在 JavaScript 每個物件都有一個屬性叫 constructor,constructor 這屬性指向該物件的 constructor function。

例如:

var Person = function (nickname) {
    this.nickname = nickname;
};

var mike = new Person('Mike');

// true
mike.constructor === Person;

上面的 mike.constructor 會指向 mike 這物件的建構子 (constructor function),也就是 Person。

prototype

而每個 constructor function 都有一個屬性叫 prototype (原型物件),prototype 物件上面有類別的屬性和方法。

所以在 JavaScript 中,類別更精確來說其實應該是稱原型 (prototype)。

prototype chain

前面提到 JavaScript 的繼承是 prototype-based,那是什麼意思?

JavaScript 的繼承關係,是將不同的 object 藉由 prototype 串連在一起,形成一個 prototype chain (原型鍊)。JavaScript 在尋找一個物件有沒有某個屬性或某個方法,就是沿著 prototype chain 一路往上找,直到找到為止 (或找不到 undefined)。

__proto__, Object.getPrototypeOf(obj)

每個物件內部都有一個 __proto__ 屬性,指向一個物件繼承的原型 (internal prototype)。

你也可以用 Object.getPrototypeOf(obj) 方法取得一個物件的 internal prototype。

例如:

Object.getPrototypeOf(mike) === mike.__proto__
IE 在 IE9 開始才有支援 Object.getPrototypeOf(obj) 方法。
IE 在 IE11 開始才有支援 __proto__ 屬性。

Prototype-based Inheritance Example (繼承實作範例)

舉個例子來看 JavaScript 怎麼來實作繼承關係:

// 建立一個叫 Shape 的 constructor function
var Shape = function () {
};

// 建立一個物件 p
var p = {
    a: function () {
        alert('aaa');
    }
};

// 將 p 指定給 Shape (constructor function) 的 prototype
Shape.prototype = p;

// 建立一個新物件 circle (Shape 的實例)
var circle = new Shape();

// 顯示 aaa
circle.a();

// true
Shape.prototype === circle.__proto__;

// 在 circle 物件上增加一個 a 方法
circle.a = function() {
    alert('bbb');
};

// 顯示 bbb
circle.a();
+-------------------+
|  Shape.prototype  | 
+-------------------+

         ↑ circle.__proto__

+-------------------+
|      circle       | 
+-------------------+ 

上面的例子中,物件的繼承關係 (prototype chain) 就像上圖這樣,所有的屬性和方法會從最下面的 circle 開始找,一路找到 prototype chain 結束。所以第一個 circle.a() 會顯示 aaa 因為在 circle 物件中找不到,繼續往上找到在 Shape.prototype 中有 a() 這個方法;第二個 circle.a(),因為在 circle 有定義了 a() 這個方法,所以就會執行 circle 的 a()。

那 prototype chain 到最後會是什麼?答案是 null

再來看另一個繼承的例子:

// 叫 Animal 的 constructor function
function Animal(name) {
    this.name = name;
}

// Animal 的 prototype
Animal.prototype = { 
    canWalk: true,
    sit: function() {
        this.canWalk = false;
        alert(this.name + ' sits down.');
    }
};

// 叫 Kangaroo 的 constructor function
function Kangaroo(name) {
    this.name = name;
}

// 使 Kangaroo 繼承 Animal
Kangaroo.prototype = inherit(Animal.prototype);

// Kangaroo 的 prototype 方法
Kangaroo.prototype.jump = function() {
    this.canWalk = true;
    alert(this.name + ' jumps!')
};

// 建立 Kangaroo 的物件實例
var kango = new Kangaroo('kango');

// 顯示 kango sits down.
kango.sit();

// 顯示 kango jumps!
kango.jump();

// 繼承的 helper function
function inherit(proto) {
    function F() {};
    F.prototype = proto;
    return new F();
}

你可能會在其他地方看到另一種常見的繼承寫法,像是這樣:

Kangaroo.prototype = new Animal();

那跟我這邊使用一個 inherit helper function 的方法差在哪呢?

用 new Animal 的方法基本上也沒錯,因為實例化的物件原本就會繼承 Animal.prototype,但是用 new Animal 的方法有幾個缺點:

  1. 你需要事先知道 Animal 這 constructor function 的參數有哪些、怎麼用,像我們這邊沒傳參數 name 進去,就可能會不小心發生意外錯誤!
  2. 另外本質上,我們只是想繼承 Animal 的 prototype 啊,不是要建立一個 Animal 物件實例。

再來看看 inherit helper function 的作法就乾淨多了:

  1. 它先建立一個空的、乾淨的 F constructor function
  2. 再將 F.prototype 指向傳進來的 proto
  3. 最後將一個繼承 proto 的空物件傳回去

在這個例子中,物件的繼承關係 (prototype chain) 會像是這樣:

+----------------------+
|   Animal.prototype   | 
+----------------------+

         ↑ Kangaroo.prototype.__proto__

+----------------------+
|  Kangaroo.prototype  | 
+----------------------+

         ↑ kango.__proto__

+----------------------+
|        kango         | 
+----------------------+

這就是 JavaScript OOP 繼承的實作方法啦!

Polymorphism (多型)

OOP 中的多型 (polymorphism) 在 JavaScript 中的實作也很簡單,就是在子類別中重寫覆蓋 (override) 掉父類別中的方法或屬性。

例如:

Kangaroo.prototype.sit = function() {
    alert(this.name + ' sits as a Kangaroo!');
}

當執行 kango.sit() 時,就會優先執行 Kangaroo 中定義的 sit,因為 prototype chain 原理!

Private/Protected Properties/Methods - Encapsulation (封裝)

在 JavaScript 中沒有 private/protected 的屬性,只能用 naming convention 的方式,像是大家約定好不要直接存取底線開頭 (underscore) 的屬性。

例如:

function Animal(name) {
    this.name = name;
}

// protected
Animal.prototype._walk = function() {
    alert(this.name + ' walks.');
};

// public
Animal.prototype.walk = function() {
    this._walk();
}

var doggy = new Animal('doggy');

// 大家默契約定好只可以執行公開的 walk() 方法,禁止直接執行 _walk()
doggy.walk();

Static Methods/Properties

OOP 中的靜態屬性或方法在 JavaScript 中的實作方式,是直接將方法或屬性加在 constructor function 上。

例子:

function Animal() { 
    Animal.count++;
}

// 靜態屬性
Animal.count = 0;

// 靜態方法
Animal.getCount = function() {
    alert(Animal.count);
};

new Animal();
new Animal();

// 顯示 2
Animal.getCount();

instanceof

instanceof 運算子用來檢查一個物件 (object) 是否建立或繼承 (prototype chain) 自某個 constructor。

語法:

object instanceof constructor

instanceof 比較的邏輯是:

  1. 取出 object.__proto__
  2. 比較 object.__proto__constructor.prototype 是否相等
  3. 如果不相等,將 object 重設為 object = object.__proto__,再重複步驟 2,直到條件符合或 prototype chain 結束找不到為止

例子:

function Kangaroo() {}

var kango = new Kangaroo;

// true
kango instanceof Kangaroo;
function C() {}
function D() {}

var o = new C();

// true
o instanceof C;

// false
o instanceof D;

// true
o instanceof Object;

// true
C.prototype instanceof Object;

C.prototype = {};

var o2 = new C();

// true
o2 instanceof C;

// false
o instanceof C;

// 繼承
D.prototype = new C();

var o3 = new D();

// true
o3 instanceof D;

// true
o3 instanceof C;