[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,并让返回类扩展它。

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

上一篇:Redis历史和应用场景


下一篇:maven