:::

談JavaScript使用prototype實作物件導向的探討

image

JavaScript雖然是物件導向的程式語言,但是他的物件導向方法並不統一,跟我們在學校所學的C、Java或甚至是PHP等都有很大的差異。

最知名的JavaScript繼承方法之一是使用「prototype」(此處並不是在講Prototype的JavaScript Framework喔),Fillano(馮旭平)談論物件導向Javascript - 實作繼承的效果為prototype的繼承方法作了很多說明,網路上也有很多教學是以「prototype」方式進行,例如JSDoc在介紹使用方法中就有示範一段以prototype作為繼承的例子。

我在論文系統發展初期也是使用prototype來實作繼承,但是做到一半的時候,我發現prototype其實是有很多問題的。除了老是要撰寫「this.prototype.」的宣告開頭讓程式大小居高不下,prototype還有使用「傳址」(連結位址) 而非「傳值」(複製資料) 來初始化變數的缺點,這會導致繼承時上層類別的變數會因為傳址而被變更的問題。

這一篇就是要來談談JavaScript中以prototype實作繼承的這個問題,並且一一探討解決的方法。


prototype實作繼承的方法與問題

prototype是JavaScript物件中特殊的一種屬性,透過指定prototype屬性,便可以指定要繼承的目標。然而當類別的屬性為「物件」的資料型態(例如有個屬性為birthday物件,而該物件包含了year、month、day這三種屬性),而方法本身又是修改該屬性中的屬性(例如修改birthday中的year這個屬性)時,就會影響到上層物件的屬性內容。

以下我舉一個簡單的範例:「Ancestor」上層類別跟「Child」子類別。首先下圖是他的UML類別圖:

image

將上面的類別圖寫成JavaScript物件導向的繼承,就會變成下列的程式碼:

//上層類別
function Ancestor() {
    this.setBirthdayYear(1889);
}

//屬性
Ancestor.prototype.birthday = {
    year: null,
    month: null,
    day: null
};

//方法
Ancestor.prototype.setBirthdayYear = function (year)
{
    this.birthday.year = year;
}; 

//子類別
function Child() {
    //即使Child沒有宣告setBirthdayYear的方法,
    //也可以使用上層類別Ancestor的setBirthdayYear
    this.setBirthdayYear(1915);  
}

//Child繼承Ancestor
Child.prototype = new Ancestor();   

最重要的繼承方法是最後一行「Child.prototype = new Ancestor();」,透過此宣告,Child類別就能使用上層類別Ancestor的屬性birthday跟方法setBirthdayYear()。

乍看之下這樣是沒有問題的,但是實際上Child在執行setBirthdayYear時,卻會連帶地影響到Ancestor的birthday屬性。舉例來說:

var ancestor = new Ancestor;
var child = new Child;
document.write(ancestor.birthday.year);    //應該要顯示1889,但卻顯示1915
document.write(child.birthday.year);    //顯示1915

很遺憾地,Ancestor的birthday屬性的確被Child修改了。由此可知,這種JavaScript物件導向的實作方式有著傳址運作的缺陷。

改進prototype的實作方式:在constructor指定參數

要改善上述實作方式的方法之一,是在constructor(建構子)的時候指定參數,讓每次類別在實體化成為物件的時候,都去重新指定參數的內容。以下是修改之後的Ancestor類別:

//上層類別
function Ancestor() {
    //初始化屬性
    this.birthday = {
        year: null,
        month: null,
        day: null
    };
    
    this.setBirthdayYear(1889);
}

在建構子當中加入屬性初始化的設定之後,就算之後Child修改了Ancestor的屬性,因為Ancestor在實體化的時候每次都會將屬性初始化,所以Ancestor看起來就像是不會受到Child影響一樣。

值得注意的是,這只有在屬性為物件時才可能會受到影響,請對可能受到影響的屬性物件進行初始化,如果屬性的資料型態為字串、數字、布林值,那麼即使不進行初始化也是無所謂的。相關的探討請見JavaScript中參數的傳值與傳址探討

這樣做是正確的,但在多重繼承的時候,仍會有一些問題發生。請繼續看以下的探討。

多層繼承時將會導致問題發生

儘管上一節中為類別的建構子加入初始化屬性的設定,就可以暫時解決上層類別受到影響的問題,但是如果要實作多層繼承的時候,卻仍因為最下層類別不會去執行最上層類別的建構子,導致最後仍沒有進行初始化屬性的錯誤。

接下來我再舉一個例子作為說明,以下是多層繼承的UML類別圖:(雖然這並不是一個很標準的物件導向的例子,大家請不要見怪。)

image

現在我們有4個類別,各別是Grandfather爺爺、Father爸爸、Son兒子、Daughter女兒。他們用上述的prototype實作方式來寫成JavaScript程式碼之後,會如以下:

//最上層類別
function Grandfather() {
    this.birthday = {
        year: null,
        month: null,
        day: null
    };
    this.setBirthdayYear(1889);
}

