實作繼承


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

ES6 提供了定義(模擬)類別時的標準化方式,而在繼承這方面,可以使用 extends 來實現。例如在〈模擬類別的封裝與繼承〉中看到的繼承模擬:

function Circle(x, y, r) {
    this.x = x;
    this.y = y;
    this.r = r;
}

Circle.PI = 3.14159; // 相當於Java類別的靜態方法

Circle.prototype.area = function() {
    return this.r * this.r * Circle.PI;
};

Circle.prototype.toString = function() {
    var text = [];
    for(var p in this) {
        if(typeof this[p] != 'function') {
            text.push(p + ':' + this[p]);
        }
    }
    return '[' + text.join() + ']';
};

function Cylinder(x, y, r, h) {
    Circle.call(this, x, y, r); // 呼叫父建構式
    this.h = h;
}

// 原型繼承
Cylinder.prototype = new Circle();

// 設定原型物件之constructor為目前建構式
Cylinder.prototype.constructor = Cylinder;

// 以下在 new 時會再建構,不需要留在原型物件上
delete Cylinder.prototype.x;
delete Cylinder.prototype.y;
delete Cylinder.prototype.r;

// 共用的物件方法設定在 prototype 上
Cylinder.prototype.volumn = function() {
    return this.area() * this.h;
};

在 ES6 中可以寫成:

class Circle {
    constructor(x, y, r) {
        this.x = x;
        this.y = y;
        this.r = r;     
    }

    area() {
        return this.r * this.r * Circle.PI();
    }

    toString() {
        let text = [];
        for(let p in this) {
            if(typeof this[p] != 'function') {
                text.push(p + ':' + this[p]);
            }
        }
        return '[' + text.join() + ']';     
    }

    static PI() {
        return 3.14159;
    }
}

class Cylinder extends Circle {
    constructor(x, y, r, h) {
        super(x, y, r); // 呼叫父建構式
        this.h = h;
    }

    volumn() {
        return this.area() * this.h;
    }
}

let cylinder = new Cylinder(0, 0, 10, 5);
console.log(cylinder.area());
console.log(cylinder.toString());
console.log(cylinder.volumn());
console.log(Cylinder.PI());

如果熟悉基於類別的繼承,對上面的程式同樣無需做太多的解釋,而這邊也看到了 super 在建構式中,可以用來呼叫父建構式,這是過去基於原型模擬類別繼承時,難以做到的功能,而子類別也可以查找到父類中的 static 方法。

如果定義了子類別建構式,除非子類建構式最後 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 't
his' or returning from derived constructor
    at new B (repl:2:16)
    at repl:1:1
    at ContextifyScript.Script.runInThisContext (vm.js:50:33)
    at REPLServer.defaultEval (repl.js:240:29)
    at bound (domain.js:301:14)
    at REPLServer.runBound [as eval] (domain.js:314:12)
    at REPLServer.onLine (repl.js:441:10)
    at emitOne (events.js:121:20)
    at REPLServer.emit (events.js:211:7)
    at REPLServer.Interface._onLine (readline.js:282:10)
>

在子類建構式中試圖使用 this 之前,也一定要先使用 super 呼叫父類建構式,就類別風格來說,可以想成父類建構初始化必須先完成,再執行子類別初始化;如果沒有子類沒有定義建構式,自動加入的建構式中會呼叫父類建構式。

super 也可以用在方法之中,這可用來指定呼叫父類中定義的方法,例如:

> class A {
...     toString() {
.....       return 'A';
.....   }
... }
undefined
> class B extends A {
...     toString() {
.....         return super.toString() + 'B'
.....   }
... }
undefined
> let b = new B();
undefined
> b.toString();
'AB'
>

而在 ES6 中要繼承內建的型態變得簡單多了:

> class MyArray extends Array {}
undefined
> let myArray = new MyArray();
undefined
> myArray[0] = 1;
1
> myArray[1] = 10;
10
> myArray.length;
2
> myArray instanceof Array;
true
>

若父類與子類中有同名的靜態方法,可以使用 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,這是因為 ES6 中的 super() 主要是為了創造 this 參考的物件(更具體地說,就是最頂層父類建構式 return 的物件),然後再從父至子逐層執行初始流程,這點跟基於原型時實作繼承的方式就有差異了,基於原型時實作繼承時,會先在子建構式中創造出 this 參考的物件,然後再呼叫父初始流程。

如果子類建構式沒有 return 任何物件,那麼隱含地 return this,這就表示如果子類建構式中沒有 returnthis 無關的物件時,一定要呼叫 super,不然就會發生錯誤。

