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

Max力出奇跡發表於2021-08-28

我們描述了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,並讓返回類擴充套件它。

相關文章