//屬性
Grandfather.prototype.birthday = {
    year: null,
    month: null,
    day: null
};

//方法
Grandfather.prototype.setBirthdayYear = function (year)
{
    this.birthday.year = year;
}; 

//上層類別
function Father() {
    this.setBirthdayYear(1915);    
}

//Father繼承Grandfather
Father.prototype = new Grandfather();    

//子類別之一
function Son() {
    this.setBirthdayYear(1943);    
}

//Son繼承Father
Son.prototype = new Father();    

//子類別之二
function Daughter() {
    this.setBirthdayYear(1945);    
}

//Daughter繼承Father
Daughter.prototype = new Father();

類別名稱修改,並且加入了Son跟Daughter這兩個類別之外,基本上跟前面舉例中的Ancestor與Child沒有太大差別。但是這樣子執行的時候,卻會發生很大的問題:

var grandfather = new Grandfather();
var father = new Father();
var son = new Son();
var daughter = new Daughter();

document.write(grandfather.birthday.year);    //顯示1889
document.write(father.birthday.year);    //應該要顯示1915,但卻顯示1945
document.write(son.birthday.year);    //應該要顯示1943,但卻顯示1945
document.write(daughter.birthday.year);    //顯示1945

你可以注意到,Daughter修改了屬性的資料,連帶影響到了Father跟Son,也就是說,除了最上層Grandfather因為有在建構子時初始化屬性之外,其他沒有在建構子中初始化屬性的子類別仍會受到影響。

你可能想到說:那麼同樣地也在Father、Son、Daughter時初始化屬性不就得了?但這樣的作法是與物件導向中為了不要重複撰寫程式碼的原則相違背,因此並不推薦這麼做。

解決方式:在建構子呼叫上層類別的建構子

為了解決上述的問題,就必須在建構子呼叫上層類別的建構子,確保子類別每次實體話的時候也能夠初始化屬性。

在上述例子中,只要修改Father類別,讓他在建構子當中初始化就可以了。讓我們用JavaScript中經典的prototype base繼承法來取得並執行上層物件的建構子,修改之後的Father類別如下:

//上層類別
function Father() {
    this.base = Grandfather;
    this.base();
    this.setBirthdayYear(1915);    
}

加入了中間那兩行便可以呼叫上層類別的建構子,透過這種指定方式,this.base會被當做是Grandfather這個function(也就是Grandfather的建構子),然後在下面呼叫this.base()的時候,就會把Grandfather做過的事情再做一次,也就可以達到呼叫上層建構子的這個目的了。

除了這個方法之外,也可以使用call()函式來取得上層建構子。以call()來修改的Father類別如下:

//上層類別
function Father() {
    Grandfather.call(this);
    this.setBirthdayYear(1915);    
}

call()會將Grandfather裡面的this以輸入的參數取代並執行一遍,而現在輸入的參數是Father的this,因此就等於Father中去執行Grandfather的建構子一樣的意思。call()方法可以說是比base更為簡潔且直覺的實作方法,詳細的內容可以參考ECMA-262的13.2.1 [[Call]]方法的定義

最後讓我們重新看一下以call()修改之後的四個類別多層繼承的程式碼:

//最上層類別
function Grandfather() {
    this.birthday = {
        year: null,
        month: null,
        day: null
    };
    this.setBirthdayYear(1889);
}

//屬性
Grandfather.prototype.birthday = {
    year: null,
    month: null,
    day: null
};

//方法
Grandfather.prototype.setBirthdayYear = function (year)
{
    this.birthday.year = year;
}; 

//上層類別
function Father() {
    Grandfather.call(this);
    this.setBirthdayYear(1915);    
}

//Father繼承Grandfather
Father.prototype = new Grandfather();    

//子類別之一
function Son() {
    Father.call(this);
    this.setBirthdayYear(1943);    
}

//Son繼承Father
Son.prototype = new Father();    

//子類別之二
function Daughter() {
    Father.call(this);
    this.setBirthdayYear(1945);    
}

//Daughter繼承Father
Daughter.prototype = new Father();

//輸出檢驗
var grandfather = new Grandfather();
var father = new Father();
var son = new Son();
var daughter = new Daughter();

document.write(grandfather.birthday.year);    //顯示1889
document.write(father.birthday.year);    //顯示1915
document.write(son.birthday.year);    //顯示1943
document.write(daughter.birthday.year);    //顯示1945

結語

由於JavaScript是傳址的方式運作,所以在物件導向上會發生很多出乎意料之外的問題,讓我在這兩個禮拜一直在找解決的方法。這一篇寫了又改、改了又寫,反反覆覆地好多次,總算是把JavaScript用prototype的繼承方法有個比較完整的整理。網路上的資料零零散散地看了很多,大致上把比較有印象的列在下方供大家參考。寫這篇也是對自己這些日子學習JavaScript的繼承方法有一個交代,作為學習的一個筆記。

