2013/4/19

JavaScript 的繼承與實作

我差不多是在十幾年前開始接觸 JavaScript 的。和許多那時候的程式設計師一樣, 我並沒有很認真的把 JavaScript 當作「正常」的程式語言來對待。我寫的程式主要是以 ASP.NET 搭配 VB.NET 為主, 後來改為 C#; 可以說從一開始就戴著物件導向的眼鏡。而那個時候我們都被教導說 JavaScript 並非 OOP, 所以我也從來不認為 JavaScript 是一種物件導向語言, 也因此, JavaScript 從一開始就被我當作玩具, 是工作之餘的娛樂

我還記得, 我想應該不只是我, 許多程式設計師都喜歡到一些專門收集 JavaScript 程式碼的網站中去「借」點新奇有趣的 JavaScript 程式回來, 稍為改一下, 或者甚至不用改, 就套用到自己的網站上。這些前端程式碼可以做些後端程式辦不到的事情, 包括即時的畫面處理, 或者跑馬燈之類的。但是好玩歸好玩, JavaScript 對我來說仍然是可有可無; 那些前端效果和我所專注的後端程式一點關係也沒有。

不過, 說真的, 我也曾經想要好好的把 JavaScript 學好, 所以買了 JavaScript 的書回家研究。但是奇怪的事情發生了! 當我把枯燥無味的書都翻完之後, 雖然我已經了解 JavaScript 的基本型別、迴圈和各種語法, 卻發現我在網路上抄下來的 JavaScript 程式中, 竟然有一些書上沒有的指令!

照理說, 這些指令在瀏覽器上能跑, 表示這些指令一定是存在的, 但是為什麼在書上翻來翻去就是找不到? 這種事在 .NET 語言中是絕對不會發生的。.NET 的書籍素質比較高一點 (我也比較挑一點), 而且就算偶有遺漏, 也一定可以從 MSDN 網站上查到, 然而 JavaScript 並沒有類似 MSDN 的網站可以參考 (那時候也沒有什麼 w3school)。換句話說, 就算我想認真, 國內的書籍作者也不認真 (我指的是那時候, 不是現在)。久而久之, JavaScript 就又被我踢到不入流的語言之列了。

時至今日, 隨著可攜式裝備的普及, JavaScript 搖身一變, 成為程式語言的主流, 相關資源也豐富了; 實在是滄海桑田。但是, 連 JavaScript 的作者 Brendan Eich都說過, JavaScript 是在短短十天裡面匆促做出來的; 換句話說, 這個語言並不像 Java 或者 C#/VB 那樣立有遠大的志向; 它甚至在一開始就被要求不能像 Java 長得那般龐大, 以致於這種語言裡面沒有類別和介面這種概念, 也沒有其它 OOP 普遍具備的功能。

但即便如此, 幸好 Eich 仍然為 JavaScript 保留了許多強大的特色, 例如非同步載入、事件驅動 (event-driven), 以及動態的記憶體管理等等。不過, 最重要的, 就是他為 JavaScript 加入了 prototype-based 的這個特色, 使得這種語言一方面能維持靈活的解譯能力, 另一方面又能模擬 OOP 的各種功能。

背景與但書

我必須說 ECMA 6 之前的 JavaScript 頂多只能「模擬」OOP。因為缺乏真正的物件導向支援能力, 當你把它改造得像是 OOP 之後, 它仍然在許多方面表現得跟真正的 OOP (有天生的封裝、繼承和多型) 不一樣。這是一個非常重要的觀念, 所有 JavaScript 程式設計師都必須有這個認識。如果你以為把它改得像是 OOP, 然後就把它當作其它 OOP 一樣的使用, 那麼保證你會遇到許多莫名其妙的問題。

當然, 如果你堅持說改過的 JavaScript 長得像是 OOP, 它就一定是 OOP; 我不會跟你爭辯。什麼人要定義什麼, 我管不到; 一定要堅持什麼是什麼, 或者什麼不是什麼, 然後在那裡爭執不休, 這種事對我來講就是吃飽了太閒, 沒有意義。反正我上面講的那一段是重要的觀念, 寫程式的人有注意到就好。

