ES6 in Depth: Subclassing

ES6 In Depth: Subclassing

两周之前,我们介绍了ES6中新增加的一个类的系统,它可以用来做面向对象式的创建对象。我们可以展示一下,你可以怎么使用它来编写代码,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Circle {
  constructor(radius) {
    this.radius = radius;
    Circle.circlesMade++;
  };

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

  static get chiclesMade() {
    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允许继承,也就是一个类可以将另外一个作为基类,然后为自己增加更多的特性进而实现扩展。让我们更为接近这一新特性,查看其操作的可能性。

在我们开始讨论子类化之前,还需要花些时间来复习一下属性的继承和动态的原型链。

JS的继承

当我们创建一个对换昌,我们可以为其增加属性,同时它继承于其本身原型对象的属性。JS程序大大应该会很熟悉存在的Object.create这个API,它让我们可以简单地做到这点:

1
2
3
4
5
6
7
8
9
var proto = {
  value: 4,
  method() { return 14; }
}

var obj = Object.create(proto);

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

接着,当我们为obj添加在proto拥有相同名称的属性时,这些obj属性就相当于proto的影子:

1
2
3
obj.value = 5;
obj.value; //5
proto.value; //4

子类化的基础

这时,我们应该知道我们应该怎么通过类来处理对象创建时的原型链。再次重复一下,当我们创建一个类时,我们在类定义的内部为constructor方法创建一个新的函数,它可以处理所有的静态方法。我们也可以为创建好的函数增加一个包含原型属性的对象,它可以处理实例对象的方法。为了创建一个能继承所有静态属性的新类,我们不得不创建一个函数对象来继承基类的函数对象。类似的,对于实例方法,我们不得不为创建一个新的原型链上的函数,并继承于基类的原型类对象。

这描述有点让人迷惑,让我们来看一个例子,它会显示我们是怎么不用新语法来实现继承,接着进行些细小的扩展来让其更美观些。

继续我们之前例子,假设我们有一个类Shape,我们希望其可以子类化:

1
2
3
4
5
6
7
8
9
class Shape {
  get color() {
    return this._color;
  }
  set color(c) {
    this._color = parseColorAsRGB(c);
    this.markChanged();  // repaint the canvas later
  }
}

当我们试图编写这样的代码时,对于static的属性我们依然会遇到之前文章同样的问题:没有语法来改变原型链的函数定义。当你可以有一方法类如Object.setPrototypeOf来解决这问题时,这方法比那些直接在原型链上创建函数要显得性能更为低劣。

1
2
3
4
5
6
7
8
9
class Circle {
  // As above
}

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

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

这代码是十分丑陋的。我们增加了类的语法,可以让我们将对象的所有逻辑都封装在一个地方中,而不是像刚才的包括其它的”hooking things up(使用勾子方法提升功能)”的逻辑。Java、Ruby和其它的面向对象的语言中,都有一个方式来声明一个子类化的类,并指出来源于哪个类,所以我们也应该这样。我们使用关键字extends,所以我们可以编写成:

1
2
3
class Circle extends Shape {
  // As above
}

你可以在extends的后面添加任意的表达式,只要它是一个合法的prototype的原型构建器,如:

  • 其它类
  • 由存在的继承框架产生的与类相似的函数
  • 一个普通的函数
  • 一个包含一个函数或者类的变量
  • 一个对象的访问属性(obj[name])
  • 一个函数调用

如何你不想实例继承于Object.prototype,你甚至可以使用 null

Super 属性

所以现在我们可以进行子类化,同时我们可以继承属性,可以通过我们的继承为方法创建影子方法。但是,如果你想避免这些影子方法呢?

想象一下我们希望为我们的Circle编写一个子类,它可以通过某些因素进行缩放。为了做到这点,我们可以编写下面显得不太自然的类:

1
2
3
4
5
6
7
8
9
10
11
class ScalableCircle extends Circle {
  get radius() {
    return this.scalingFactor * super.radius;
  }
  set raadius() {
    throw new Error("ScalableCircle radius is constants." +
                                   "Set scaling factor instead.");
  }

  // Code to handle scalingFactor
}

注意在radius的getter方法中使用了super.radius。这个super的关键字可以让我们忽略自身的属性,而开始从我们的原型链中查找属性,因此也会过滤所有之前我们提到的影子方法。

我们可以在任意方法的函数定义中访问super属性(随便提醒,super[expr]也能工作)。当这些函数从原始对象中脱离出来后,super的方法访问实际上关联的是方法第一次定义的对象。这就意味着将一个方法脱离原有操作并定义到一个本地变量中,并不会改变super的方法访问产生的行为。

1
2
3
4
5
6
7
8
9
var obj = {
  toString() {
    return "MyObject: " + super.toString();
  }
}

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

子类化后的内置操作(基类方法的继承)

另外,你们可能希望在JS语言内部编写些扩展功能。JS内部的数据结构赋予其巨大的能力,可以利用这些能力来创建新的数据类型,其也是子类化设计中的基础。JS支持你编写一个具有版本的数组。(我知道,相信我,我知道)。版本数组应该可以修改,可能提交修改,回退到之前的提交变化。有一种方式来快速的编写版本数组,就是创建Array的子类。

1
2
3
4
5
6
7
8
9
10
11
12
13
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属性。再者,返回数组的函数此时会返回一个VersionedArray(如Array.prototype.slice())!

类构建器的派生

你可能已经注意到了上面的例子中的constructor方法有super()

在传统的类模型中,构建器是用来初始化类实例的内部状态的。每个连续的子类负责初始化状态和相关的具体子类(什么叫连续的子类?晕)。我们希望这些能起作用,所以需要子类可以通过扩展来操作相同的初始化代码。

为了调用基类的构建器,我们可以再次使用super这关键字,它是操作就是个函数。这语法只有在使用extends的类的constructor的方法中是合法的,我们可能重新编写我们形状(Shape)类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Shape {
  constructor() {
    this._color = color;
  }
}

class Circle extends Shape {
  constructor(color, radius) {
    super(color);
    this.radius = radius;
  }

  // As from above
}

在JS中,我们通过编写构建器,来操作this对象、安装属性和初始化内部的状态。一般来说,当我们通过new调用构建器时this对象就创建成功了,就像是Object.create()会处理其构建器的prototype的属性。但是,有些内置操作会在不同的内部对象布局上。例如,数组在内存中的位置不同于常规的对象。因为,我们想看到子类的内置操作,我们需要让基类分配到this对象。如果它是内置操作,我们会得到我们想要的对象行为,如果它是普通的构建器,我们会得到期望中的默认的this对象。

可能会得到个奇怪的结论,认为this绑定的是子类的构建器。当我们运行到基类的构建器时,允许其指定this对象,但我们子类实际上并不会有this的值。因此,在没有调用基类构建器之前,所有访问子类构建器会导致一个ReferenceError错误。

就如之前的文章,你可以省略掉contructor方法,派生出来的构建器也是可能省略的,它就如你编写:

1
2
3
constructor(...args) {
  super(...args);
}

有时,构建器并不能作用于this对象。相反,它们会通过其它的方式来创建对象,并初始化,接着直接返回此对象。如果是这一情况,那就不用使用super了。所有的构建器都会直接返回一个对象,不依赖于其基类构建器是否调用。

new.target

基类指定this对象时会有其它怪异的效果,那就是有时基类并不知道指定哪类的对象。假设,你正在编写一个对象框架的库,你希望有个基类Collection,某些子类会是数组,某些子类会是映射(Map)。然后,这时你运行Collection构建器,你并不会被告知是创建了哪类对象。

因此我们在进行内置操作时,当我们运行内置的构建器,我们需要知道其原始类的prototype。没有原型,我们是不能创建一个对象,使其有相应的实例方法的。为了解决这Collection的怪异情况,我们需要增加语法来暴露其信息给JS代码。我们已经增加了一个新的元属性(Meta Property) new.target,它关联构建器,可能直接调用new。通过new调用一个函数时,new.target会设置到其调用的函数中。调用super的函数中的new.target会作用new.target的值 。

这很难理解,所以让我用代码来解释我说的意思吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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

最好的两个世界(多继承)

希望你已经从这新特性的头脑风暴中生还出来,十分感谢能一直走到现在。现在,让我们花点时间来讨论,这些新特性怎么解决我们的问题。很多人很直率地质疑在JS加入继承这一特性是否好的。你可能会认为继承在创建对象上并不如组合一般好,或者认为相比于旧式的原型链模型,这精简的新语法会减少其设计上的灵活性。不容质疑的是,混入(mixin)通过扩展的方式来分享代码从而创建对象已经成为惯用手法。同时,它还有个好的原因:它提供了简单方式来将不相关的代码放置到同一个对象中,而不需要理解这两部分不相关代码是否适合在同一继承结构中。

在这主题上有很多不同的激烈信仰,但是我认为这并不值得讨论。首先,对一个语言增加类的特性,并不一定要求开发人员使用。第二,也同等的重要,语言中有类这一特性并不意味着它总是解决继承问题的最好方法!实际上,部分问题使用原型链继承的方式更为合适。到今天为此,类只是为你提供了额外的工具;它并不是唯一的,也不是最为必要的。

如果你想继续使用混入的方式,你可以理解那些由不同事情整合继承出来的类,才能通过混入来实现继承,以保证每件事能正常进行。不幸的是,现在这方式可能会修改继承模型,这显得十分刺耳。所以,JS并不对继承多个类进行实现。不过,它还是有一混合的方式来允许在类框架中使用混入方式的。详细看一下下面的函数,它是基于众所周知的extend混入的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function mix(...mixins) {
  class Mix {}

  // Programatically 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.definProperty(target, key, desc);
    }
  }
}

