[ES6深度解析]14:子類 Subclassing

我們描述了ES6中添加的新類系統,用於處理創建對象構造函數的瑣碎情況。我們展示了如何使用它來編寫如下程式碼:

class Circle {
    constructor(radius) {
        this.radius = radius;
        Circle.circlesMade++;
    };

    static draw(circle, canvas) {
        // Canvas drawing code
    };

    static get circlesMade() {
        return !this._count ? 0 : this._count;
    };
    static set circlesMade(val) {
        this._count = val;
    };

    area() {
        return Math.pow(this.radius, 2) * Math.PI;
    };

    get radius() {
        return this._radius;
    };
    set radius(radius) {
        if (!Number.isInteger(radius))
            throw new Error("Circle radius must be an integer.");
        this._radius = radius;
    };
}

不幸的是,正如一些人指出的那樣,當時沒有時間討論ES6中其他類的強大功能。與傳統的類系統(例如c++或Java)一樣,ES6允許繼承,即一個類使用另一個類作為基類,然後通過添加自己的更多特性來擴展它。讓我們仔細看看這個新特性的可能性。

在開始討論子類之前,花點時間回顧一下屬性繼承動態原型鏈是很有用的。

JavaScript繼承

當我們創建一個對象時,我們有機會給它添加屬性,但它也繼承了它的原型對象的屬性。JavaScript程式設計師將熟練的使用現有的Object.createAPI,輕鬆做到這一點:

var proto = {
    value: 4,
    method() { return 14; }
}

var obj = Object.create(proto);

obj.value; // 4
obj.method(); // 14

此外,當我們給obj添加與proto上相同名稱的屬性時,obj上的屬性會覆蓋掉proto上的屬性:

obj.value = 5;
obj.value; // 5
proto.value; // 4

子類基本要點

記住一點,我們現在可以看到應該如何連接由類創建的對象原型鏈。回想一下,當我們創建一個類時,我們創建了一個新函數,與類定義中包含所有靜態方法的constructor方法相對應。我們還創建了一個對象作為所創建函數的prototype屬性,它將包含所有的實例方法(instance method)。為了創建繼承所有靜態屬性的新類,我們必須使新函數對象繼承父類的函數對象。類似地,對於實例方法,我們必須使新函數的prototype對象繼承父類的prototype對象。

這種描述非常複雜。讓我們嘗試一個示例,展示如何在不添加新語法的情況下將其連接起來,然後添加一個微不足道的擴展,使其更美觀。繼續前面的例子,假設我們有一個想要被繼承的Shape類:

class Shape {
    get color() {
        return this._color;
    }
    set color(c) {
        this._color = parseColorAsRGB(c);
        this.markChanged();  // repaint the canvas later
    }
}

當我們試圖編寫這樣的程式碼時,我們遇到了與上一篇關於靜態屬性的文章相同的問題:在定義函數時,沒有一種語法方法可以改變它的原型。你可以用Object.setPrototypeOf來解決這個問題。對於引擎來說,這種方法的性能和可優化性都不如使用預期原型創建函數的方法。

class Circle {
    // As above
}

// Hook up the instance properties
Object.setPrototypeOf(Circle.prototype, Shape.prototype);

// Hook up the static properties
Object.setPrototypeOf(Circle, Shape);

這太難看了。我們添加了類語法,這樣我們就可以封裝關於最終對象在一個地方的外觀的所有邏輯,而不是在之後使用Object.setPrototypeOf的邏輯。Java、Ruby和其他面向對象語言都有一種方法來聲明一個類聲明是另一個類的子類,我們也應該這樣做。我們使用關鍵字extends,所以可以這樣寫:

class Circle extends Shape {
    // As above
}

可以在extends後面放任何你想要的表達式,只要它是一個帶prototype屬性的有效constructor函數。例如:

  • 另一個class
  • 從現有的繼承框架中來的類class的函數
  • 一個普通function
  • 一個代表函數或類的變數
  • 一個函數調用:func()
  • 一個對對象屬性的訪問:obj.name

如果你不希望實例繼承Object.prototype,你甚至可以使用null

父類的屬性(super properties)

