JavaScript OOP 入門

簡介

JavaScript 雖然沒有像 class 這樣的 keyword 用來宣告 class, 但它的設計確實是能實現 OOP 的.
JavaScript 是 prototype based programming language. 意思是, 所有的東西都是以 prototype 來定義的. Prototype 在這裡跟 C++, Java or C# 的 class 是類似的, 都是用做產生 object 的樣版.
不過, 因為 JavaScript 是 scripting language, 所以說, 這個 prototype 就不會是在宣告時期就定死了的, 而是在 runtime 時還可以再加以改變的.
剛接觸時, 因為這種以 prototype 宣告 class 的語法比較離散, 相對的就沒有其他語言好瞭解 (class 的內容集中到一塊). 但如果關鍵的地方理解了, 其實你會覺得不過就這樣..
最後, 不掌握 JavaScript 的 OO 開發模式還有 variable 的 scope, 在配合 HTML 處理 event 時一定會碰到許多奇奇怪怪的問題 (尤其是看似類似但又不相似的 this 關鍵字), 所以, 不能不重視!

練習環境

如果是在 Ubuntu 12.10 或之後的版本, 可安裝 gjs 做為執行 .js 的工具$ sudo apt-get install gjs
Mozilla 官方的 JavaScript shellhttps://developer.mozilla.org/en-US/docs/SpiderMonkey/Introduction_to_the_JavaScript_shell
或是可以參考 Mozilla 列出的這堆 JavaScript shellhttps://developer.mozilla.org/en-US/docs/Web/JavaScript/Shells
下面的 sample code 是在 gjs 中試驗的. 要放到 browser 中執行, 只要改掉 print() 即可.

開始

最簡單的 class (沒有 field 也沒有額外 method 的 class)function Shape() { }
在 JavaScript 中, function 可以是 function, 也可以是 object 的 constructor, 這通常是初學時比較難接受的地方. 用上面的 class 產生 objectvar shape = new Shape();
接下來為 Shape 加個 argb propertyfunction Shape(argb){    this.argb = argb;}
var shape = new Shape(0xffff0000);print("0x" + shape.argb.toString(16));
這 樣我們就產生了顏色為紅色 (r = 0xff) 且不透明 (a = 0xff) 的 Shape object. 這裡 Shape() 就是用做 constructor 對 property argb 進行初始. this.argb = argb 即是把目前正要產生的 Shape object 的 property argb 值設為傳入的 argb 參數.
再來為 Shape 加個 draw() methodfunction Shape(argb){    this.argb = argb;    this.draw = function() {        print("Shape[rgba=0x" + this.argb.toString(16) + "]");    };}
var shape = new Shape(0xffff0000);shape.draw();
又看到 this.XXXX = OOOO 這樣的寫法, 這裡是將 Shape object 的 draw property 指定一個 function, 供後序呼叫. Shape class 就這麼簡單.
接下來, 增加一個 Point class 做為 Shape 的 child classfunction Shape(argb){    this.argb = argb;    this.draw = function() {        print("Shape[rgba=0x" + this.argb.toString(16) + "]");    };}
function Point(argb, x, y){    Shape.call(this, argb);    this.x = x;    this.y = y;}
var shape = new Point(0xffff0000);shape.draw();
在 Point 的 constructor 中, 我們將 Shape 的 constructor 串 (chain) 在一塊, 這樣 Point 就會有 Shape 的成份, 也可以對新 new 出來的 Point object 呼叫. 但, 這樣 Point 就是 Shape 的 child class 了嗎? 用 instanceof 試試print(shape instanceof Object);print(shape instanceof Shape);print(shape instanceof Point);
執行結果truefalsetrue
所以

  • shape 是 Object (JavaScript 中所有 class 的 parent) 的 child class
  • shape 不是 Shape
  • shape 是 Point