原本我用prototype的方式來寫JavaScript的繼承,但因為發現了這個問題,於是又想改用Dean Edwards的Base工具來實作繼承,但是這卻導致Aptana這個JavaScript IDE看不懂,讓IDE整個武功盡失。在找尋更為理想的解決方式時,又回來整理這一篇。

image

到目前為止,我發現Aptana IDE可以完全理解這種prototype繼承方法,甚至不需要用JSDoc的@extends標註繼承的對象,Aptana也可以理解並帶出上層類別的方法或屬性。

接下來我想要繼續研究一個可以支援我需要的物件導向功能,並且又能讓Aptana這種JavaScript IDE讀取分析的理想實作方式。待我程式實作到一定程度時,確認都沒有問題了,我再把我的方法整理之後寫上來吧。


參考資源

總共6 則留言 ( 我要發問 , 隱藏留言 顯示留言 )

  1. 您好, 我對javascript不算熟, 不過你的結果可以用以下的code達成目的:

    function Grandfather() {this.setBirthdayYear(1889);}

    Grandfather.prototype.setBirthdayYear = function (year)
    {this.year = year;};

    function Father() {this.setBirthdayYear(1915);}

    Father.prototype = new Grandfather();

    function Son() {this.setBirthdayYear(1943);}

    Son.prototype = new Father();

    function Daughter() {this.setBirthdayYear(1945);}

    Daughter.prototype = new Father();

    var grandfather = new Grandfather();
    var father = new Father();
    var son = new Son();
    var daughter = new Daughter();

    如此即可得到最後的結果...
    至於原因嘛... 我也不清楚 :p

    回覆刪除
  2. 有點複雜,晚點再來研究看看@@"

    回覆刪除
  3. 不好意思, 上面的例子有問題...
    在測試之後, 似乎是跟你的property 是struct or variable有關...
    如果是struct 就會變成是傳reference, 如果是variable, 就會是value

    下面的code即可顯示區別

    function Grandfather() {
    this.Set(1,2,3);
    }

    Grandfather.prototype.foo = 0;
    Grandfather.prototype.S = {foo1:0,foo2:0};

    Grandfather.prototype.Set = function (a,b,c)
    {
    this.foo = a;
    this.S.foo1 = b;
    this.S.foo2 = c;
    };

    function Father() {
    this.Set(7,8,9);
    }

    Father.prototype = new Grandfather();

    var gf = new Grandfather();
    var f = new Father();

    document.write(gf.foo+'
    ');
    document.write(gf.S.foo1+'
    ');
    document.write(gf.S.foo2+'
    ');
    document.write(f.foo+'
    ');
    document.write(f.S.foo1+'
    ');
    document.write(f.S.foo2+'
    ');

    但我試圖把你的 GrandFather.call(this)加進去, 但好像對struct而言, 值還是會錯...

    回覆刪除
  4. 嗯,我仔細地看了一下你程式的寫法跟結果

    結果應該是跑出「189789」的值
    這個意思是:
    1. gf的靜態屬性foo的值並沒有被f覆蓋
    2. gf的物件屬性S底下的foo1跟foo2,卻會被f覆蓋

    這還是不脫離我之前整理的JavaScript傳值與傳址心得
    http://pulipuli.blogspot.com/2010/09/javascript.html

    簡單來說,類別的屬性如果是物件型態並且操作該物件底下的屬性,那麼就是用傳址來運作
    如果類別的屬性是靜態型態,那麼就是用傳址來運作

    其實如果要Grandfather的S避免被覆蓋,set方法應該要改寫如下:

    Grandfather.prototype.Set = function (a,b,c)
    {
    this.foo = a;
    this.S = new Object;
    this.S.foo1 = b;
    this.S.foo2 = c;
    };

    那麼gf.S跟f.S就會是完全不一樣的物件,而不會彼此受到影響

    -------------------

    GrandFather.call(this)只是重複再做一次宣告動作而已
    但因為傳址影響的關係,所以重複做還是一樣會受到影響的喔

    -------------------

    不好意思隔了這麼久才回XDD
    歡迎多多討論喔!

    回覆刪除
  5. 這不是傳址問題, year 不直接在 constructor 裏面宣告而以「Ancestor.prototype.birthday= { year: null, month: null, day: null };」建立 year, 這個 year 算是外部共用的變數並不屬於 class 的一部份故不會繼承, 這是寫法錯誤問題。

    回覆刪除
  6. To 5樓匿名,

    你說的沒錯。

    最近我在讀JavaScript設計模式,才發現以前的認知很多都是錯誤的。
    特別是JavaScript沒有class這個概念很重要。
    有志學習JavaScript的人請務必讀一下「JavaScript設計模式」這本書,定價480不貴也不厚,但是裡面滿滿的精華絕對讓你了之後會對之前寫的程式感到悔不當初!
    http://www.taaze.tw/apredir.html?125290739/http://www.taaze.tw/sing.html?pid=11100603595

    回覆刪除