Contents
  1. 1. JavaScript的继承
    1. 1.1. 为什么需要继承
    2. 1.2. 类式继承
      1. 1.2.1. extend函数
    3. 1.3. 原型式继承
      1. 1.3.1. 对继承而来的成员的读和写的不对等性
    4. 1.4. 类式继承和原型式继承的对比
    5. 1.5. 掺元类

JavaScript的继承

最近在看由Ross Harmes 和 Dustin Diaz写的一本书:《Pro JavaScript Design Patterns》(JavaScript设计模式),一本比较老的书。当我看完继承的时候,对JavaScript的继承有了更深的理解。写这个Blog,就当是一个整理的过程吧!

为什么需要继承

一般来说,在设计类的时候,我们希望能减少重复性代码,并且尽量弱化对象之间的耦合。使用继承,就符合了前一个设计原则的需要,也就是减少重复性代码。借助这种机制,你可以在现有类的基础上进行设计并充分利用它们已经具备的各种方法,而对设计进行修改也更为轻松。
继承的优点:减少重复性代码、方便修改。
继承的缺点:可能导致父类和子类产生强耦合。

类式继承

类式继承是原型链继承构造函数继承的组合,所以这里的类式继承,在一些书上也叫作组合继承。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*Class Person*/
function Person(name) {
this.name = name;
}
Person.prototype.getName = function() {
return this.name;
}
/*Class Author*/
function Author(name, books) {
Person.call(this, name); //Call the superclass's constructor in the scope of this.
this.books = books; //Add an attribute to Author.
}
Author.prototype = new Person(); //Set up the prototype chain.
Author.prototype.constructor = Author; //set the constructor attribute to Author.
Author.prototype.getBooks = function() { //Add a method to Author.
return this.books;
};

这里让Author这个类去继承了Person这个类。

首先要创建子类Author的构造函数。在构造函数中,调用超类Person的构造函数,并将name参数传给它。我们知道,在使用new运算符的时候,系统会为你做一些事。它先创建一个空对象,然后调用构造函数。在些过程中,这个空对象处于作用域链的最前端。而在Author函数中调用超类Person的构造函数时,你必须手工完成同样的任务:Person.call(this, name)。这条语句调用了Person的构造函数,并且在此过程中让那个空对象(new Author出来的空对象,用this代表)处于作用域链的最前端,而name则被作业参数传入。

在JavaScript中,每个函数对象都有一个名为prototype的属性,这个属性要么指向另一个对象,要么为null。
在创建一个对象时,JavaScript会自动将其原型对象设置为其构造函数的prototype属性所指的对象。应该注意的是,构造函数本身也是一个对象,它也有自己的原型对象,但这个原型对象并不是它的prototype属性所指向的那个对象。函数作为一个对象,其构造函数是Function。因此,构造函数的原型对象实际上是Function.prototype所指的对象。

在访问对象的某个成员时,如果这个成员未见于当前对象的prototype中,那么JavaScript会在prototype属性所指的原型对象中查找它。如果在那个对象中也没有找到,那么JavaScript会沿着原型链向上逐一访问每个原型对象,直到找到这个成员(或已经查过原型链最顶端的Object.prototype对象)。这意味着为了让一个类继承另一个类,只需要将子类的prototype设置为指向超类的一个实例即可。

