實作繼承

August 27, 2022

要說為何基於原型的 JavaScript 中,始終有開發者追求基於類別的模擬,原因之一大概就是,使用基於原型的方式實現繼承時,許多開發者難以掌握,或者實作上有複雜、難以閱讀之處,因而寄望在類別的模擬下,繼承這方面能夠有更直覺、簡化、易於掌握的方式。

基於類別的繼承

ES6 以後提供了模擬類別的標準方式,而在繼承這方面,可以使用 extends 來模擬基於類別的繼承,例如,以下是基於原型實作繼承,考慮了作為方法的特性必須不可列舉、constructor 特性等必須遵循標準等要求:

function Role(name, level, blood) {
    this.name = name;   // 角色名稱
    this.level = level; // 角色等級
    this.blood = blood; // 角色血量
}

Object.defineProperties(Role.prototype, {
    toString: {
        value: function() {
            return `(${this.name}, ${this.level}, ${this.blood})`;
        },
        writable: true,
        configurable: true
    }
});

function SwordsMan(name, level, blood) {
    Role.call(this, name, level, blood);
}

SwordsMan.prototype = Object.create(Role.prototype, {
    constructor: {
        value: SwordsMan,
        writable: true,
        configurable: true    
    }
});

Object.defineProperties(SwordsMan.prototype, {
    fight: {
        value: () => console.log('揮劍攻擊'),
        writable: true,
        configurable: true
    },
    toString: {
        value: function() {
            let desc = Role.prototype.toString.call(this);
            return `SwordsMan${desc}`;
        },
        writable: true,
        configurable: true
    }    
});

function Magician(name, level, blood) {
    Role.call(this, name, level, blood);
}

Magician.prototype = Object.create(Role.prototype, {
    constructor: {
        value: Magician,
        writable: true,
        configurable: true    
    }
});

Object.defineProperties(Magician.prototype, {
    fight: {
        value: () => console.log('魔法攻擊'),
        writable: true,
        configurable: true
    },
    cure: {
        value: () => console.log('魔法治療'),
        writable: true,
        configurable: true
    },
    toString: {
        value: function() {
            let desc = Role.prototype.toString.call(this);
            return `Magician${desc}`;
        },
        writable: true,
        configurable: true
    }  
});


function drawFight(role) {
    console.log(role.toString());
}

let swordsMan = new SwordsMan('Justin', 1, 200);
let magician = new Magician('Monica', 1, 100);

drawFight(swordsMan);
drawFight(magician);

若改以類別與 extends 來模擬的話會是如下,相對來說簡潔許多:

class Role {
    constructor(name, level, blood) {
        this.name = name;   // 角色名稱
        this.level = level; // 角色等級
        this.blood = blood; // 角色血量
    }
    
    toString() {
        return `(${this.name}, ${this.level}, ${this.blood})`;
    }
}

class SwordsMan extends Role {
    constructor(name, level, blood) {
        super(name, level, blood); 
    }

    fight() {
        console.log('揮劍攻擊');
    }

    toString() {
        return `SwordsMan${super.toString()}`;
    }
}

class Magician extends Role {
    constructor(name, level, blood) {
        super(name, level, blood);
    }

    fight() {
        console.log('魔法攻擊');
    }

    cure() {
        console.log('魔法治療');
    }

    toString() {
        return `Magician${super.toString()}`;
    }
}

let swordsMan = new SwordsMan('Justin', 1, 200);
let magician = new Magician('Monica', 1, 100);

swordsMan.fight();
magician.fight();
console.log(swordsMan.toString());
console.log(magician.toString()); 

想繼承某個類別時,只要在 extends 右邊指定類別名稱就可以了,既有的 JavaScript 建構式,像是 Object 等,也可以在 extends 右方指定;若要呼叫父類別建構式,可以使用 super,若要呼叫父類別中定義的方法,則是在 super 來指定方法名稱。

如果要呼叫父類別中以符號定義的方法,則使用 [],例如 super[Symbol.iterator](arg1, arg2, ...)

類別語法的繼承,能夠繼承標準 API,而且內部實作特性以及特殊行為也會被繼承,例如,可以繼承 Array,子型態實例的 length 行為,能隨著元素數量自動調整。

建構式

如果沒有使用 constructor 定義建構式,會自動建立預設建構式,並自動呼叫 super,如果定義了子類別建構式,除非子類別建構式最後 return 了一個與 this 無關的物件,否則要明確地使用 super 來呼叫父類建構式,不然 new 時會引發錯誤:

> class A {}
undefined
> class B extends A {
...     constructor() {}
... }
undefined
> new B();
ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor
    at new B (repl:2:16)
    …略

在子類建構式中試圖使用 this 之前,也一定要先使用 super 呼叫父類建構式,也就是父類別定義的建構初始化流程必須先完成,再執行子類別建構式後續的初始化流程。

super 與 extends

若父類別與子類別中有同名的靜態方法,也可以使用 super 來指定呼叫父類的靜態方法:

> class A {
...     static show() {
.....         console.log('A show');
.....    }
... }
undefined
> class B extends A {
...     static show() {
.....         super.show();
.....         console.log('B show');
.....   }
... }
undefined
> B.show();
A show
B show
undefined
>