這是因為, 在 JavaScript 中認定兩個 class 有沒有關係, 看的是 prototype, 而不是共用 constructor 與否, 不然, 就算兩個 class 長得再像, 它們都是沒有直接的父子關係的, 如下圖
要 怎麼把 Point 變成 Shape 的 child class 呢? 這時候就要用到 prototype. 在 JavaScript 中, 所有的 class 都有 prototype 這個屬性. 而 object 有 proto 參考到它所屬的 class 的 prototype. 所以, 要讓 Point 成為 Shape 的 child class, 必須要把兩者的 prototype 關聯上. 一種網路上找得到的做法是function Shape() { print("Shape()"); }
function Point() { Shape.call(this); print("Point()"); }Point.prototype = new Shape();
var shape = new Point();
print(shape instanceof Object);print(shape instanceof Shape);print(shape instanceof Point);
執行結果Shape()Shape()Point()truetruetrue
這個做法, 是錯的. 雖然 Point 成為 Shape 的 child class 了 ((shape instanceof Shape) == true), 但 prototype 串連時做了一個 new Shape() 的動作造成 Shape 的 constructor 被多執行了一次, 這並不是我們預期的結果. constructor, 不應該在宣告 class 時 (將 Point 跟 Shape 經由 prototype 關聯在一塊時) 就動作, 而是應該在 new Shape 時動作. 造成這個問題的原因是在 Shape 後多了 (), 以下是正確的版本…Point.prototype = new Shape;…
去掉了 Shape 後的 (), 避免 constructor 的執行. 之所以 new 出 object 後指定給 Point.prototype 也可行的原因可參考這個比較表
另一個做法如下function Shape() { print("Shape()"); }
function Point() { Shape.call(this); print("Point()"); }Point.prototype = Object.create(Shape.prototype);
var shape = new Point();
print(shape instanceof Object);print(shape instanceof Shape);print(shape instanceof Point);
gjs 建議的做法如下, 這種直接設定 proto 的做法似乎是 SpiderMonkey 上的 hack, 但在 seed 上測過看起來也沒問題function Shape() { print("Shape()"); }
function Point() { Shape.call(this); print("Point()"); }Point.prototype = {    proto: Shape.prototype    };
var shape = new Point();
print(shape instanceof Object);print(shape instanceof Shape);print(shape instanceof Point);
上 面這個寫法需要做些解釋. 先前有說過所有的 object 都有 proto 這個屬性, 參考到 object 所屬的 prototype. prototype 本身也有 proto  屬性 (JavaScript 中, 所有的 instance 都是 object), 但跟 object 不同的是它是參考到 parent 的 prototype 上. 所以上面的寫法, 意思是在初始 Point class 時, 將 Point.prototype.proto 參考到 parent (Shape) 的 prototype, 細節可參考這個段落
最後, 加上 draw() function 的 override, 完整範例如下function Shape(argb) {    this.argb = argb;    print("Shape()");}Shape.prototype = {    draw: function() {        print("Shape[argb=0x" + this.argb.toString(16) + "]");    }}
function Point(argb, x, y) {    Shape.call(this, argb);    this.x = x;    this.y = y;    print("Point()");}Point.prototype = {    proto: Shape.prototype,        draw: function() {        print("Point[argb=0x" + this.argb.toString(16) + ", " +              "x=" + this.x + ", " +              "y=" + this.y + "]");    }};
var shape = new Point(0xffff0000, 50, 50);shape.draw();
print(shape instanceof Object);print(shape instanceof Shape);print(shape instanceof Point);
至此最簡單的 object model 就成型了.

小實驗

JavaScript 下 inheritance 實現的方式千奇百怪, 以下為想到的方式跟執行的結果

實驗 1

function Shape() {};
function Point() {};
Point.prototype = Shape;
print(new Point() instanceof Shape);        // falseprint(new Point() instanceof Point);        // true

實驗 2

function Shape() {};
function Point() {};Point.prototype.proto = Shape;
print(new Point() instanceof Shape);        // falseprint(new Point() instanceof Point);        // true

實驗 3

function Shape() {};
function Point() {};Point.prototype.proto = new Shape;
print(new Point() instanceof Shape);        // trueprint(new Point() instanceof Point);        // true

實驗 4

function Shape() {};
function Point() {};Point.prototype.proto = Shape.prototype;
print(new Point() instanceof Shape);        // trueprint(new Point() instanceof Point);        // true

實驗 5 (只是變更 object 的 prototype, 無 inheritance)

function Shape() {};
var shape = new Object();shape.proto = Shape;Shape.call(shape);
print(shape instanceof Shape);        // false

實驗 6 (只是變更 object 的 prototype, 無 inheritance)

function Shape() {};
var shape = new Object();shape.proto = new Shape;Shape.call(shape);
print(shape instanceof Shape);        // true

實驗 7 (只變更 object 的 prototype, 無 inheritance)

function Shape() {};
var shape = new Object();shape.proto = Shape.prototype;Shape.call(shape);
print(shape instanceof Shape);        // true

實驗 8