为了让Author继承Person,还必须手工将Author的prototype设置为Person的一个实例。最后一个步骤是将prototype的constructor属性重设为Author(因为把prototype属性设置为Person的实例时,其constructor属性被抹除了。

extend函数

为了简化类的声明,可以把派生子类的整个过程包装在一个名为extend的函数中。

1
2
3
4
5
6
7
/*extend function*/
function extend(subClass, superClass) {
var F = function() {};
F.prototype = superClass.prototype;
subClass.prototype = new F();
subClass.prototype.constructor = subClass;
}

这个函数所做的事与先前的一样。不过extend作为一项目改进,它添加了一个空函数F,并将用它创建的一个对象实例插入原型链中。这样做可以避免创建超类的新实例,因为它可能会比较庞大,而且有时超类的构造函数有一些副作用,或者执行一些需要进行大量计算的任务。

使用了extend函数后,前面那个Person/Author的例子变成了这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*Class Person*/
function Person(name) {
this.name = name;
}
Person.prototype.getName = function() {
return this.name;
}
/*Class Author*/
function Author(name, books) {
Person.call(this, name); //Call the superclass's constructor in the scope of this.
this.books = books; //Add an attribute to Author.
}
extend(Author, Person);
Author.prototype.getBooks = function() {
return this.books;
};

还有一个问题:超类(Person)的名称被固化在了Author类的声明之中:Person.call(this, name)。更好的做法是像下面这样用一种更具普适性的方式来引用父类。

1
2
3
4
5
6
7
8
9
10
11
12
/*Extend function, improved.*/
function extend(subClass, superClass) {
var F = function() {};
F.prototype = superClass.prototype;
subClass.prototype = new F();
subClass.prototype.constructor = subClass;
subClass.superclass = superClass.prototype;
if(superClass.prototype.constructor == Object.prototype.constructor) {
superClass.prototype.constructor = superClass;
}
}

这个增强版提供了superclass属性,这个属性可以用来弱化Author和Person之间的耦合。它的最后3行代码用来确保超类的prototype.constructor属性已被正确设置(即使超类就是Object类本身),在用这个新的superclass属性调用超类的构造函数时这个问题很重要:

1
2
3
4
5
6
7
8
9
/*Class Author*/
function Author(name, books) {
Author.superclass.constructor.call(this, name);
this.books = books;
}
extend(Author, Person);
Author.prototype.getBooks = function() {
return this.books;
}

另外,有了superclass属性,就可以直接调用超类中的方法。这在既要重定义(重写)超类的某个方法而又想访问其在超类中的实现时可以派上用场。例如,为了用一个新的getName方法重定义(重写)Person类中的同名方法,你可以先用Author.superclass.getName获得作者的名字,然后在此基础上添加其他信息:

1
2
3
4
Author.prototype.getName = function() {
var name = Author.superclass.getName.call(this);
return name + ', Author of ' + this.getBooks().join(', ');
};

原型式继承

原型式继承需要用到一个创建克隆对象的函数:clone(),它可以用来创建新的类object对象。它会创建一个空对象,而该对象的原型对象被设置成为object。这意味着在新对象中查找某个方法或属性时,如果找不到,那个查找过程会在其原型对象中继续进行。

1
2
3
4
5
6
/*Clone function*/
function clone(object) {
function F() {};
F.prototype = object;
return new F();
}

使用原型式继承,并不需要用类来定义对象的结构,只需要直接创建一个对象即可。可以从上面的代码看出来,这个对象随后可以被新的对象重用,这得益于原型链查找的工作机制。该对象被称为原型对象(prototype object),这是因为它为其他对象应有的模样提供了一个原型。这正是原型式继承这个名称的由来。

我们重新设计Person和Author:

1
2
3
4
5
6
7
/*Person Prototype Object*/
var Person = {
name: 'default name',
getName: function() {
return this.name;
}
};

Person现在是一个对象字面量,并没有使用一个名为Person的构造函数来定义类的结构。它是所要创建的其他各种类Person对象的原型对象。

你不必为创建Author而定义一个Person的子类,只要执行一次克隆即可:

1
2
3
4
5
6
/*Author Prototype Object*/
var Author = clone(Person);
Author.books = []; //Default value
Author.getBooks = function(){
return this.books;
}

然后你可以重定义该克隆中的方法和属性。可以个性在Person中提供的默认值,也可以添加新的属性和方法。这样一来就创建了一个新的原型对象,你可以将其用于创建新的类Author对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var author = [];
author[0] = clone(Author);
author[0].name = 'Dustin Diaz';
author[0].books = ['JavaScript Design Patterns--DD'];
author[1] = clone(Author);
author[1].name = 'Ross Harmes';
author[1].books = ['JavaScript Design Patterns--RH'];
author[0].getName(); //'Dustin Diaz'
author[0].getBooks(); //['JavaScript Design Patterns--DD']
author[1].getName(); //'Ross Harmes'
author[1].getBooks(); //['JavaScript Design Patterns--RH']

对继承而来的成员的读和写的不对等性

在类式继承中,Author的每一个实例都有一份自己的books数组副本,可以用代码author[1].books.push(‘New Book Title’)为其添加元素。但是对于使用原型式继承方式创建的类Author对象来说,由于原型链接的工作方式,这种做法并非一开始就能行得能。一个克隆并非其原型对象的一份完全独立的副本(克隆只是浅复制),它只是一个以那个对象为原型对象的对象而已。克隆刚被创建时,author[1].name其实是一个返指最初的Person.name的链接。

对于从原型对象继承而来的成员,其读和写具有内在的不对等性。在读取author[1].name的值时,如果你还没有直接为author[1]实例定义name属性的话,那么所得到的是其原型对象的同名属性值。而在写放author[1].name的值时,你是在直接为author[1]对象定义一个新属性。

1
2
3
4
5
6
7
8
9
10
11
12
var authorClone = clone(Author);
console.log(authorClone.name); //打印的是Person.name,值为'default name'。
//因为这时候,authorClone没有name属性,沿着原型链向上找,
//Author原型对象也没有name属性,继续沿着原型链向上找,
//直到找到Person.name
authorClone.name = 'new name'; //为authorClone的name属性设置值
console.log(authorClone.name); //打印的是'new name'
authorClone.books.push('new book'); //同样的,push的时候,先在authorClone中打books属性,
//找不到,沿着原型链向上找,在Author中有该属性,
//所以push修改的是原型对象Author.books的值。
authorClone.books = []; //添加books数组到authorClone对象自己上。
authorClone.books.push('new book'); //现在修改的就是authorClone上的books数组。

我们从上面例子中看到,向authorClone.books数组中添加新元素,有时候实际上是把这个元素添加到Author.books数组中。这个很危险,因为你对那个值的修改不仅会影响到Author,而且还会影响到所有继承了Author但还未改写那个属性的默认值的对象。

有时候原型对象自己也含有子对象。如果想覆盖子对象中的一个属性值,你不得不重新创建整个子对象。这可以通过将该子对象设置为一个空对象字面量,然后对其进行重塑而办到。但这意味着克隆出来的对象必须知道其原型对象的每一个子对象的确切结构和默认值。这样对象之间就形成了比较强的耦合

为了尽量弱化对象之间的耦合,任何复杂的子对象都应该使用方法来创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var CompoundObject = {
string1: 'default value',
childObject: {
bool: ture,
num: 10
}
}
var compoundObjectClone = clone(CompoundObject);
//坏了!改变了CompoundObject.childObject.num的值。
compoundObjectClone.childObject.num = 5;
//还好,我们可以重新创建整个子对象。前提是我们知道子对象的结构
//和默认值,这样对象之间就形成了比较强的耦合
compoundObjectClone.childObject = {
bool: true,
num: 5
}

有没有更好的办法呢?有!用一个工厂方法来创建childObject:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var CompoundObject = {};
CompoundObject.string1 = 'default name';
CompoundObject.createChildObject = function() {
return {
bool: true,
num: 10
}
};
CompoundObject.childObject = CompoundObject.createChildObject();
var compoundObjectClone = clone(CompoundObject);
//compoundObjectClone.childObject = CompoundObject.createChildObject();
//我觉得下面这样写比上一行注释那样写会好一点。
compoundObjectClone.childObject = compoundObjectClone.createChildObject();
compoundObjectClone.childObject.num = 5;

类式继承和原型式继承的对比

类式继承和原型式继承是大相径庭的两种继承范型,它们生成的对象也有不同的行为方式。两种继承的范型都各有其优缺点。

类式继承是大多数JavaScript程序员比较熟悉、用得比较多的继承范型。如果你设计的是一个供众人使用的API,或者可能会有不熟悉原型式继承的其他程序员基于你的代码进行设计,那么最好还是用类式继承。

原型式继承更能节约内存。原型链读取成员的方式使得所有克隆出来的对象都共享每个属性和方法的唯一一份实例,只有在直接设置了某个克隆出来的对象的属性和方法时,情况才会有所变化。与此相比,在类式继承方式中创建的每一个对象在内存中都有自己的一套属性(和私用方法)的副本。原型式继承在这方面的节约效果很突出。

掺元类

这是一种重用代码的方法,不需要用到严格的继承。如果想把一个函数用到多个类中,可以通过扩充(augmentation)的方式让这些类共享该函数。其实际的做法大体为:先创建一个包含各种通用方法的类,然后再用它扩充其它类。这种包含通用方法的类称为掺元类(mixin class)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*Mixin class*/
var Mixin = function() {};
Mixin.prototype = {
serialize: function() {
var output = [];
for(key in this) {
output.push(key + ': ' + this[key]);
}
return output.join(', ');
}
};
augment(Author, Mixin);
var author = new Author('Ross Harmes', ['JavaScript Design Patterns']);
var serializedString = author.serialize();

augment函数很简单。它用一个for…in循环遍访予类(giving class)的prototype中的每一个成员,并将其添加到受类(receiving class)的prototype中。如果受类中已经存在同名成员,则跳过这个成员,转而处理下一个。受类中的成员不会被改写。

1
2
3
4
5
6
7
8
/*Augment function*/
function augment(receivingClass, givingClass) {
for(methodName in givingClass.prototype) {
if(!receivingClass.prototype[methodName]) {
recevingClass.prototype[methodName] = givingClass.prototype[methodName];
}
}
}

这个函数还可以再改进一下。如果掺元类中包含许多方法,但你只想复制其中的一两个。下面的新版本会检查是否存在额外的可选参数,如果存在,刚只复制那些名称与这些参数匹配的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*Augment function, improved*/
function Augment(receivingClass, givingClass) {
if(argments[2]) { //只扩充指定的方法
for(var i = 2, len = arguments.length; i < len; i++) {
receivingClass.prototype[arguments[i]] = givingClass.prototype[arguments[i]];
}
} else { //扩充所有方法
for(methodName in givingClass.prototype) {
if(!receivingClass.prototype[methodName]) {
recevingClass.prototype[methodName] = givingClass.prototype[methodName];
}
}
}
}

现在就可以用augment(Author, Mixin, ‘serialize’);这条语句来达到只为Author类添加一个serialize方法的目的。

用一些方法来扩充一个类有时比让这个类继承另一个类更合适。这是一种避免出现重复性代码的轻便的解决方法。不过适合这种方案的场合并不是很多。只有那些通用到足以使其在彼此大不相同的各种类都能派上用场的方法才适合于共享,如果那些类彼此的差异不是那么大,那么普通的继承往往更合适。

Contents
  1. 1. JavaScript的继承
    1. 1.1. 为什么需要继承
    2. 1.2. 类式继承
      1. 1.2.1. extend函数
    3. 1.3. 原型式继承
      1. 1.3.1. 对继承而来的成员的读和写的不对等性
    4. 1.4. 类式继承和原型式继承的对比
    5. 1.5. 掺元类