现在我们可以使用mix函数还创建一个组合型的超级类,通过变量mixins,我们不需要创建详细的继承关系。细想一下,当你编写一个协作编辑工具时,需要记录编辑的动作,同时需要将其内容序列化。你可以使用mix函数来编写一个DistributedEdit的类。

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

这对两个世界都很好。通过这例子,可以简单地看到怎么处理使得模型可以混入多个基类:我们可以简单地将基类放到mix函数中,并用类进行扩展继承。

当前可用性

好了,我们已经谈到许多有关子类化的内置等所有新功能,但是你是否现在可能使用它呢?

好吧,部分吧。在主要的浏览器商家中,Chrome已经支持我们今天所讨论到大部分的内容。在严格模式中,你可以我们讨论过的所有所有事情,除了Array的子类化。其它内置操作也可以使用,但是Array会出现一些额外的问题,所以可以不奇怪地确定还没有完成。我在firefox实现此功能,并快要接近尾声了(所有除了Array)。可以检查一下 bug 1141863 看到更多的信息,但是它会在几周后的日更新版本中出现。

再者,Edge已经支持super,但并不支持子类化的内置操作。Safari并不支持任何函数功能。

转换编译器在部分会有些不利的地方。它们可能创建类,可能使用super,但它们并没有一种方式来子类化内部操作,因为你需要一个引擎来支持一个类的实例能回溯到内置的操作方法(参考Array.prototype.splice)。

好了,这真是长啊。下周,Jason Orendorff 会回来并一起讨论ES6的模块系统。

Comments