对象的继承

本文是对 JavaScript 高级程序设计 一书中相关章节的整理,加上自己的理解


function Robot(name) {
this.name = name;
}
Robot.prototype.smile = function() {
alert("smile");
};
function Cat(age) {
this.age = age;
}
Cat.prototype.climbTree = function () {
alert("climb a tree");
};

假如现在我们要创建一个机器人实例对象,而且我们还想要它在拥有自身属性和方法的同时,继承一个猫类型对象拥有的所有属性和方法,那么我们要怎么做呢?

1. 原型链

原型链是实现继承的主要方法。那什么是原型链呢,我们首先来复习一下构造函数、原型和实例之间的关系:每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型对象的内部指针。 如果我们让一个构造函数(类型1)的原型对象等于另一个构造函数(类型2)的实例,那么此时这个构造函数(类型1)的原型对象(类型2的实例)将包含一个指向另一个构造函数(类型2)的原型的指针。假如另一个构造函数的原型(类型2)又是另一个构造函数(类型3)的实例,那么上述的关系依然成立,如此层层递进,就构成了实例与原型的链条,只不过其中的实例是以原型的形式存在的,所以可以称为原型链。

我们来看一下怎么用原型链实现文章开头的继承问题:

function Cat(age) {
this.age = age;
}
Cat.prototype.climbTree = function () {
alert("climb a tree");
};
function Robot() {
this.name = name;
}
// 用 Cat 的实例重写 Robot 的原型
Robot.prototype = new Cat(3);
// 在 Robot 原型中添加方法
Robot.prototype.smile = function () {
alert("smile");
};
// 实例化一个 Robot
var robot1 = new Robot("Paco");
// robot1 继承了 Cat 的 age 属性
alert(robot1.age); // 3
// robot1 成功调用添加到原型中的方法
robot1.smile(); // "smile"
// robot1 继承了 Cat 的 climbTree 方法
robot1.climbTree(); // "climb a tree"

以上代码定义了两个类型:Cat 和 Robot。每个类型都有一个属性和一个方法。它们的主要区别是 Robot 继承了 Cat,而继承是通过 Cat 实例,并将该实例赋值给 Robot.prototype 实现的。实现的本质是重写原型对象,代之以一个新类型的实例。
robot-cat
需要注意的是,robot1.instrubtor 现在指向的是 Cat,只是因为 robot1 中的 instructor 本来就是共享的 Robot 构造函数中的 instructor,现在构造函数被重写了成 Cat 实例,自然就共享的 Cat 构造函数中的 instructor。

别忘了默认的原型

事实上,前面例子中展示的原型链还少了一环。我们知道,所有引用类型默认都继承了 Object,而这个继承会通过原型链实现。下图为我们展示了该例子中完整的原型链:
robot-cat

确定原型,构造函数和实例的关系

  1. 用 instanceof 操作符确定实例和构造函数的关系

    robot1 instanceof Object; // true
    robot1 instanceof Robot; // true
    robot1 instanceof Cat; // true
  2. 用 isPrototypeOf() 方法确定实例和原型的关系

    Object.prototype.isPrototypeOf(robot1); // true
    Robot.prototype.isPrototypeOf(robot1); // true
    Cat.prototype.isPrototypeOf(robot1); // true

谨慎定义方法

给原型中添加方法一定要在原型被重写之后添加,因为如果在重写之前添加,重写时是会被覆盖的。

原型链的问题

  1. 包含引用类型值的原型属性会被所有实例共享,这也正是为什么要在构造函数中,而不是在原型对象中定义属性的原因。原型属性中包含引用类型值时,所有实例都共享这个引用类型。因此,在一个实例中修改这个引用类型,会反映到所有的实例中,这往往不是我们想要的结果。这跟在创建对象的时候,通过构造函数定义实例属性,通过原型定义共享方法和属性的道理是一样的。
  2. 没办法在不影响所有对象实例的情况下,给超类型的构造函数传递参数。

2. 借助构造函数

在子类型的构造函数内部调用(借用)超类型的构造函数

function Cat() {
this.friends = ["dog", "mouse"];
}
function Robot() {
Cat.call(this);
}
var robot1 = new Robot();
robot1.friends.push("human");
alert(robot1.friends); // "dog, mouse, human"
var robot2 new Robot();
alert(robot2.friends); // "dog, mouse"

通过 call 方法,我们实际上是在(未来将要)新创建 Robot 实例时调用了 Cat 构造函数,这样一来,就会在新 Robot 对象上执行 Cat() 函数中定义的所有对象初始化代码。结果 Robot 的每个实例都会有自己的 friends 属性的副本。

传递参数

function Cat(age) {
this.age = age;
}
function Robot(age) {
// 继承了 Cat,同时还传递了参数
Cat.call(this, age);
}
var robot1 = new Robot(3);
alert(robot1.age); // "3"

借用构造函数的问题

  1. 如果方法都在构造函数中定义,函数无复用性可言,没创建一个实例,都要重新声明一遍共享的方法函数。
  2. 超类型原型中的方法,是不能通过在子类型中调用超类型的构造函数实现继承的。有就是说,超类型原型中定义的方法对子类型不可见。

3. 组合继承

将原型链和借用构造函数的技术组合到一起来发挥二者之长。其背后的思路是使用原型链实现对原型属性和方法的继承,而通过借用构造函数来实现对实例属性的继承。运用这种方法的前提是,被继承的对象的创建方式应该遵循,在原型上定义方法,在实例中定义实例属性的原则,即上篇文章 中的组合使用构造函数和原型模式。