如果是來自基於類別的語言開發者,知道先前討論的繼承語法,大概就足夠了,當然,JavaScript 終究是個基於原型的語言,以上的繼承語法,很大成份是語法蜜糖,也大致上可以對照至基於原型的寫法,透過原型物件的設定與操作,也可以影響既定的類別定義。

只不過,既然決定使用基於類別來簡化程式的撰寫,非絕對必要的話,不建議又混合基於原型的操作,那只會使得程式變得複雜,若已經使用基於類別的語法,又經常地操作原型物件,這時需要的不會是類別,建議還是直接暢快地使用基於原型方式就好了。

當然,如果對原型夠瞭解,是可以來玩玩一些試驗,接下來的內容純綷是探討,若不感興趣,可以直接跳過,不會影響後續章節的內容理解。

super 其實是個語法糖,在不同的環境或操作中,代表著不同的意義。在建構式以函式方式呼叫,代表著呼叫父類別建構式,在 super 呼叫父類別建構式之後,才能存取 this,這是因為建構式裏的 super 是為了創造 this,以及它參考的物件,更具體地說,就是最頂層父類別建構式 return 的物件,物件產生之後,才由父類別至子類別,逐層執行建構式中定義的初始流程。

如果子類別建構式沒有 return 任何物件,就是傳回 this,這就表示如果子類建構式中沒有 returnthis 無關的物件時,一定要呼叫 super,不然就會因為不存在 this 而引發錯誤。

至於透過 super 取得某個特性的話,可以將 super 視為父類別的 prototype

> class A {}
undefined
> A.prototype.foo = 10;
10
> class B extends A {
...     show() {
.....         console.log(super.foo);
.....   }
... }
undefined
> new B().show();
10
undefined
>

除了透過 super 呼叫父類別方法之外,其實還可以透過 super 設定特性,不過試圖透過 super 來設定特性時,會是在實例本身上設定,也就是這個時候的 super 就等同於 this

> class A {}
undefined
> A.prototype.foo = 10;
10
> class B extends A {
...     show() {
.....         console.log(super.foo);
.....         super.foo = 100;        // 相當於 this.foo = 100;
.....         console.log(super.foo); // 還是取 A.prototype.foo
.....         console.log(this.foo);
.....    }
... }
undefined
> new B().show();
10
10
100
undefined
>

就程式碼閱讀上來說,super.foo = 100 可以解釋成,在父類別建構式傳回的物件上設定特性吧!

如果用在 static 方法中,那麼 super 代表著父類別:

> class A {
...     static show() {
.....         console.log('A show');
.....   }
... }
undefined
> class B extends A {
...     static show() {
.....         console.log(super.name);
.....   }
... }
undefined
> B.show();
A
undefined
>

這就可以來探討一個有趣的問題,如果只定義 class A {} 時,A 繼承哪個類別呢?若開發者有基於類別的語言經驗,可能會想是否相當於 class A extends Object {}?若就底層技術來說,class A {} 時沒有繼承任何類別:

> class A {
...     static show() {
.....         console.log(super.name); // 結果是空字串
.....    }
... }
undefined
> class B extends Object {
...     static show() {
.....         console.log(super.name); // 結果是 'Object'
.....   }
... }
undefined
> A.show();

undefined
> B.show();
Object
undefined
>

這是因為 ES6 以後提供的類別語法,終究就只是模擬類別,本質上,每個類別就是個函式,就像 ES6 之前利用 function 來定義建構式那樣:

> A.__proto__ === Function.prototype;
true
>

使用 extends 指定繼承某類別時,子類別本質上也是個函式,而它的 __proto__ 會是 extends 的對象:

> B.__proto__ === Object;
true
> class C extends B {}
undefined
> C.__proto__ === B;
true
>

如此一來,若父類別定義了 static 方法,透過子類別也可以呼叫,而且以範例中的原型鏈來看,最後一定有個類別的 __proto__ 指向 Function.prototype,也就是說,每個類別都是 Function 的實例,在 ES6 之前,每個建構式都是 Function 實例,在 ES6 以後,並沒有為類別創建一個類型。

或者應該說「類別」這名詞只是個晃子,底層都是 Function 實例;extends 實際上也不是繼承類別,當 class C extends P {} 時,其實是將 C.prototype.__proto__ 設為 P.prototype

從原型來看,class A {} 時,A.prototype.__proto__Object.prototype,而 class B extends Object {} 時,B.prototype.__proto__ 也是 Object.prototypeextends 實際上還是在處理原型。

> class A {}
undefined
> A.prototype.__proto__ === Object.prototype
true
> class B extends Object {}
undefined
> B.prototype.__proto__ === Object.prototype
true
>

你甚至可以透過 class Base extends null 的方式,令 Base.prototype.__proto__null,只是作用不大,或許可用來建立一個不繼承任何方法的物件吧!例如:

class Base extends null {
    constructor() {
        return Object.create(null);
    }
}

就結論來說,ES6 提供類別語法的目的,是為了打算基於類別的典範來設計時,可以在程式碼的撰寫與閱讀上清楚易懂;然而,類別語法終究只是模擬,JavaScript 本質上還是基於原型,在類別語法不如人意,覺得其行為詭異,或無法滿足需求時,回歸基於原型的思考方式,往往就能理解其行為何以如此,也能進一步採取適當的措施,令程式碼在可以滿足需求的同時,同時兼顧日後的可維護性。

分享到 LinkedIn 分享到 Facebook 分享到 Twitter