function Shape() {};
function Point() {};
var shape = new Object();shape.proto = new Point;shape.proto.proto = new Shape;Shape.call(shape);Point.call(shape);
print(shape instanceof Shape);        // trueprint(shape instanceof Point);        // false

實驗 9

function Shape() {};
function Point() {};
var shape = new Object();shape.proto = Point.prototype;shape.proto.proto = Shape.prototype;Shape.call(shape);Point.call(shape);
print(shape instanceof Shape);        // trueprint(shape instanceof Point);        // true

實驗 10

function Shape() {};
function Point() {};Point.prototype.proto = Shape.prototype;
print(Shape);                    // function Shape() {}print(Shape.prototype);            // [object Object]print(Shape.proto);            // function () {}print(Shape.prototype.proto);        // [object Object]
print(Point);                    // function Point() {}print(Point.prototype);            // [object Object]print(Point.proto);            // function () {}print(Point.prototype.proto);        // [object Object]

實驗 11

function Shape() {};
var new_proto = new Shape;print(new_proto);                // [object Object]print(new_proto == Shape);            // falseprint(new_proto == Shape.prototype);    // false

結論

new 的使用其實是沒有必要的,  像實驗 8 這樣的情況還有可能不小心造成非預期的效果 (原因應該可從實驗 11 解釋 - new Shape 後基本上就跟 Shape.prototype 是不同的東西了), 只要將 Shape.prototype 指定給正確的 proto, class 間的關係就可以建立起來, class 的初始也會比使用 new 來得有效率 (減少一堆 object 的產生).

delete 的用途

JavaScript 跟其他的 scripting language 一樣, 有 garbage collector (GC). 因此, delete 不會是用做 object 的刪除, 這點要搞清楚. delete 的用途只是去掉對 object 的參考, object 會在沒有參考對象時被 JavaScript engine 進行回收. 並且, 對象只能是 object 的 property, 而不能是 variable, 例如function Shape() {};var s = new Shape();        // 產生 Shape object, 參考 = 1var x = new Object();x.y = s;                // 參考 = 2
delete x.y;            // 刪掉 x 的 y property, 參考 = 1s = null;                // 回收
被 delete 的 property 將不存在, 相對的也就沒有地方能保持對 Shape object 的參考, 當 s 又被設成 null 時, Shape object 就會被回收. 這樣的觀念很重要, 參考沒管理好, 雖然有 gc, 但還是可能會有 memory leak 的產生 (一般 memory leak 指的是 unreachable memory block, 所以這裡指的是效果).

對 object 及 prototype 增刪 property/method 效果各有什麼不同?

對 object 增刪 property/method 的影響範圍只限該 instance, 同類型的 object 並不會被改變, 例如function Shape() { }var shape1 = new Shape();shape1.argb = 0xffff0000;var shape2 = new Shape();
print(shape1.argb);print(shape2.argb);
執行結果4294901760undefined
但對 prototype 操作則影響 “所有” 屬於該 prototype (class) 的 object, 例如function Shape() { }var shape1 = new Shape();Shape.prototype.argb = 0xffff0000;var shape2 = new Shape();
print(shape1.argb);print(shape2.argb);
執行結果42949017604294901760
這 種設計非常的強大! 試想, 當你拿到了人家的 binary library, 想對 class 階層中較上層的 class 進行修改時, 在 C++, Java 或是 C# 中, 能做的是 subclassing string class, 然後把這個新功能加在裡面, 或是寫一個提供這個功能的 class 後把 string 傳給它進行運算, 比較分散些. 但在有這樣功能的語言如 JavaScript, Python, Ruby 還有 Objective-C 中, 就像上範所示般的容易. 許多 JavaScript 的 framework 都依賴於這樣的機制, 對 built-in class 進行 hot-patch, 用以修正不同版本 JavaScript engine 間的差異或是擴展現有 class 的功能.

參考資料

gjs style guildehttps://live.gnome.org/GnomeShell/Gjs_StyleGuide
Working with objectshttps://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Working_with_Objects?redirectlocale=en-US&redirectslug=JavaScript%2FGuide%2FWorking_with_Objects#Defining_getters_and_setters
Learn JavaScripthttps://developer.mozilla.org/en-US/learn/javascript
JavaScript versions comparisonhttps://developer.mozilla.org/en-US/docs/JavaScript/Guide/About?redirect=no
https://developer.mozilla.org/en-US/docs/JavaScript/Introduction_to_Object-Oriented_JavaScript?redirect=no