[ES6深度解析]14:子类 Subclassing
- 2021 年 8 月 28 日
- 筆記
- ES6, javascript
我们描述了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.create
API,轻松做到这一点:
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
的真实实例,包括map
、filter
和sort
。Array.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,并让返回类扩展它。