第 28 章。內建類別的子類別
目錄
購買書籍
(廣告,請勿封鎖。)

第 28 章。內建類別的子類別

JavaScript 的內建建構函式難以建立子類別。本章說明原因並提出解決方案。

術語

我們使用片語 建立內建子類別,避免使用術語 延伸,因為 JavaScript 中已採用此術語

建立內建類別 A 的子類別
建立給定內建建構函式 A 的子建構函式 BB 的執行個體也是 A 的執行個體。
延伸物件 obj
將一個物件的屬性複製到另一個物件。Underscore.js 使用此術語,延續 Prototype 架構建立的傳統。

建立內建子類別有兩個障礙:具有內部屬性的執行個體和無法作為函式呼叫的建構函式。

障礙 1:具有內部屬性的執行個體

大多數內建建構函式 具有所謂的 內部屬性 的執行個體(請參閱 屬性的種類),其名稱以雙方括號撰寫,如下所示: [[PrimitiveValue]]。內部屬性由 JavaScript 引擎管理,通常無法在 JavaScript 中直接存取。JavaScript 中正常的子類別技術是將超建構函式作為函式呼叫,並使用子建構函式的 this(請參閱 第 4 層:建構函式之間的繼承

function Super(x, y) {
    this.x = x;  // (1)
    this.y = y;  // (1)
}
function Sub(x, y, z) {
    // Add superproperties to subinstance
    Super.call(this, x, y);  // (2)
    // Add subproperty
    this.z = z;
}

大多數內建函式會忽略作為 this 傳遞的子執行個體 (2),下一節會說明此障礙。此外,通常無法將內部屬性新增至現有執行個體 (1),因為這往往會徹底改變執行個體的本質。因此,無法使用 (2) 的呼叫來新增內部屬性。下列建構函式具有內部屬性的執行個體

Wrapper 建構函式

BooleanNumberString 的執行個體會包裝基本型別。它們都具有內部屬性 [[PrimitiveValue]],其值由 valueOf() 傳回;String 有兩個額外的執行個體屬性

  • Boolean:內部執行個體屬性 [[PrimitiveValue]]
  • Number:內部執行個體屬性 [[PrimitiveValue]]
  • 字串:內部實例屬性 [[PrimitiveValue]]、自訂內部實例方法 [[GetOwnProperty]]、一般實例屬性 length。當使用陣列索引時,[[GetOwnProperty]] 可透過從包裝字串中讀取來啟用字元的索引存取。
陣列
自訂內部實例方法 [[DefineOwnProperty]] 會攔截正在設定的屬性。它會確保 length 屬性運作正確,方法是在新增陣列元素時保持 length 為最新狀態,以及在縮小 length 時移除多餘元素。
日期
內部實例屬性 [[PrimitiveValue]] 會儲存日期實例所表示的時間(以自 1970 年 1 月 1 日 00:00:00 UTC 以來的毫秒數表示)。
函式
內部實例屬性 [[Call]](在呼叫實例時要執行的程式碼)以及其他可能的屬性。
正規表示式

內部實例屬性 [[Match]],加上兩個非內部實例屬性。根據 ECMAScript 規範

[[Match]] 內部屬性的值是 RegExp 物件的模式的實作依賴表示法。

沒有內部屬性的內建建構函式只有 ErrorObject

障礙 1 的解決方法

MyArrayArray 的子類別。它有一個 getter size,會傳回陣列中的實際元素,忽略孔洞(length 會考慮孔洞)。實作 MyArray 所使用的技巧是建立一個陣列實例,並將其方法複製到其中:[22]

function MyArray(/*arguments*/) {
    var arr = [];
    // Don’t use Array constructor to set up elements (doesn’t always work)
    Array.prototype.push.apply(arr, arguments);  // (1)
    copyOwnPropertiesFrom(arr, MyArray.methods);
    return arr;
}
MyArray.methods = {
    get size() {
        var size = 0;
        for (var i=0; i < this.length; i++) {
            if (i in this) size++;
        }
        return size;
    }
}

這段程式碼使用輔助函式 copyOwnPropertiesFrom(),其顯示和說明如下:複製物件

我們沒有在第 (1) 行呼叫 Array 建構函式,因為有一個怪癖:如果呼叫時只有一個數字參數,該數字並不會變成元素,而是決定空陣列的長度(請參閱初始化包含元素的陣列(避免!))。

以下是互動

> var a = new MyArray('a', 'b')
> a.length = 4;
> a.length
4
> a.size
2

注意事項

複製方法到一個實例會導致冗餘,而這可以用原型來避免(如果我們有選項可以使用一個原型)。此外,MyArray 會建立不是其實例的物件

> a instanceof MyArray
false
> a instanceof Array
true

障礙 2:無法當作函式呼叫的建構函式

即使 Error子類別沒有具有內部屬性的實例,你仍然無法輕鬆地對它們進行子類別化,因為子類別化的標準模式無法運作(從前面重複):

function Super(x, y) {
    this.x = x;
    this.y = y;
}
function Sub(x, y, z) {
    // Add superproperties to subinstance
    Super.call(this, x, y);  // (1)
    // Add subproperty
    this.z = z;
}

問題在於 Error 即使當作函式呼叫(1)也會產生一個新實例;也就是說,它會忽略透過 call() 傳遞給它的參數 this

> var e = {};
> Object.getOwnPropertyNames(Error.call(e)) // new instance
[ 'stack', 'arguments', 'type' ]
> Object.getOwnPropertyNames(e) // unchanged
[]

在前面的互動中,Error 會傳回一個具有自身屬性的實例,但它是一個新實例,不是 e。子類別化模式只有在 Error 將自身屬性新增到 this(在前述情況中為 e)時才會運作。

障礙 2 的解決方法

在子建構函式內部,建立一個新的超級實例,並將其自身屬性複製到子實例

function MyError() {
    // Use Error as a function
    var superInstance = Error.apply(null, arguments);
    copyOwnPropertiesFrom(this, superInstance);
}
MyError.prototype = Object.create(Error.prototype);
MyError.prototype.constructor = MyError;

輔助函式 copyOwnPropertiesFrom() 顯示在 複製一個物件 中。嘗試 MyError

try {
    throw new MyError('Something happened');
} catch (e) {
    console.log('Properties: '+Object.getOwnPropertyNames(e));
}

以下是 Node.js 上的輸出

Properties: stack,arguments,message,type

instanceof 關係就像它應該的那樣

> new MyError() instanceof Error
true
> new MyError() instanceof MyError
true

另一個解決方案:委派

委派是子類別化一個非常乾淨的替代方案。例如,要建立你自己的陣列建構函式,你會在一個屬性中保留一個陣列:

function MyArray(/*arguments*/) {
    this.array = [];
    Array.prototype.push.apply(this.array, arguments);
}
Object.defineProperties(MyArray.prototype, {
    size: {
        get: function () {
            var size = 0;
            for (var i=0; i < this.array.length; i++) {
                if (i in this.array) size++;
            }
            return size;
        }
    },
    length: {
        get: function () {
            return this.array.length;
        },
        set: function (value) {
            return this.array.length = value;
        }
    }
});

顯而易見的限制是,你無法透過方括號存取 MyArray 的元素;你必須使用函式才能這麼做

MyArray.prototype.get = function (index) {
    return this.array[index];
}
MyArray.prototype.set = function (index, value) {
    return this.array[index] = value;
}

Array.prototype 的一般函式可以透過以下的元程式化片段轉移

[ 'toString', 'push', 'pop' ].forEach(function (key) {
    MyArray.prototype[key] = function () {
        return Array.prototype[key].apply(this.array, arguments);
    }
});

我們透過在儲存在 MyArray 實例中的陣列 this.array 上呼叫 Array 函式來衍生 MyArray 函式。

使用 MyArray

> var a = new MyArray('a', 'b');
> a.length = 4;
> a.push('c')
5
> a.length
5
> a.size
3
> a.set(0, 'x');
> a.toString()
'x,b,,,c'



[22] 靈感來自 Ben Nadel 的部落格文章。

下一章:29. JSDoc:產生 API 文件