實作繼承
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
,這就表示如果子類建構式中沒有 return
與 this
無關的物件時,一定要呼叫 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.prototype
,extends
實際上還是在處理原型。
> 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 本質上還是基於原型,在類別語法不如人意,覺得其行為詭異,或無法滿足需求時,回歸基於原型的思考方式,往往就能理解其行為何以如此,也能進一步採取適當的措施,令程式碼在可以滿足需求的同時,同時兼顧日後的可維護性。