要讓 JavaScript 模擬 OOP, 我們必須先來探討一下 OOP 的定義, 然後再跟我們所熟知的 OOP (本文中將以 C# 為例) 做個比較。

在本文中, OOP 的最重要觀念有三個, 這也是本文的主軸:

  1. 封裝 (Encapsulation)
  2. 繼承 (Inheritence)
  3. 多型 (Polymorphism)

至於 JavaScript 原本就有的資料抽象化 (abstraction) 特色, 或者像多載、覆寫等等技術上的細節, 我在本文中就不特別強調了。其它的 OOP 特色, 我在本文中也不會提到。在繼承這個部份, 我並沒有打算把它講得, 或者做得, 像 class-based 的 OOP 一樣。因為我並不覺得 JavaScript 的 prototype-based 特性是缺點; 它有其迷人之處。況且, 要把 prototype-based 改得像是 class-based, 我不認為有必要, 我的功力也沒有那麼高、也沒有興趣。充其量, 我只要讓 JavaScript 可以更輕鬆地做到容易架構這一點就行了。在我的想法中, 我也許會說這種繼承方式叫做 "class-like" 而絕非 "class-based"。

本文比較適合已具備 JavaScript 之基本常識, 且具 OOP 背景, 又對如何在 JavaScript 實現繼承有興趣的人閱讀。同時, 由於作者本身並非傳統的 JavaScript 開發者, 與讀者在用語和習慣上可能並不一樣, 請多多包涵。本文的某些觀點可能也只是作者本人的片面認知, 不一定代表 JavaScript 的普遍做法、傳統, 或者習慣。

我的寫作方式向來都是慢慢修改、慢慢增加的; 除了基本主旨不會改變之外, 本文在任何時候與現在你所看到的樣貌可能有明顯的差異。每當我對文章進行修改, 請恕我不另行通知; 我也不保證多久會修改一次, 以及會不會修改。同時, 我不會對本文的舊版內容進行備份。

我每次在發表本文時, 都會盡量力求文章內容的正確與精準。但是萬一有疏漏之處, 還請讀者多方見諒, 也希望讀者們不吝提供意見。當然, 我歡迎的是理性的討論, 而不是一來就擺明要踢館的慠慢態度。堅持你的定義才對、其他人的見解都是狗屁的基本教義派, 我也不歡迎。如果你是抱著以上那兩種心態來的朋友, 我誠心建議你不需要再往下閱讀, 因為那只會浪費你我的時間而已。

術語

在本文中, 我可能會使用一些術語; 有些是把原文術語翻譯過來, 有些則是我自己的定義。以下我把它們條列並簡單地說明 :

Prototype-based -

基於 prototype 的, 亦即底層是以 prototype 實作 behavior sharing 的。JavaScript 大概是唯一已知的 prototype-based 語言。若基於 prototype 而實現類似繼承的功能, 我們通常稱之為 prototypical inheritance。

Class-based -

基於 class 的, 亦即底層是以 class 實作 behavior sharing (其實不只 behavior)。例如 C#/VB/Java 等。若基於 class 而實現類似繼承的功能, 我們通常稱之為 classical inheritance。這裡的 "classical" 只是 class 的形容詞形式, 跟「古典」是同一個字, 但意義不一樣。

類別 -

亦即 class。中國則普遍對這個名詞以單字「類」稱之。在本文中, 不使用原始的 prototypical inheritance 方式, 而採用我介紹的兩種 pattern 實作的繼承, 當中的封裝單位我都以「類別」(必須加上對稱的全形括號) 稱之。

建構子 -

即英文中的 constructor, 亦即建立 instance 的函數。許多人可能會把它稱為建構式、建構函數、建構函式等等。

instance -

即物件的具體化存在。在中文裡, 有人稱為副本、實例、實體等等, 中國則多稱為「实例」。由於並沒有公認的最好翻譯, 所以我在本文中保留不翻, 維持英文型態。在 JavaScript 中, 由於它並沒有 class 的觀念, 所有能參考的東西都是 instance。所以 JavaScript 裡所謂的「建構子」, 和 class-based 中的「建構子」, 雖然名稱一樣, 做的事情卻有一點差距。後者的概念是以類似「樣版」的方式, 將抽象物件予以實體化, 前者卻採用「工廠」(factory) 概念, 從另一個 instance 拷貝其 prototype 的值, 從而建立另一個 instance (所以才叫做 "prototype-based" 語言)。有興趣的朋友可以參考參考資料 [12], 內有簡要說明。

物件 -

即 object, 中國用語是「对象」。在 JavaScript 中幾乎什麼都是 object, 但是原始型別 (數字、字串等) 的變數卻不是 object。這種概念和其它傳統 OOP 有很大的差距。在 OOP 語言中, class 是 class, object 是 object。在 JavaScript 中模擬出來的「類別」卻不是抽象的, 它有實體。也因此我在本文中都把在 JavaScript 中模擬出來的「類別」以括號括住, 提醒讀者不要誤解, 以免發生混亂。

此外, JavaScript 中有 "Object" 這個函數, 並且習慣以 "object" 稱呼物件的 instance。這也容易讓人產生混淆, 無法立即區分究竟「物件」這個字是指抽象的物件, 還是具體的物件。所幸, 由於 JavaScript 中所有 object 都已經是 instance, 所以引發誤解的情況並不會太多, 唯有談到非 Object 型別的變數時, 記得要和其它 OOP 慣用的「物件」作個區隔 (也可以以「變數」稱之), 以免產生誤解。

然而, 非物件的變數卻擁有物件的 prototype (例如宣告 var i = 4, 而 i 卻擁有 Object.prototype 中的 toString() 和 hasOwnProperty() 等方法)。而以 var i=4 (型別為數字) 和 var i=new Number(4) (型別為物件) 的兩個變數, 卻有同一個建構子 (function Number())。其實這是因為 JavaScript 會暗地裡幫原始型別變數套上隱形的物件 wrapper 所致。以上談到的都是容易讓人混淆之處, 讀者唯有多留意, 寫程式時要記得適時地抛開物件導向的框架。如此, 程式的 bug 也許不會變少, 心情至少可以快樂點。

函數 -

function。亦即使用 Function() 建構子建立的物件。也有人稱為函式。所有使用 Funcion() 建構子所建立的 instance, 都會繼承 Function.prototype。要知道一個變數是否為函數, 使用 typeof someobject === 'function' 來檢測即可。

字面標注 -

即 literal。在本文中主要是用以表示以大括號包住的幾近於 JSON 標注字串, 用來建立物件。也有人稱之為實字、字面值等等。實際上 literal 不只用來表示物件, 它可以用在許多地方[20], 例如 var arr = [2, 3, 5, 7, 11, 13, 17]; 這裡的 "[2, 3, 5, 7, 11, 13, 17]" 也是 literal。在 JavaScript 中, 字面標注方法很有彈性, 例如你可以標示一個陣列為 ['John', , 'Mary']; 它代表三個陣列元素, 其中被省略的那個元素, JavaScript 會使用 undefined 來代表它的值。

方法 -

即 method。在 JavaScript 中並沒有真正的「方法」; 那是 OOP 的概念。在 OOP 中, 類別中不外乎屬性和方法, 方法就是函數。然而, 在 JavaScript 中, 函數卻是物件(這就是為何 function 物件可以可以呼叫在 Object.prototype 中定義的方法, 例如 .toString()), 也是我們用來實作「類別」的單位。為避免混淆, 我以「方法」來稱呼定義於「類別」中的 function (它還有另一個意義, 下面會再講到)。MDN 也同樣使用 method 來指稱 function 中定義的 function。

寫法 -

我指的是 pattern。和傳統的 OOP 不同, JavaScript 並沒有提供「標準」的繼承方式。或者更正確地講, JavaScript 根本沒有 OOP 的「繼承」機制, 但是卻有 prototypical inheritance 這種 behavior sharing 的概念與方法。因此, 要做到類似 classical inheritance, 有很多種「寫法」, 或者說「做法」。在本文中至少會提出三種; 只有後面一種 (即 Parasitic Combination Inherientance) 才是我真正推薦的。值得注意的是, 如果不清楚了解 JavaScript 的 prototype 原理, JavaScript 開發者有可能會寫出有問題的繼承方法而不自知。因此, 正確的寫法/做法是有必要的。

JavaScript 的物件與方法有什麼特別?

前面提到, JavaScript 是 prototype-based 的語言 (請同時參考我寫的「JavaScript 中的 Delegation」一文; 本文乃是以該文為基礎); 我稍後將會寫到如何實作的方法。但在此之前, 我們得先復習一下 JavaScript 裡面的物件模型 (Object Model)。這一點非常重要, 因為我的程式裡會一再重複出現 JavaScript objects, 而且它的語法和用法和其它語言有很大的差異, 我最好先把它說明清楚。

在 JavaScript 中, 所有物件都衍生自 Object 這個物件, 所有物件都有 prototype 這個內建的屬性 (只有一個例外, 後面再解釋), 所有物件都從 Object.prototype 繼承屬性和方法。以下, 我們先來看看 JavaScritp objects 的宣告方式 (本文中許多範例都是從「JavaScript 中的 Delegation」中的範例修改或衍生而來) :

var Chessman = { name: "empty", location: {}, isAbroad: false };

以上 Chessman 是一個以 var 宣告的變數。在 JavaScript 中如果使用 var 宣告一個變數, 僅僅是宣告它是一個變數而已; 這點可能和你所使用的語言的變數宣告方式在本質和型式上有很大的不同。事實上, 你可以連 var 都省略掉, 而能通過 JavaScript 的解譯程式 :

Chessman = { name: "empty", location: {}, isAbroad: false }; // 寫成這樣只是因為語法允許, 我並沒有要求或建議你採用這種寫法!

凡是寫過 JavaScript 的人偶爾都有一種共同的感覺, 就是「怎麼寫都對」! 作為一種動態語言, JavaScript 的自由度對許多人而言似乎是太高了一點, 而其嚴謹度則遠遠比不上 Java/C# 之類的語言。然而, 過於自由的語法也經常造成除錯上的盲點。我建議程式設計師應該遵行規範 (不管是公司的規範、團隊的規範, 甚至是自己的規範), 如此寫出來的程式才會有條理、容易看懂, 也容易除錯。所以, 還是乖乖把 var 加回去吧! 如此, 如果你在程式中看到 var 這個關鍵字, 就知道該變數是在這裡宣告, 而不是已經宣告過的。

此外, 任意地省略 var, 會造成大家都知道的 scoping 問題。如果你不知道這個問題, 可以參考 MDN。至於建立物件的方式, 下面還會再講到。

在上述寫法中, Chessman 就是一個 object。說實在話, 它看起來似乎稍為有點類別的影子。如果你看不出來的話, 讓我把它寫成這樣吧 :

   1:  var Chessman = {
   2:      name: "empty",
   3:      location: {},
   4:      isAbroad: false
   5:  };

這樣是不是更像了呢?

可惜的是, 面子像了, 裡子卻不像。我在上面說過, Eich 設計 JavaScript 的原始目的, 就是要讓它不要和 Java 太接近, 所以 JavaScript 是沒有類別的。在上述程式碼的寫法中, 它只是 JavaScript object 的寫法而已 (有人把 JavaScript 的此種標示方式取了一個名字, 叫做 JSON – JavaScript Object Notation, 亦即「JavaScript 物件標示」。不過, 其實我們慣用的 JSON 另有其定義, 與 JavaScript 程式裡的物件字面標注方式存在著程度不大的差異)。

在上例中, name 是一個有預設值的字串, isAbroad 是一個有預設值的布林值, location 則是一個空物件。它們是 Chessman 的屬性 (後面再解釋)。

如果我把上面的 Chessman 從 var 改成 function 呢? 如下所示 :

function Chessman(name, location, isAbroad) { };

除了原始型別的變數不算, 在 JavaScript 中幾乎什麼都是物件; function 也是物件。所以, 即使我把 Chessman 宣告成一個 function 而不使用 var, 它仍然是一個物件。

對 JavaScript 而言, function 是這種語言中最接近類別的東西了。事實上, 我們的確必須透過 function 物件來模仿 OOP。以下, 我要透過 function 的建構子 (在下例中為 Chessman()) 建立兩個 instance :

   1:  function Chessman(name, location, isAbroad) {
   2:      this.name = name;
   3:      this.location = location;
   4:      this.isAbroad = isAbroad;
   5:  }
   6:  var Soldier = new Chessman('Soldier', {}, false);
   7:  var Knight = new Chessman('Knight', {}, false);

到這裡為止, 或許你又開始覺得 function 根本就是類別嘛! 錯了! 有建構子不一定就是類別。因為在 JavaScript 裡你可以這樣寫:

Chessman.id = 1;

而程式不會跳出錯誤。但是在 C#, 編譯程式就會把你擋下來。

為什麼在 JavaScript 可以而 C# 不行? 因為我說過了, function 本身也是物件, 也是一個 instance; 而 C# 中的類別並不是物件, 更不是 instance。重點是, 我再說一次, JavaScript 裡面根本沒有類別! 就像「少年 Pi 的奇幻漂流」這部電影裡的情節一樣, 你千萬不能把那隻老虎當作朋友, 即使你和牠在相處時看起來像是朋友一樣 (扯遠了)...

function 並不是類別, 它在本質上不是。然而,  我們必須透過 function 模仿 OOP 的許多行為 (封裝、繼承等等)。如果你不能在一開始便認清這個事實, 你未來只會不斷地苦惱並抱怨說明明許多其它 OOP 可以輕鬆辦到的事, 在 JavaScript 中卻必須迂迴地做, 或者甚至根本辦不到。在我的文章裡, 我不打算把 function 稱為類別 (我會定義其它形式的「類別」, 後面再講), 但是在別人的文章裡, 他們可能會把 function 稱之為類別。不管如何, 每當你看到 JavaScript (我指的是 ECMA 6 之前的版本, 也就是大家現在使用的版本) 中的「類別」(包括本文), 都要記得它並不是真類別, 那是模仿的。

此外, JavaScript 中的物件事實上是從對應的參考型別 (reference types) 所生成的 instance [13]。這裡所謂的「參考型別」, 可能無法以傳統 OOP 的方式來理解, 因為它雖然也叫做「參考型別」, 但是卻和傳統 OOP 中的所謂「參考型別」有相當大的差距。我在後面講到型別的時候, 再來說明。

另外再來補充一下, 在 JavaScript 中, 我們如果講到「方法」(method) 這個詞, 它事實上是一個包含 Function() 物件的屬性 (請參考 MDN); 當我們說「執行某個方法」時, 它實際上是把那個 funcion 物件取出來執行。所以它的實際意義與實作方式, 跟 C# 或其它語言不太一樣。但是我們在使用時, 可能感覺不到什麼差異。這個定義和我在本文中的其它定義不太一樣, 但其實並不衝突。

註: 我在上面使用了「Function() 物件」(括號可以省略, 但 F 必須大寫) 這種說法, 指的是使用 Function() 建構子所新建的 instance。同理, 如果你看到「Object() 物件」這種說法, 意思就是使用 Object() 建構子所新建的 instance。依此類推。在部份 JavaScript 的書籍裡可以看到這種寫法; 並不是我發明來誤導人的。

此 JSON 與彼 JSON

有朋友總愛強調 JavaScript 中對物件的字面標示方法「不叫做 JSON」。我的回應是「如果那不叫做 JSON, 那叫做什麼?」

JSON 原本就是「JavaScript 物件標注記號」的縮寫; "JSON" 這個名詞就是這麼來的。那麼, 當我們確實在 JavaScript 程式裡把它用來做標注物件時, 它怎麼又「不叫做」JSON 了? 確實, 當 JSON 被當作交換文件的規格時, 文件內的 identifier 和 value 都要加上雙引號; RFC 4627 規格 (JSON 的最初規範) 裡確實也這麼寫。但是, 如果你仔細看一下 RFC 4627 的目的 :

The application/json Media Type for JavaScript Object Notation (JSON)

你可以看出, 這是一份為 JSON 而設計的交換文件的規格。換包話說, 它定義的是 application/json 這種文件的規格! 這種文件經過交換之後, 其內容需經不同程式 (parser) 進行解析, 再把資訊予以擷取, 所以標注成 "identifier" : "value" 這樣的方式, 當然是合情合理 (在文件裡, identifier 是字串, value 也是字串, 當然必須用引號包起來)。但是我們在 JavaScript 程式中的字面標注中, 屬性可以不加上雙引號, 因為它的解析者不是 document parser 而是 interpreter。因為這個原因 (及其它), 所以有些人堅持說 JavaSript 裡的標注方式不叫做 JSON。

我舉一個例子來解釋這種情況。例如, 我叫做 Johnny, 假設有一天我開了一家公司, 也叫做 Johnny; 那麼, 難道從此 "Johnny" 這個字就只能用來代表「公司」? "Johnny" 這個字是因我而得, 但是我這個「人」反而不能叫做 "Johnny" 了! 有這種道理嗎?

同樣的, 一個字串如果內含特殊符號 (例如引號) 的話, 那麼, 若要把它放進 JavaScript 程式裡, 必須加上逸出字元 (規格也是這樣寫的)。但你能說它應該因此而不能再叫做「字串」嗎?

如果一定要很明白地講清楚, 那麼我認為「JavaScript 的物件字面標注方式和 JSON 文件不一樣」這句, 可以算是客觀的說法, 而不是一句「那不是 JSON」就把 JSON 的原始意義給扭曲了。

未來, 你一定還會在許多地方看到有人把 JavaScript 程式中的物件標注方式稱為 JSON。知道有這麼回事就好, 不要再去計較這種必也正名乎的小細節了! 著眼在更重要的事情上吧!

將 function 定義於物件的 Prototype

我已經在「JavaScript 中的 Delegation」一文中介紹過 JavaScript 的 prototype [4][8][10], 在這裡我就不再介紹, 而是直接把它拿來使用。

首先, 我要把原來的範例程式修改一下, 並且引進 prototype :

   1:  function Chessman(name, location, isAbroad) {
   2:      this.name = name;
   3:      this.location = location;
   4:      this.isAbroad = isAbroad;
   5:  }
   6:   
   7:  Chessman.prototype = {
   8:      move: function () {
   9:          console.log(this.name + ' moved.');
  10:      }
  11:  }
  12:   
  13:  var Soldier = new Chessman('Soldier', {}, false);
  14:  Soldier.move(); // “Soldier moved.” 
  15:  var Knight = new Chessman('Knight', {}, false);
  16:  Knight.move(); // “Knight moved.” 

這個程式與上一個程式的最大差異, 就在 Chessman.prototype 這一段指令。"prototype" 是 JavaScript 的內建關鍵字, 也是每一個 JavaScript 物件天生就有的屬性 (但是它不一定存取得到, 我稍後會再說明)。我們可以透過 prototype 同時新增物件的屬性和方法; 例如 :

   1:  Chessman.prototype = {
   2:      color: 'Black',
   3:      move: function () {
   4:          console.log(this.name + ' moved.');
   5:      }
   6:  }

你不一定要採用這種物件表示法; 你也可以把它拆開來寫 :

   1:  Chessman.prototype.color = 'Black';
   2:  Chessman.prototype.move = function () {
   3:      console.log(this.name + ' moved.');
   4:  }

這種寫法和上一種寫法, JavaScript 解譯程式的實作方式其實略有不同, 但是我們在使用時可能看不出差異, 讀者自己留意就可以了。此外, 由於 prototype 並不是本文中的討論重點, 裡面的學問又有點複雜; 有興趣的人可以去看看我在文章最後面的一些參考資料。我就不在這裡浪費大家時間了。

在上面這段程式裡, 我除了原來的 move 方法之外, 又另外加入一個 color 屬性。然後 Chessman 及其衍生物件就多了一個 color 屬性可用, 例如 :

   1:  var Soldier = new Chessman('Soldier', {}, false);
   2:  Soldier.color = 'Red';
   3:  console.log(Soldier.color);

如果你使用 Visual Studio 2012 進行程式編輯的話, 你會發現, 一旦你在 Chessman.prototype 中加入了 color 屬性之後, Soldier 變數的屬性在 intellisense 列表中就會多出這個 color 屬性, 非常方便。

VSintellisense

不過, 話說回來, 如果你的 function 的存在目的是為了模仿 OOP 的類別的話, 你其實不需要在覆寫  prototype 時加入屬性 (即此程式中的 name、location、isAbroad 和 color 等)。你應該在建立 function 時明白地以 own-property 型式進行屬性宣告, 如下例 :

   1:  function Chessman(name, location, isAbroad) {
   2:      this.name = name;
   3:      this.location = location;
   4:      this.isAbroad = isAbroad;
   5:      this.color = 'Black';
   6:  }

在本文中, 我一律將 function 的所有屬性寫在 function 的定義裡 (如上面這個程式的寫法), 而不是寫在被覆寫的 prototype 裡。在 prototype 的屬性宣告中, 我只會定義新的方法 (例如以下範例程式 1 裡 Chessman 的 move 方法), 不會有屬性 (除非是共用屬性)。再說一次, 沒有人說你一定得遵循這種規則不可; 這只是一種樣式 (pattern) 而已。這種寫法有個專有的名稱, 叫做 "Combination Constructor/Prototype Pattern" (見參考資料 [13] 第六章, 第166頁)。依照這種寫法, 可以達成特定的目的。

有人認為這種寫法會造成 scope 不一致的問題, 亦即封裝單位的屬性和方法並不處於相同的 bag 裡。然而, 這是一種牽強而毫無道理的說法, 因為它並不會造成問題。雖然屬性和方法位於不同的 bag (一個是 own property, 一個是 prototype), 它們仍然被封裝在一起。因為, 所謂的封裝, 就是對於外界而言, 無需知曉它的內部運作, 而仍然能夠安全地存取裡面的資訊。我把整個 pattern 當作封裝的單位, 亦即我所定義的「類別」,  但是屬性和方法是不是位於不同的 bag, 並不會造成問題。當然, 以上這句話只限於把它當作「類別」來用。我並沒有要求或建議你把這種「類別」使用在任何其它情境之下

為方便講解和引用, 在文章裡, 我將在本文中把這個「封裝的單位」簡稱為「類別」。而且, 為了表示這「類別」在本質上和 OOP 的類別不一樣, 所以本文中的這種「類別」, 我都會以括號括住, 以表示它只是模仿 OOP 的「類別」, 而不是真的類別。

只要是套用了 "Combination Constructor/Prototype Pattern", 那麼, 就請避免在覆寫 prototype 時加入非方法的屬性 (除非是共用屬性 -- 再強調一次), 因為如此會減低程式的可閱讀性。所以, 覆寫 prototype 時加入方法即可。

截至目前為止的程式碼如下, 你可以自己試著練習 :


範例程式 1

在這個程式中, logMsg 方法只是方便程式把測試訊息輸出到結果視窗而已, 跟本文主旨無關。

其實, 根據 ECMA-262 的規範, 物件的 prototype 是無法直接被存取的, 因此, prototype 裡面的屬性也是不可列舉的。換句話說, 你無法使用 for 迴圈列出物件的 prototype 已經被定義了多少個屬性。雖然你可以在 Visual Studio 2012 的 IDE 中, 從 intellisense 提示裡看到這些屬性, 你還是無法以 JavaScript 程式把它列舉出來; 我好像也沒聽說 JavaScript 有 reflection 可以用。

然而, 在 Safari、FireFox 和 Chrome 這類瀏覽器裡倒是提供了 __proto__ 這個虛擬屬性, 讓你可以存取物件的 prototype (例如 obj.__proto__.toString())。但是這種東西只對某些瀏覽器有效, 也不在 ECMA 6 以前的正式規格裡, 所以至少在 ECMA 6 普及以前, 我並不推薦你去使用它。

不過, 雖然 function 也是物件, function 物件卻可以存取其 protytype。例如上面的 Soldier 物件, 可以透過 Soldier.prototype 以取得其 prototype 內的屬性。如果你企圖對其它物件取 prototype, 你只會取到 undefined。

如果我們採用 "Combination Constructor/Prototype Pattern" 或者我最後一節會介紹的 "Parasitic Combination Inheritance", 我們不需要, 也不應該在「類別」之外又去定義其它屬性和方法。換句話說, 所有「類別」的屬性和方法都應該是預先定義的, 而不應該是動態定義的, 也不應該被動態地改變, 否則它就不應該稱之為「類別」(即使是加上括號的)。

JavaScript 的型別系統

有件有趣的事情跟讀者們分享一下。我曾經看到有人在網路上發問, 說為什麼他對 instance (例如範例程式 1 的 Soldier 和 Knight) 下 typeof 檢查, 得到的結果竟然是 object 而不是 Chessman?

會問這種問題, 很顯然那位仁兄是以 Java 或者 C#/VB 的觀點在思考。作為一個弱型別/動態型別的語言, JavaScript 的型別系統還真的很弱、很動態... 特別是如果與其它強型別語言比較的話。

如果各位讀者也和我一樣是從 C#/Java 的角度來看, 那麼 JavaScript 的型別系統比我們想像中還要複雜。

在最新的 ECMA-262 5.1 規格中 (Clause 8), JavaScript 的的型別有以下幾種 :

  • ECMAScript 語言型別 (ECMAScript language types) :
    • Undefined
    • Null
    • Boolean
    • String
    • Number
    • Object
  • 規格型別 (Specification types) :
    • Reference
    • List
    • Completion
    • Property Descriptor
    • Property Identifier
    • Lexical Environment
    • Environment Record

其中規格型別屬於運用於語言型別的中介值 (meta-value), 我們不會直接宣告這種型別, 因為這種型別是值型別的「樣版」(這只是協助了解的形容詞; 並不一定精確)。相對的, 語言型別這類值型別 (value types) 才是我們所熟知而且不可不知的型別。不過, 我這裡所說的「值型別」, 跟 C# 中的「值型別」在意義上並不完全相同。

事實上, 在 JavaScript 中也有「參考型別」(reference types), 不意外的, 它和 C# 中的「參考型別」在意義上也不完全相同。

不過, 我先暫時跳過這一段不談。性急的朋友, 可以先去參考參考資料 [13] 第五章 "Reference Type"。否則可以等我下次修改本文時, 再來談這個主題。

所以, JavaScript 仍然是有型別的, 但是它並沒有像 .NET 語言中的「自訂型別」這種東西。你千萬不要期望 typeof 指令會傳回你認為正確的型別。例如, 如果你對一個陣列變數下達 typeof, 它會傳回 object; 對 null 下達 typeof, 它也傳回 object; 對一個數字變數下達 typeof, 則傳回 number。但是如果你對上述範例中的 Chessman 下達 typeof, 它竟然會傳回 "function" -- 雖然 "function" 並不在 JavaScript 的語言型別的列表中。

換句話說, JavaScript 中的 typeof, 跟 C# 之類語言的 typeof 方法, 顯然有不同的邏輯。(為了協助大家了解, 我也把上面的 MDN typeof 說明頁中文化了。你在這裡也可以看到為何 typeof null 會得到 'object' 的原因 – null 真的不是 object; 它什麼都不是)

那麼, 如果 JavaScript 沒有 function 這種型別, 為什麼 typeof 會傳回 function 這種「型別」來?

在 JavaScript 中, function 確實是個 object, 但是 JavaScript 會分辨一個物件是不是 function; function 是 object, 行為卻與 object 不同 (所以我們可以透過 function 變數模仿繼承, object 變數不行), 這是因為一種 object 可能有多種規格型別, function 的規格型別和其它 object 不同。

那麼, 如果你需要知道某個物件是否衍生自某個 function, 該怎麼辦?

問題的答案很簡單, 就是改用 instanceof 這個指令來做判斷, 不要使用 typeof。如以上範例, 你如果執行 Soldier instanceof Chessman 這道指令, 就會得到 true, 表示該物件衍生自 Chessman。如果得到 false, 表示該物件並非衍生自 Chessman。總之, 你就是無法使用 typeof 指令企圖得出什麼「父類別」的型別 -- 根本沒這種東西。

同樣有趣的事情是, 你使用 Soldier instanceof Chessman 可以得到 true, 你如果使用 Soldier instanceof Object, 也會得到 true。這個現象看起來似乎有點類似 C# 的巢狀繼承。然而, JavaScript 的繼承並沒有這麼直覺, 我會在後面提到。

物件的建立

從技術面來說, 物件是規格型別屬於 Reference 的一個 instance (關於 JavaScript 的型別, 請參考上面「JavaScript 的型別系統」一節)。在前面的範例程式中, 我們也曾經看到使用 new 指令建立一個 instance 的方法。以下我把最常見的幾個建立物件的方法列出來 :

第一種, 我們可以以字面標注 (literal ) 的方式建立物件。本文第一個範例就是如此 :

   1:  var Chessman1 = {
   2:      name: "empty",
   3:      location: {},
   4:      isAbroad: false
   5:  };

第二種方法, 就是使用 new + 建構子, 例如類似範例程式 1 中的寫法 :

var Soldier = new Chessman2('Soldier, {}, false);

在這裡, Chessman2() 指的是範例一裡面定義的 Chessman1() 函數, 所以不要跟第一種方法裡的 Chessman1 物件搞混了。在上面這一行程式中, Chessmans2 叫做 Soldier 的建構子 (constructor)。這時 Chessman2 會傳回一個物件 (若不是當作建構子, 一般的 function 本來只會傳回 false, 相當於 C# 中的 void), 指派給新建立的 instance。

JavaScript 初學者可能會忽略一點, 其實我在「JavaScript 中的 Delegation」一文已經提到過; 那就是使用 new 關鍵字建立起來的 instance (如下方範例程式 1.5 裡面的 Soldier) 在呼叫 "parent" (即 Chessman2) 內的方法 (例如 move()) 時, 方法中的 "this" 關鍵字不再指向它自己 (即 Chessman2) 而是 instance (即 Soldier)。如果你看不懂這是什麼意思, 除了範例程式 1.5, 也可以再回去操作一下「JavaScript 中的 Delegation」文中的範例, 應該就可以理解。

前面兩種方法都是用來建立一般物件, 也就是我們說的 object instance; 以下兩種則是函數物件的建立方法。

第三種方法, 我們可以使用比較不一樣的形式來宣告一個函數物件 :

   1:  var Chessman3 = function() {
   2:      name = "empty";
   3:      location = {};
   4:      isAbroad = false;
   5:  };

如果跟第一種方法的 Chessman1 做個比較, 雖然都是以 var 宣告, 但 Chessman1 的建構子是 Object() (因此它是個物件, 也是前面講過的「Object() 物件」) 而 Chessman3 的建構子則是 Function(), 所以它是個 function。

第四種則是慣用的函數定義方法 :

   1:  function Chessman4 () {
   2:      name = "empty";
   3:      location = {};
   4:      isAbroad = false;
   5:  };

使用第三種及第四種方式定義的函數, Chessman3 和 Chessman4, 兩者在使用上並沒有太大的不同。甚至, 如果這兩個函數有參數的話, 代入參數的方法也一樣。但是 JavaScript 的內部實作方式其實有點不太一樣; 觀察其 prototype.constructor 就可以看出來。

不過, 若不去討論技術細節, 那麼使用者可以把 Chessman3 和 Chessman4 當作同一種東西來使用, 在絕大部份情況下不會出問題。然而, 如果遵循好的程式設計規範, 除非你確實有什麼特殊的理由, 否則我不建議你採用第三種寫法。我建議還是永遠都採用第四寫法比較好。在本文中我一律採用第四種寫法。

如果你很想知道第三種和第四種宣告方式到底有什麼差異, 那麼你可以自己去看 "JavaScript function declaration ambiguity" 這篇文章的解釋, 我就不在這裡重複引述了。

在本文中, 如果要使用 "Combination Constructor/Prototype Pattern" 來模仿繼承, 我們必須使用第二種方法 (new + 建構子) 來建立 instance。當然, 在範例程式 1 裡的 Soldier 仍然是使用 Object() 建構子建立起來的。我們在下面的其它範例中, 會覆寫建構子。

如果你想要實際操作測試, 可以使用以下的 jsFiddle 並觀察其結果:


範例程式 1.5

你可以試著把程式中第 26 行的註解拿掉, 看看結果有何不同。當我們真正套用 pattern 以實作繼承時, constructor 是必須被覆寫的。

除了上面提到的幾種建立物件的常見方法, 在 JavaScript 中其實還有其它的建立物件的方法, 我不打算在這裡一一介紹。例如, 自從 ECMA 5 之後, JavaScript 事實上又多出一種建立物件的方法, 也就是利用 Object.create 方法 (各大瀏覽器對這個指令的相容程度可以參考 caniuse 網站), 寫法如下 :

   1:  var created = Object.create(Soldier1);
   2:  created.move([3, 3]);

這個建立起來的 created 物件相當於 Soldier1 的複製品, 卻是個完全獨立的 instance。事實上, Object.create 方法和上述的第二種方法是一樣的; 它只是把過程包裝在 Object.create 這個單一方法而已。因此, 我在以下的範例裡就不採用這個方法了。

各種基本型別物件的建構子

事實上, 許多宣告為 JavaScript 基本型別的變數, 也可以使用建構子, 然後成為物件。例如 :

var ar = new Array();

我們也可以和 C# 一樣對建構子傳入常數以做為其初始值, 例如 :

   1:  var var01 = new Array([[1,2], 'Cross', [1,3]]);
   2:  var var02 = new String('ok');
   3:  var var03 = new Number(3);

不過, 和 C# 很不一樣的是, 以下這些指令竟然也是可以執行的 :

   1:  var var04 = new Array('ok');
   2:  var var05 = new Number('I am a string.'); // 其實會得到 NaN, 但是並不會引發例外
   3:  var var06 = new String(5);

其實更有趣的是 Boolean 建構子; 如果你不懂它的邏輯, 你會對它的結果感到莫名其妙。例如 :

   1:  var var07 = Boolean(undefined); // false
   2:  var var08 = Boolean(-0); // false
   3:  var var09 = Boolean('ok'); // true
   4:  var var10 = Boolean(null); // false

原來 Boolean 建構子如果接收到 undefined、null、false、0、-0、NaN 和空字串等參數的話, 會回傳 false, 其餘都回傳 true。

談到這裡, 我想來釐清一個觀念。在 JavaScript 中,

   1:  var var11 = '我是字串';
   2:  var var12 = new String('我是字串');

這裡的 var11 和 var12 這二者是不同的東西。雖然二者都是變數 (variable), 但 var11 是「字串」, 是原始型別, 而 var12 是「字串物件」, 是物件型別。在某些國外的書籍中, 它很可能會把 var12 這種內含 string 資料的物件稱為 "wrapper" (包裝)。基本上如果你看到什麼原始型別的 "wrapper", 基本上就是指物件了, 只是它的內容屬於該原始型別 (Number, String 等)。

JavaScript 總共提供了九種內建的建構子:

  1. Number()
  2. String()
  3. Boolean()
  4. Object()
  5. Array()
  6. Function()
  7. Date()
  8. RegExp()
  9. Error()

這九種建構子都可以使用 "new + 建構子" 的型式建立新的物件。

那麼, 如果你使用 var var11 = '我是字串'  或者 var var11 = String('我是字串' ) 這種方式 (未透過建構子) 建立了一個變數, 我們一般並不稱這個 i 是一個 instance (雖然它確實是一個有值的實體); 它也不是物件。只有使用建構子初始化 (instantiate) 的物件, 例如 var var12 = new String('我是字串'), 它才稱為 instance, 也才是物件。這和 C#/VB 的定義有點差異。然而, var11.constuctor 是 "functioin String()", 但 var12.constuctor 也是 "functioin String()" ! 這是怎麼回事?

的確, 這兩個變數的建構子都是 String() 函數。差異在於, 如果你沒有加上 new 這個識別字。即使都是呼叫 String() 建構子, 若沒有加上 new, 它會傳回一個原始型別的「值」; 加上 new, 它會傳回一個複雜型別的「物件指標」。所以你對 var11 下 instanceof Object 時, 它會傳回 false; 對 var12 下 instanceof Object 時, 它會傳回 true。

我前面說過, 所有物件都有 prototype, 但是 var11.prototype 是 undefined。為什麼? 你可以試著自己去執行以下兩行程式, 再看看結果 :

   1:  Object.getPrototypeOf(var11); // TypeError: Object.getPrototypeOf called on non-object
   2:  Object.getPrototypeOf(var12); // String {}

從以上兩行的執行結果可以看出, var11 確實不是物件, var12 是物件。但是如果你執行以下兩行 :

   1:  var11.__proto__; // String {}
   2:  var12.__proto__; // String {}

不過, 在 Chrome 瀏覽器中, 兩者都取得到 __proto__。所以千萬不要以「能不能取到 __proto__ 的值」來判斷一個變數是不是 object。

自動轉換型別的問題

由於 JavaScript 是一個動態語言, 它會暗地裡幫你做許多型別轉換。寫程式的人必須十分謹慎, 防呆要多做點, 否則不知道何時會發生莫名其妙的問題。例如 :

'2' + '3' // 23
'2' - '3' // -1
'2' * '3' // 6
'2' / '3' // 0.6666666666666666 

然而

'2' + '3' + 3 // '233'
'2' - '3' + 3 // 2
'2' * '3' + 3 // 9
'2' / '3' + 3 // 3.6666666666666666

諸如此類的事情很多。所以, JavaScript 實在比其它語言更需要嚴格的寫作規範, 否則寫出來的程式品質堪慮。

此外, 如果你的觀察力敏銳的話, 也許會發現我們在範例程式 1 裡面 function.prototype 的寫法, 也使用了字面標注宣告。不過我不會說使用這個方法建立了一個 function.prototype 這個 instance; 若用比較精確的講法, 我會說我「覆寫」了這個 function 的 prototype 屬性。我後面會再說明。

再探 Prototype

前面說過, 我不在本文中詳細介紹 prototype, 但是關於 prototype, 有幾個觀念我想在這裡稍為提一下。

首先, 請記得物件的 prototype 本身也是一個物件。如果你不相信的話, 你可以試著執行以下指令 :

typeof Chessman.prototype // 這裡的 Chessman 是在前面的範例程式中建立的 instance

猜猜看結果是什麼? 是 object。

不過, Chessman.prototype.prototype 就是 undefined 了。換句話說, JavaScript 中所有函數物件都有 prototype 屬性, 該屬性本身也是一個 object; 但是這個 object 並沒有 prototype 屬性; 這與後面會再提到的使用 Object.create(null) 建立的物件是同一個概念。

從以上這一點可以看出來, JavaScript 的型別系統, 至少它的 object 型別, 並不是放諸四海皆準的。單以 prototype 來看, 它是一個 object, 可是它又不同於一般的 object。這種行為與 C# 完全不同; 在 C# 中, 只要任何物件的型別一樣, 它們就一定會有完全相同的預設屬性和方法 (雖然 C# 的 Object 型別並沒有預設屬性), 而且它的「型別」絕對不會是一個物件 (沒有 instance)。在 JavaScript 中, 執行 "Number instanceof Object" 這道指令的結果是 true; 若把這裡的 Number 改成 String 或者 Array 等等, 結果都是 true。

其次, 如果我們採用 "Combination Constructor/Prototype Pattern" 寫法的話 (亦即如同範例程式 1 的寫法), 我們可以說 Soldier.prototype 和 Knight.prototype 就是拷貝自 Chessman.prototype。換句話說, 只要我們定義了 Chessman.prototype, 那麼 Soldier.prototype 和 Knight.prototype 就自動擁有 Chessman.prototype 的值 (在我的範例程式中, 這個「值」指的就是 name, location 等屬性, 以及 move 方法; 你也可以再自行修改或增加其它屬性和方法)。我把這個概念以圖片表示如下 :

JavaScript OO.1

從模仿 OOP 的角度, 我們可以乾脆說 Soldier 和 Knight 兩個物件「繼承」了 Chessman。雖然鑒於 JavaScript 的本質, 使用「繼承」二字並不是十分精確, 但是這些行為確實在某種程度上以「繼承」來形容, 並不離譜。

然而, 你必須十分注意 this 這個關鍵字在 "parent" 物件 (範例中的 Chessman) 上的使用時機。如果你不了解這句話的意思, 你可以先去閱讀「JavaScript 中的 Delegation」一文。在該文中我曾提到繼承和委派是兩種不同的機制, 在 JavaScript 中採用了委派而沒有繼承。但是在這篇文章裡, 由於我們要模仿 OOP, 所以我在以下的文字裡會使用「繼承」二字。這裡的「繼承」當然也只是模仿的。

繼承

在 JavaScript 中原來就已經提供 prototypical inheritance, 我把做法稍為講述一遍。基本上, JavaScript 的每個物件都有 own property, 我想寫過 JavaScript 的朋友都應該已經知道了。然而物件 (包括函數) 除了 own property 之外, 又有 prototype 這個屬性。在 prototypical inheritance 的原理中, 繼承的原則並不難理解, 就是最低層的 instance 會從自己的 own property 中尋找屬性或方法, 如果找不到, 就再去 prototype 尋找。再找不到, 就再到上一層的 prototype 尋找, 一直到找不到為止。這個尋找的路徑就叫做 "prototype chain"。當然, 因為只有函數才能成為繼承鏈的一環, 所以在整個繼承鏈中, 除了最底層的 instance 不是函數物件之外, 其它都必須是函數物件。

要使用 JavaScript 的 prototypical inheritance, 原理很簡單, 就是把下層 function 的 prototype 指向上層的 instance, 然後下層的 protoype 就會是上層的 own property 的拷貝。如果你看不懂上面這句話, 我們來看看這個範例 :


範例程式 1.8

為了不影響本文的一貫性, 我在這個範例中, 就暫時不使用象棋做例子了。

所有的現代人 (包括你我) 都是智人種 (homosapiens), 往上屬於哺乳綱 (mammalia), 再往上屬於動物界 (animalia)。人可以講話、哺乳類可以授乳、動物都可以走動。奶娘一號 "nurse1" 直接繼承 homosapiens, 而 homosapiens 繼承 mammalia, 而 mammalia 再繼承 animalia。

當然, 物種分類不是只有這麼三層, 這種表示方式是簡化過的, 只是為了示範而已。而實作此種繼承的方式也很簡單, 你只要依照程式中的做法就可以了! 我在每個上層函數中都各指定了一個屬性和一個方法, 沒有使用覆寫。當然, 你也可以使用覆寫, 也可以自行增減屬性。只要符合這種做法就可以很輕鬆地辦到繼承。

以上是 JavaScript 中最原始的繼承方式(不過它有缺點, 請參考以下連結)。當然, 要做到繼承, 並不是只有這種寫法而已。但是範例程式 1.8 的寫法應該已經是最簡單直接的了! 如果你對 JavaScript 的其它傳統繼承寫法有興趣, 你可以參考「談JavaScript使用prototype實作物件導向的探討」這篇文章, 我覺得它的分析很詳盡, 也有談到原始繼承方法的缺點; 請讀者自行參考, 我就不在這裡贅述了。

Combination Constructor/Prototype Pattern

下面我要示範套用 "Combination Constructor/Prototype Pattern" 的寫法, 把範例程式 1 改寫。我們先來看原始碼 :


範例程式 2

範例程式 2 基本上就是「JavaScript 中的 Delegation」一文中的範例。不過我在本文中引進了 "Combination Constructor/Prototype Pattern" 寫法, 所以看起來略有不同, 但差異並不大。

定義在物件本身的屬性 (例如 Soldier1.id 和 Soldier1.color), 稱為 own property; 你可以使用上面提到過的 hasOwnProperty 方法檢測物件中是否存在某個 own property。

原則上, 在 JavaScript 的既有結構下, 你所建立的每個物件最終都會繼承 Object.prototype, 只有一個例外, 就是刻意使用 ECMA 5 的指令 Object.create(null) 所建立的物件 (也就是在 prototype chain 中最後那個 prototype 指向 null 的那個物件)。例如, 在 JavaScript 中所有物件都有 toString 方法, 唯獨在這種刻意建立的物件上, toString 方法會傳回 "has no method 'toString'" 錯誤。

透過如範例程式 2 的寫法, 我們可以說 Soldier1 繼承自 Soldier, Soldier 繼承自 Chessman, 而 Chessman 又繼承自 Object。如此, 我們達成了繼承功能。同時, 由於我們成功地把屬性與方法包在物件裡; 而下層物件繼承了上層物件的屬性和方法, 同時可以擴充, 所以我們也實現了 OOP 的「封裝」。

會取 "Combination Constructor/Prototype Pattern" 這種奇怪的名字, 主要是因為它是 Constructor Pattern 和 Prototype Pattern 等幾種我沒有介紹的 pattern 的結合, 而且做了改良。使用這種寫法, 我們把屬性寫在函數的建構式裡面, 而把方法以及共用的屬性寫在 prototype 裡面。如此, 我們讓每個 instance 既定義了自己的 own properties, 又可以共享方法和共用屬性, 可以節省一些記憶體。因此, 它確實是一種 (相較於其它 pattern) 優秀的 pattern。

有朋友說這個 pattern 並不是為了繼承而發明的。我無法理解為何有這種想法。這個 pattern 的主要目的就是為了實現繼承, 而且是一個比其它 pattern (例如在範例程式 1.8 所展示的) 還要好的做法。當然, 它並非沒有缺點; 我在下一節會提出更為優秀的 pattern。

此外, 也有朋友指出, class-based 的基本精神就是不應該在類別以外再去新增屬性或者方法。然而, 如同我在前面提過的, 我並沒有打算讓 JavaScript 變成 class-based 的語言! 我說過, 我甚至並沒有把 function 稱為 class。在我的定義中, 一整個 "Combination Constructor/Prototype Pattern", 包括上層和下層 function 及其 prototype 的寫法, 才是個別的資訊封裝的單位, 和 C# 之類語言的「類別」並不一樣。我從來沒說過兩者是一樣的, 我更沒打算把 JavaScript 改得像 C# 一樣。我只希望能提出一種更具彈性的, 可以多層繼承的做法而已。如果你一定要使用類別, 那麼請使用 TypeScript, 或者等到 ECMA 6, 你或許就會有真正 (至少比我這篇文章介紹的方法還來得「真正」) 的類別可以用! 你對於「類別」的定義, 請用在真正的類別上面, 別用在這裡!

針對這個議題, "Comparing JavaScript OOP to .NET" 這篇文章做了蠻精闢的分析, 也提出了另一種 pattern。對於必也正名乎的朋友, 麻煩自行參考。如果你喜歡那種替代的寫法, 覺得一定得使用那種寫法, 才能對得起你自己良心, 那麼請自便。

關於 prototype

許多朋友非常拘泥於是不是所有 object 都有 prototype 這件事。事實上, JavaScript 作為一種 prototype-based 語言, 它原本就提供 prototypical inheritance; 所以它的物件非提供 prototype 不可。而 JavaScript 的 prototype 都是屬性 (property, 但也有人稱之為 attribute), 而屬性本身又是一個 object。我知道上面這幾句話實在像是繞口令, 然而事實就是如此。

很不幸的, 在 JavaScript 裡, 它偏偏就把 function 的 prototype 屬性取名做 "prototype"。

我說每個 object 都有 prototype, 這句話其實並沒有錯。但是很多人卻說 object 沒有 prototype, function 才有。為什麼?

正確地說, function 有一個叫做 "prototype" 這個屬性, object 卻沒有一個叫做 "prototype" 這個屬性, 但其實凡是 object (function 也是 object) 都有 prototype (指向其建構函數的 prototype), 只是 function 可以透過 "prototype" 這個屬性直接存取, object 不行 (請參考我在上面講過的 object 的 prototype 成員無法被列舉那一段), 而只能透過某些瀏覽器才有的 "__proto__" 屬性存取。對於沒有提供 "__proto__" 屬性的瀏覽器而言 (例如 IE), 則完全無法被存取。這並不是 IE 的錯; 相反的, IE 這樣做才是依照 ECMA 的規格。

因此, 所謂「所有 object 都有 prototype」這件事, 根本是不需要爭執的、是完全浪費時間的問題。如果一定要辯出個結果, 那麼最折衷的說法就是「所有 object 都有 prototype, 但是只有 function 這種 object 的 prototype 可以透過名為 "prototype" 的屬性存取, 其它 object 不能, 而只能透過某些瀏覽器有提供的 "__proto__" 屬性存取」。以上述程式為例, 我們可以用 Soldier1.__proto__.isValidMove(), 也可以使用 Object.getPrototypeOf(Soldier1).isValidMove() 以取出 Soldier1 物件的 prototype 之下的 isValidMove 方法。

如果你還沒辦法搞清楚的話, 我舉一個並不一定很適合的例子來說明。就好像某路人 A 的爸爸的名字就叫做「爸爸」(不要問我他為什麼要取這個名字! 反正名字不是我取的)。那麼, 爭論「人都有爸爸」這句話正不正確, 有什麼意義? 但偏偏有人要說「不是所有人都有爸爸, 是 A 才有爸爸」!

誰對? 誰錯? 請讀者自己評斷吧!

Parasitic Combination Inheritance

到目前為止, 我們的程式都使用 "Combination Constructor/Prototype Pattern" 來實現繼承。要模仿 OOP, 這種樣式是 JavaScript 中最普及的樣式之一; 但是這種樣式據說有個效能上的缺點, 所以後來又有了 "Parasitic Combination Inheritance"(見參考資料 [13] 第六章, 第179頁); 它事實上也是一種 pattern。寫法並不複雜, 原則上跟 "Combination Constructor/Prototype Pattern" 差不多, 只是加上一個 inheritPrototype 方法而已。

首先, 我們先宣告上層 function 以及下層 function, 然後加上以下方法 :

   1:  function inheritPrototype(subType, superType)
   2:  {
   3:      var prototype = Object(superType.prototype); //create object
   4:      prototype.constructor = subType;             //augment object
   5:      subType.prototype = prototype;               //assign object
   6:  }

將上層和下層 function 傳入, 就建立起二者的繼承關係了。以下是範例程式 :


範例程式 3

範例程式 3 和範例程式 2 的差異並不大, 唯一要注意的幾個地方, 除了加入了一個我們已經講過的 inheritPrototype 方法 (你不需要把這個方法取名為 inheritPrototype; 你可以取任何喜歡的名字) 之外, 我們在下層 fuction (即 Soldier) 的宣告處把原來的寫法小小變動了一下, 使用 Chessman.call 將參數 (即 'Soldier') 代入。接著, 我們呼叫了 inheritPrototype 方法以建立上層 (Chessman) 和下層 (Soldier) 兩個 function 之間的繼承關係。

至於建立 instance (即 Soldier1 和 Soldier2) 的寫法就跟原來的寫法一模一樣了。

結論

在這篇文章裡, 我示範了如何使用 JavaScript 的 prototype 以達成巢狀繼承, 以及套用了兩種 pattern 的做法。以上幾個範例程式都寫在 jsFiddle 裡, 你可以自行修改、複製, 並且立即看到結果。

然而, 像 C# 之類的 OOP, 其物件導向原理並沒有那麼簡單, 而我在這裡也只示範了很簡單的封裝與繼承而已, 並沒有談到多型。請務必記得一點, JavaScript 不是 C#, 也不是 Java, 在許多方面雙方是無法互相模仿, 甚至無法對比的, 例如 Access Modifier (public、protected、internal 和 private 等等), 還有 Accessors (virtual、sealed、override 和 abstract 等等), 以及其它。

不管如何, 只要封裝和繼承是可行的, 那麼 JavaScript 就具備了可擴充的架構, 讓我們更容易開發較大型的程式。但是 JavaScript 畢竟是動態的解譯程式, 它不需要, 也不適合用來建構非常大的、多人共同開發的程式 (雖然這只是我的個人看法, 也許事實已經隨著時代而改變了)。當然, 如果你很勇敢, 你想要拿 JavaScript 來創一番「大事業」, 那是你的選擇。但是在那之前, 如果你聽得進去的話, 或許使用 C#/VB/Java 這類號稱為「真正」的 OOP 會比較省工夫吧!

參考資料:

  1. Java Script: Designing a Language in 10 Days
  2. Wiki – JavaScript
  3. JavaScript 中的 Delegation
  4. Understanding JavaScript Prototypes
  5. Quick JavaScript OO Primer
  6. OOP In JavaScript: What You NEED to Know
  7. (MDN) Object.prototype
  8. JavaScript Prototype in Plain Language
  9. ECMAScript® Language Specification
  10. The magic __proto__ property
  11. JavaScript: prototypes, scoping, and the general gotchas
  12. Prototypal Inheritance in JavaScript
  13. "Professional Javascript for Web Developers", Zakas, 2011
  14. (JavaScript) Objects
  15. (JavaScript) var
  16. (JavaScript) typeof
  17. Working with objects 
  18. Can I use...
  19. "Using Prototypical Objects to Implement Shared Behavior in Object Oriented Systems", Lieberman, 1986
  20. Values, variables, and literals
  21. Comparing JavaScript OOP to .NET
  22. 談JavaScript使用prototype實作物件導向的探討

1 則留言:

  1. 各位讀者,

    很抱歉, 由於技術上的疏忽, 我不小心把原來的網頁刪除了! 現在這一篇是復原後的版本, 網址也變動了。

    若有造成不便, 敬請見諒!

    -Johnny

    回覆刪除