function Cat(name, age) {
this.name = name;
this.age = age;
this.friends = ["dog", "mouse"]
}
Cat.prototype.saySomething = function () {
alert("Hi, I'm " + this.name);
}
function Robot(name, age) {
Cat.call(this, name, age);
}
Robot.prototype = new Cat(); // 重写原型时创建的实例不用传参,实际上默认传入 undefined
Robot.prototype.constructor = Robot;
var robot1 = new Robot("Paco", 3);
robot1.friends.push("human");
alert(robot1.age); // 3
alert(robot1.friends); // "dog, mouse, human"
robot1.saySomething(); // "Hi, I'm Paco"
var robot2 = new Robot("Comma", 5);
alert(robot2.age); // 5
alert(robot2.friends); // "dog, mouse"
robot2.saySomething(); // "Hi, I'm Comma"

上面例子中,我们创建的两个 Robot 实例分别拥有自己的属性(包括 friends 属性),又可以使用同样的方法。

4. 原型式继承与寄生式继承

原型式继承

借助原型,可以基于 已有的对象 创建新对象,同时还不必因此创建自定义类型。

function object(o) {
function F() {};
F.prototype = o;
return new F();
}

在 object() 函数内部,先创建了一个临时性的构造函数,然后将传入的对象(已有的对象)作为这个构造函数的原型,最后返回这个临时类型的一个实例。从本质上讲,object() 对传入其中的对象执行了一次浅复制。对同一个已有对象重复调用该函数创建的所有实例都共享该对象作为它们的原型对象。这是因为重写临时构造函数的原型时是一个赋值操作,而引用类型的传参是采用共享传参的形式,传递的只是一个指针而已。

var robot = {
name: "hhhh",
friends: ["xiaoming", "xiaohong"]
}
var robot1 = object(robot);
robot1.friends.push("laoluo");
var robot2 = object(robot);
robot2.friends.push("xucen");
alert(robot.friends); // "xiaoming, xiaohong, laoluo, xucen"

ECMAScript 5 通过新增 Object.create() 方法规范化了原型式继承。这个方法接受两个参数:一个用作新对象原型的对象和一个为新对象定义额外属性的对象(可选)。在只接收一个参数的时候,跟 object() 方式是一样的。两个参数时,第二个参数与 Object.definePropertie() 方法的第二个参数格式相同:每个属性都是通过自己的描述符定义的。以这种方式定义的任何属性都会覆盖原型对象上的同名属性。

var robot = {
name: "hhhh",
friends: ["xiaoming", "xiaohong"]
}
var robot1 = Object.create(robot, {
name: {
value: "Paco"
}
});
alert(robot1.name); // "Paco"

在没有必要兴师动众地创建构造函数,而只是想让一个对象与另一个对象类似的情况下,原型式继承是完全可以胜任的。不过别忘了,包含引用类型值得属性始终都会共享相应的值。

寄生式继承

之所以将这两种方式在一起,是因为它们是密切相关的
原型式继承创建的对象的实例属性为空,如果需要对其增强(添加属性或方法)的话,还需要在函数外进行添加。寄生式继承是将这两步封装到了一个函数中:

var robot = {
name: "hhhh",
friends: ["xiaoming", "xiaohong"]
}
function createRobot(original) {
var clone = Object.create(original);
clone.saySomething = {
alert("hi");
};
return clone;
}
var robot1 = createRobot();
robot1.saySomething(); // "hi"

使用寄生式继承来为对象添加函数,会由于不能做到函数复用而降低效率。这一点与构造函数模式类似。

5. 寄生组合式继承

组合式继承是一个很理想的继承方式,但是在效率上有一点不足,就是它调用了两次超类型的构造函数,一次是在继承超类型实例属性时,一次是在继承超类型原型属性时,并且在第二次调用时还在子类型的原型上重复创建了超类型的实例属性,只不过被第一次调用时创建的实例属性覆盖:

function Cat(name, age) {
this.name = name;
this.age = age;
this.friends = ["dog", "mouse"]
}
Cat.prototype.saySomething = function () {
alert("Hi, I'm " + this.name);
}
function Robot(name, age) {
Cat.call(this, name, age); // 第一次调用
}
Robot.prototype = new Cat(); // 第二次调用

寄生组合式继承是将组合继承(借用构造函数 + 原型链继承)中的原型链继承用寄生继承代替。那么寄生组合继承实际上就是借用构造函数和寄生继承(对超类型的原型进行一次浅复制,用得到的对象重写子类型的原型)的组合。

function Cat(name, age) {
this.name = name;
this.age = age;
this.friends = ["dog", "mouse"]
}
Cat.prototype.saySomething = function () {
alert("Hi, I'm " + this.name);
}
function Robot(name, age) {
Cat.call(this, name, age);
}
function inheritPrototype(SubType, SuperType) {
var prototype = Object.create(SuperType.prototype);
prototype.constructor = SubType;
SubType.prototype = prototype;
}
var robot1 = new Robot("Paco", 3);
inheritPrototype(Robot, Cat);

这个例子的高效率体现在它只调用了一次 Cat 构造函数,因此避免了在 Robot.prototype 上面创建不必要、多余的属性。
开发人员普遍认为寄生组合式继承是引用类型最理想的继承方式。