透過 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 來設定特性時,這時代表的是在父類建構式傳回的物件上設定特性,然而在子類中,父類建構式傳回的物件就會是 this 參考的物件,因此這個時候的 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
>

如果用在 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 {} 時,它是繼承哪個類別呢?ES6 中,只要是可以 new 的對象,就可以作為 extends 的對象,在其他物件導向程式語言中,你可能會想是是不是相當於 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);
.....   }
... }
undefined
> A.show();

undefined
> B.show();
Object
undefined
>

然而,就原型鏈繼承的語義來看,是繼承自 Object 沒錯:

> new A().__proto__.__proto__ === Object.prototype;
true
> new B().__proto__.__proto__ === Object.prototype;
true
>

然而,就 A__proto__ 來看,A 只是一個普通函式,就像沒有 ES6 的 class 語法前,利用 function 來定義建構式那樣:

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

當使用 extends 指定繼承某個可以 new 的對象時,__proto__ 會是 extends 的對象:

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

如果想要判斷 class 定義下的繼承關係,可以透過類別的 __proto__,如果一路向上,最後一定會是 Function.prototype,也就是最後一定會是個普通函式,例如,就算是 class B extends Object {}B.__proto__ 會是 Object,而 Object.__proto__Function.prototype,因為原生的 Object 本來就是個普通函式。

照這來看,ES6 的類別本身,沒有定義一個頂層的基礎類別,任何沒有 extendsclass 定義,都會是個基礎類別,行為上就像是個普通函式,畢竟 JavaScript 本來就是個基於原型的語言。

一個特殊的情況是 extends null

> class A {}
undefined
> A.__proto__ === Function.prototype;
true
> A.prototype.__proto__ === Object.prototype;
true
> class B extends null {}
undefined
> B.__proto__ === Function.prototype;
true
> B.prototype.__proto__ === undefined;
true
> new B();
TypeError: Super constructor null of B is not a constructor
    at new B (repl:1:1)
    at repl:1:1
    at ContextifyScript.Script.runInThisContext (vm.js:50:33)
    at REPLServer.defaultEval (repl.js:240:29)
    at bound (domain.js:301:14)
    at REPLServer.runBound [as eval] (domain.js:314:12)
    at REPLServer.onLine (repl.js:441:10)
    at emitOne (events.js:121:20)
    at REPLServer.emit (events.js:211:7)
    at REPLServer.Interface._onLine (readline.js:282:10)
>

就語義上來說,extends null 是真正沒有繼承任何類別(或者說也不是個類別了),最後也不會有普通函式的行為(雖然 B.__proto__ 是參考至 Function.prototype),而且原型鏈最後中斷了,B.prototype.__proto__ 會是 undefined,這意謂著 B.prototype.toString 也會傳回 undefined(就像 null 實際上也不能做什麼),這樣的類別也沒辦法拿來建構(也不能當普通函式呼叫),除非明確定義 constructor(),並在最後 return 一個與與目前類別無關的物件。

> class C extends null {
...     constructor() {}
... }
undefined
> new C();
ReferenceError: Must call super constructor in derived class before accessing 't
his' or returning from derived constructor
    at new C (repl:2:16)
    at repl:1:1
    at ContextifyScript.Script.runInThisContext (vm.js:50:33)
    at REPLServer.defaultEval (repl.js:240:29)
    at bound (domain.js:301:14)
    at REPLServer.runBound [as eval] (domain.js:314:12)
    at REPLServer.onLine (repl.js:441:10)
    at emitOne (events.js:121:20)
    at REPLServer.emit (events.js:211:7)
    at REPLServer.Interface._onLine (readline.js:282:10)
> class D extends null {
...     constructor() {
.....       return {}
.....   }
... }
undefined
> new D();
{}
>

一個 extends null 的類別可以做什麼呢?我想得到的是定義一個 Null extends null {},用來真正代表 null 型態吧!只是對於動態定型的 JavaScript 來說,這樣的意義並不大,只能說是特別為 null 做出的邊角案例(Corner case)考量。

可以玩的原型探討還有很多,而且會讓你迷失方向…別忘了,ES6 基於類別的語法終究只是模擬,試圖從原型鏈等機制上,來理解 ES6 基於類別的語法是不明智的,因為很大的成份是語法糖,而且看來在這過程中原型鏈也被改寫過,如果你打算使用 ES6 基於類別的語法來實現物件導向,以便約束基於原型時的過度彈性,那就用基於類別的想法來看待。

如果用了基於類別的語法,卻又老是在那邊逐磨基於原型時的原理機制,那麼建議就放棄 ES6 的類別語法吧!