我們可以創建子類,我們可以繼承屬性,有時我們的方法甚至會重寫我們繼承的方法。但如果你想要繞過這個重寫機制呢?假設我們想要編寫Circle類的一個子類來處理按某個因數縮放圓。為了做到這一點,我們可以編寫類:

class ScalableCircle extends Circle {
    get radius() {
        return this.scalingFactor * super.radius;
    }
    set radius() {
        throw new Error("ScalableCircle radius is constant." +
                        "Set scaling factor instead.");
    }

    // Code to handle scalingFactor
}

注意,radius getter使用super.radius。這個新的super關鍵字允許我們繞過我們自己的屬性,並從我們的原型開始尋找屬性,從而繞過我們可能做過的任何重寫

父類屬性訪問(順便說一下,super[expr]也可以正常使用)可以在任何用方法定義語法定義的函數中使用。雖然這些函數可以從原始對象中提取出來,但訪問是綁定到方法最初定義的對象上的。這意味著將super方法賦值給局部變數中不會改變super`的行為。

var obj = {
    toString() {
        return "MyObject: " + super.toString();
    }
}

obj.toString(); // MyObject: [object Object]
var a = obj.toString;
a(); // MyObject: [object Object]

子類的內置命令

你可能想要做的另一件事是為JavaScript語言內置程式編寫擴展。內置的數據結構為該語言添加了巨大的功能,能夠創建利用這種功能的新類型是非常有用的,並且是子類設計的基礎部分。假設您想要編寫版本控制數組。你應該能夠進行更改,然後提交它們,或者回滾到以前提交的更改。快速實現的一種方法是編寫Array的子類。

class VersionedArray extends Array {
    constructor() {
        super();
        this.history = [[]];
    }
    commit() {
        // Save changes to history.
        this.history.push(this.slice());
    }
    revert() {
        this.splice(0, this.length, this.history[this.history.length - 1]);
    }
}

VersionedArray的實例保留了一些重要的屬性。它們是Array的真實實例,包括mapfiltersortArray.isArray()會像對待數組一樣對待它們,它們甚至會獲得自動更新的數組length屬性。甚至,返回新數組的函數(如Array.prototype.slice())將返回VersionedArray!

派生類構造函數

你可能已經注意到上一個示例的構造函數方法中的super()。到底發生了什麼事?

在傳統的類模型中,構造函數用於初始化類實例的任何內部狀態。每個子類負責初始化與其相關聯的狀態。我們希望將這些調用鏈接起來,以便子類與它們所擴展的類共享相同的初始化程式碼。

為了調用父類的構造函數,我們再次使用super關鍵字,這一次它就像一個函數一樣。此語法僅在使用extends的類的構造函數方法中有效。使用super,我們可以重寫Shape類。

class Shape {
    constructor(color) {
        this._color = color;
    }
}

class Circle extends Shape {
    constructor(color, radius) {
        super(color);

        this.radius = radius;
    }

    // As from above
}

在JavaScript中,我們傾向於編寫對this對象進行操作的構造函數,設置屬性並初始化內部狀態。通常,this對象是在使用new調用構造函數時創建的,就像在構造函數的prototype屬性上使用Object.create()一樣。然而,一些內置對象有不同的內部對象布局。例如,數組在記憶體中的布局與普通對象不同。因為我們希望能夠繼承這些內置對象,所以我們讓最基本的構造函數(最上級的父類)分配this對象。如果它是內置的,我們會得到我們想要的對象布局,如果它是普通構造函數,我們會得到this對象的默認值。

可能最奇怪的結果是在子類構造函數中綁定this的方式。在運行基類構造函數並允許它分配this對象之前,我們不會擁有this。因此,在子類構造函數中,在調用父類造函數super()之前對this的所有訪問都將導致ReferenceError。

正如我們在上一篇文章中看到的,你可以省略構造函數方法constructor,派生類(子類)構造函數也可以省略,就像你寫的:

constructor(...args) {
    super(...args);
}

有時,構造函數不與this對象交互。相反,它們以其他方式創建對象,初始化它,然後直接返回它。如果是這種情況,就沒有必要使用super。任何構造函數都可以直接返回一個對象,與是否調用過父類構造函數(super)無關。

new.target

讓最上級的父類分配this對象的另一個奇怪的副作用是,有時最上級的父類不知道要分配哪種對象。假設你正在編寫一個對象框架庫,你想要一個基類Collection,它的一些子類是Arrays,一些是Maps。然後,在運行Collection構造函數時,您將無法判斷要創建哪種類型的對象!

由於我們能夠繼承父類的內置屬性,當我們運行父類內置構造函數時,我們已經在內部知道了原始類的prototype。沒有它,我們就無法創建具有適當實例方法的對象。為了解決這種奇怪的Collection問題,我們添加了語法,以便將該資訊公開給JavaScript程式碼。我們添加了一個新的元屬性new.target,它對應於用new直接調用的構造函數。調用使用new調用的函數會設置new.target為被調用的函數,並在該函數中調用super轉發new.target的值。

這很難理解,所以我來告訴你我的意思:

class foo {
    constructor() {
        return new.target;
    }
}

class bar extends foo {
    // This is included explicitly for clarity. It is not necessary
    // to get these results.
    constructor() {
        super();
    }
}

// foo directly invoked, so new.target is foo
new foo(); // foo

// 1) bar directly invoked, so new.target is bar
// 2) bar invokes foo via super(), so new.target is still bar
new bar(); // bar

我們已經解決了上面描述的Collection的問題,因為Collection構造函數可以只檢查new.target,並使用它來派生類沿襲,並確定要使用哪個內置構造函數。

new.target在任何函數中都是有效的,如果函數不是用new調用的,它將被設置為undefined

兩全其美

許多人都直言不諱地表示,在語言特性中編寫繼承是否是一件好事。你可能認為,與舊的原型模型相比,繼承永遠不如組合創建對象(composition)好,或者新語法的整潔不值得因此而缺乏設計靈活性。不可否認的是,在創建以可擴展方式共享程式碼的對象時,mixin已經成為一種主要的習慣用法,這是有原因的:它們提供了一種簡單的方法,可以將不相關的程式碼共享到同一個對象,而無需理解這兩個不相關的部分

在這個話題上有不同意見,但我認為有一些事情值得注意。首先,作為一種語言特性添加的並沒有強制使用它們。第二,同樣重要的是,將作為一種語言特性添加並不意味著它們總是解決繼承問題的最佳方法!事實上,有些問題更適合使用原型繼承進行建模。在一天結束的時候,課程只是教會你可以使用的另一個工具;不是唯一的工具,也不一定是最好的。

如果你想繼續使用mixin,你可能希望你可以訪問繼承了幾個東西的類,這樣你就可以繼承每個mixin,讓一切都很好。不幸的是,現在更改繼承模型會很不協調,因此JavaScript沒有為類實現多重繼承。也就是說,有一種混合解決方案允許mixin在基於類的框架中。基於眾所周知的mixin extend習慣用法,考慮以下的函數。

function mix(...mixins) {
    class Mix {}

    // Programmatically add all the methods and accessors
    // of the mixins to class Mix.
    for (let mixin of mixins) {
        copyProperties(Mix, mixin);
        copyProperties(Mix.prototype, mixin.prototype);
    }
    
    return Mix;
}

function copyProperties(target, source) {
    for (let key of Reflect.ownKeys(source)) {
        if (key !== "constructor" && key !== "prototype" && key !== "name") {
            let desc = Object.getOwnPropertyDescriptor(source, key);
            Object.defineProperty(target, key, desc);
        }
    }
}

現在我們可以使用mix函數來創建一個複合基類,而不必在各種mixin之間創建顯式的繼承關係。想像一下,編寫一個協作編輯工具,其中記錄了編輯操作,並且需要對其內容進行序列化。你可以使用mix函數來編寫一個類DistributedEdit:

class DistributedEdit extends mix(Loggable, Serializable) {
    // Event methods
}

這是兩全其美的方案。很容易看到如何擴展這個模型來處理自己有超類的mixin類:我們可以簡單地將父類傳遞給mix,並讓返回類擴展它。