Javascript 继承篇

1. Prototype 链(Prototype chaining)

Javascript 是一种动态语言,实现一个目标通常有多种方式,继承也不例外。首先我们介绍下实现继承最普遍的方式 :利用 Prototype 链。

这里假设你已经对 prototype 以及 __proto__ 有了一定的了解,否则请先参考 Javascript 之 Prototype

prototype 链的示意图:
技术分享

接下来的例子我们都采用类似示意图中的3层继承结构:最顶层为一个 Sharp 类,它有一个 名为 TwoDSharp 的子类,最后还有一个名为 Triangle 的类继承自 TwoDSharp。

1.1 Prototype 链示例

首先定义这3个类:

function Shape(){
  this.name = ‘Shape‘;
  this.toString = function () {
    return this.name;
  };
}

function TwoDShape(){
  this.name = ‘2D shape‘;
}

function Triangle(side, height){
  this.name = ‘Triangle‘;
  this.side = side;
  this.height = height;
  this.getArea = function () {
    return this.side * this.height / 2;
  };
}

要实现继承关系,只需要添加下面的代码:

TwoDShape.prototype = new Shape();
Triangle.prototype = new TwoDShape();

// 修正 constructor 信息
TwoDShape.prototype.constructor = TwoDShape;
Triangle.prototype.constructor = Triangle;

这里需要指出: Javascript 操作的是对象而不是类,所以 prototype 属性需要一个实例化对象来实现继承,而不像其他 OO 语言直接声明继承自某一个类。
另外在你通过这种方法实现继承后,你再去修改甚至删除父类的函数,已经没有什么关系了,因为我们的继承关系是建立在实例对象上的。

var my = new Triangle(5, 10);

// 自属性
my.getArea();
// 25

// 父属性
my.toString();
// "Triangle"

// 修正 constructor 的语句让我们能获得正确的信息
my.constructor === Triangle;
// true

// 类型确认
my instanceof Shape;
// true
my instanceofTwoDShape;
// true
my instanceof Triangle;
// true
Shape.prototype.isPrototypeOf(my);
// true
TwoDShape.prototype.isPrototypeOf(my);
// true
Triangle.prototype.isPrototypeOf(my);
// true

1.2 把共有属性转到 prototype 上

当你用构造函数创建实例对象时,你可以通过 this 关键字来为其追加属性。当这些属性是只读时,这种做法值得商榷:

function Shape(){
  this.name = ‘Shape‘;
}

这意味着你每一个 Shape 实例对象在内存中都保存着一份 name 属性的值,这显然是不必要的,可以尝试把属性转移到 prototype 上:

function Shape() { ... }
Shape.prototype.name = ‘Shape‘;

利用这种方法,我们可以改进一下前面的函数定义:

// 构造函数
function Shape() {}
// 扩展 prototype
Shape.prototype.name = ‘Shape‘;
Shape.prototype.toString = function () {
  return this.name;
};

// 构造函数
function TwoDShape() {}
// 继承实现
TwoDShape.prototype = new Shape();
TwoDShape.prototype.constructor = TwoDShape;
// 扩展 prototype
TwoDShape.prototype.name = ‘2D shape‘;

function Triangle(side, height) {
  this.side = side;
  this.height = height;
}
// 继承实现
Triangle.prototype = new TwoDShape();
Triangle.prototype.constructor = Triangle;
// 扩展 prototype
Triangle.prototype.name = ‘Triangle‘;
Triangle.prototype.getArea = function () {
  return this.side * this.height / 2;
};

验证一下:

var my = new Triangle(5, 10);
my.getArea();
// 25

my.toString();
// "Triangle"

TwoDShape.prototype.isPrototypeOf(my);
// true
my instanceof Shape;
// true

输出结果都满足预期,这里要指出一个细节:my 上的 toString() 方法要比改进前多一步查找,因为原先这个函数就在 Shape 类上,而现在它在 Shape.prototype 上。

2. 仅继承 prototype

前面的例子可以看到在 prototype 上扩展可重用的属性和方法的优势,那么直接从 prototype 继承显然是个不错的方法,至少它带来两个好处:

  • 不用再实例化对象来实现继承
  • 运行时定位属性/方法的效率高了
function Shape() {}
// 扩展 prototype
Shape.prototype.name = ‘Shape‘;
Shape.prototype.toString = function () {
  return this.name;
};

function TwoDShape() {}
// 实现继承
TwoDShape.prototype = Shape.prototype;
TwoDShape.prototype.constructor = TwoDShape;
// 扩展 prototype
TwoDShape.prototype.name = ‘2D shape‘;

function Triangle(side, height) {
  this.side = side;
  this.height = height;
}
// 实现继承
Triangle.prototype = TwoDShape.prototype;
Triangle.prototype.constructor = Triangle;
// 扩展 prototype
Triangle.prototype.name = ‘Triangle‘;
Triangle.prototype.getArea = function () {
  return this.side * this.height / 2;
};

// 测试
var my = new Triangle(5, 10);
my.getArea();
// 25
my.toString();
// "Triangle"

如果你细究一下,你会发现现在 my 的 toString() 函数定位高效了不少。
这里要提一下这个方法的缺点:由于父子的 prototype 都指向同一个对象,当其中一方修改时就会影响到另一方:

Triangle.prototype.name = ‘Triangle‘;
var s = new Shape();
s.name;
// "Triangle"

这显然不可接受啊!

3. 临时构造函数 – new F()

前面介绍的继承方法非常高效,却有个致命的缺点。我们引入一个临时的构造函数来解决:

function Shape() {}
// 扩展 prototype
Shape.prototype.name = ‘Shape‘;
Shape.prototype.toString = function () {
  return this.name;
};

function TwoDShape() {}
// 继承实现
var F = function () {};
F.prototype = Shape.prototype;
TwoDShape.prototype = new F();
TwoDShape.prototype.constructor = TwoDShape;
// 扩展 prototype
TwoDShape.prototype.name = ‘2D shape‘;

function Triangle(side, height) {
  this.side = side;
  this.height = height;
}
// 继承实现
var F = function () {};
F.prototype = TwoDShape.prototype;
Triangle.prototype = new F();
Triangle.prototype.constructor = Triangle;
// 扩展 prototype
Triangle.prototype.name = ‘Triangle‘;
Triangle.prototype.getArea = function () {
  return this.side * this.height / 2;
};

// 测试
var s = new Shape();
s.name;
// "Shape"
"I am a " + new TwoDShape(); // 调用 toString()
// "I am a 2D shape"

3.1 子类中访问父类

传统的 OO 语言通常都有一个关键字(super/base等)来代表父类,这种方式下子类能非常方便的访问父类成员。
在 Javascript 中没有类似的语法,但我们依然能巧妙的实现类似的功能,下例中我们建立了一个 uber 属性来指向父的 prototype 对象。

function Shape() {}
// 扩展 prototype
Shape.prototype.name = ‘Shape‘;
Shape.prototype.toString = function () {
  var const = this.constructor;
  return const.uber ? this.const.uber.toString() + ‘, ‘ + this.name : this.name;
};

function TwoDShape() {}
// 继承实现
var F = function () {};
F.prototype = Shape.prototype;
TwoDShape.prototype = new F();
TwoDShape.prototype.constructor = TwoDShape;
TwoDShape.uber = Shape.prototype;
// 扩展 prototype
TwoDShape.prototype.name = ‘2D shape‘;
function Triangle(side, height) {
  this.side = side;
  this.height = height;
}
// 继承实现
var F = function () {};
F.prototype = TwoDShape.prototype;
Triangle.prototype = new F();
Triangle.prototype.constructor = Triangle;
Triangle.uber = TwoDShape.prototype;
// 扩展 prototype
Triangle.prototype.name = ‘Triangle‘;
Triangle.prototype.getArea = function () {
  return this.side * this.height / 2;
};

这里修改了两个内容:

  • 建立了一个 uber 属性来指向父类的 prototype 对象
  • 一个新的 toString() 函数,用来帮助我们验证这套运行机制
var my = new Triangle(5, 10);
my.toString();
// "Shape, 2D shape, Triangle"

这里的属性名 uber 可以任意命名,但并不建议采用以下的常用名称。
“superclass”:这暗示了 Javascript 中有类的概念,而实际却不是这样
“super”:这是 Java 中的父对象关键字,在 Javascript 是保留字(虽然没作用)

3.2 把继承逻辑合到一个 function 中

现在我们把继承的实现逻辑提取出来做成一个 fucntion,方便调用:

function extend(Child, Parent) {
  var F = function () {};
  F.prototype = Parent.prototype;
  Child.prototype = new F();
  Child.prototype.constructor = Child;
  Child.uber = Parent.prototype;
}

现在要定义继承关系就相当简洁明了:

// 类定义
function Shape() {}
Shape.prototype.name = ‘Shape‘;
Shape.prototype.toString = function () {
  return this.constructor.uber ? this.constructor.uber.toString() + ‘, ‘ + this.name : this.name;
};

// 类定义 + 继承
function TwoDShape() {}
extend(TwoDShape, Shape);
TwoDShape.prototype.name = ‘2D shape‘;

// 类定义
function Triangle(side, height) {
  this.side = side;
  this.height = height;
}
// 继承
extend(Triangle, TwoDShape);
// 扩展
Triangle.prototype.name = ‘Triangle‘;
Triangle.prototype.getArea = function () {
  return this.side * this.height / 2;
};

// 测试
new Triangle().toString();
// "Shape, 2D shape, Triangle"

4. 复制属性

现在换一下思路,既然我们继承的目的是重用代码,那么把一个对象的成员简单地复制到另一个对象不也可以吗?我们这就新建一个 extend2() 函数来实验一下这个方法:

function extend2(Child, Parent) {
  var p = Parent.prototype;
  var c = Child.prototype;
  for (vari in p) {
    c[i] = p[i];
  }
  c.uber = p;
}

这里没有设置 Child.prototype.constructor 的原因是:我们没有整个替换掉 Child.prototype 对象,而是在其本身上进行了扩展。

这个方法比起前面的 extend() 来说有一个缺点:每个子类成员都被做了一个副本,而前者是通过 prototype 链。另外需要注意的是这个复制操作仅仅对原始(primitive)类型有效,对于 object 类型(包括 function 及 array)来说因为它们是基于引用传递的,你复制的仅仅是它们的地址!

var Shape = function () {};
varTwoDShape = function () {};
Shape.prototype.name = ‘Shape‘;
Shape.prototype.toString = function () {
  return this.uber ? this.uber.toString() + ‘, ‘ + this.name : this.name;
};

// extend()
extend(TwoDShape, Shape);
var td = new TwoDShape();
td.name;
// "Shape"
TwoDShape.prototype.name;
// "Shape"
td.__proto__.name;
// "Shape"
td.hasOwnProperty(‘name‘);
// false
td.__proto__.hasOwnProperty(‘name‘);
// false

// extend2()
extend2(TwoDShape, Shape);
var td = new TwoDShape();
td.__proto__.hasOwnProperty(‘name‘);
// true
td.__proto__.hasOwnProperty(‘toString‘);
// true
td.__proto__.toString === Shape.prototype.toString;
// true

子成员都需要做一个副本的话 extend2() 似乎并不高效,但这没有想象的那么糟糕因为实际上我们仅仅对原始(primitive)类型做了复制;另外当 Javascript 引擎在 prototype 链上查找成员时查找的路径变短了。

4.1 注意引用拷贝

有时候引用拷贝的结果并不是你期待的,比如下面的例子:

function Papa() {}
function Wee() {}
Papa.prototype.name = ‘Bear‘;
Papa.prototype.owns = ["porridge", "chair", "bed"];

extend2(Wee, Papa);

Wee.prototype.hasOwnProperty(‘name‘);
// true
Wee.prototype.hasOwnProperty(‘owns‘);
// true

Wee.prototype.owns;
// ["porridge", "chair", "bed"]
Wee.prototype.owns=== Papa.prototype.owns;
// true

Wee.prototype.name += ‘, Little Bear‘;
// "Bear, Little Bear"
Papa.prototype.name;
// "Bear"

// 对 Wee 的 owns 属性的修改会作用到 Papa 上
Wee.prototype.owns.pop();
// "bed"
Papa.prototype.owns;
// ["porridge", "chair"]

但是当你整个替换 Wee 的 owns 属性时,又是另外一番情形了:

Wee.prototype.owns= ["empty bowl", "broken chair"];
Papa.prototype.owns.push(‘bed‘);
Papa.prototype.owns;
// ["porridge", "chair", "bed"]

这里用一张示意图来分析一下这个过程:
技术分享

  1. 生成一个新 object对象,A 中存放指向它的地址
  2. 声明一个新变量 B,把 A 中的地址复制一份给 B
  3. 修改 B.code 意味着修改 B 的地址指向内容的 code,由于 A 的指向和 B 的一致,所以修改也会作用到 A 上。
  4. 一个新对象被创建,B 的地址被改为指向这个新的对象。A 和 B 指向了不同的内容,不再互相影响了。

5. 从实例对象继承

前面的篇章我们都在构造函数上做文章,通过设置这些函数的 prototype 来实现继承。之前提到过 Javascript 中并没有类的概念,所以它的继承实际上就是代码的重用,前面的 extend2() 就是个比较直接的方式,但它只是在构造函数本身上动手脚。让我们更简单粗暴一点,跳过构造函数直接生成子类的实例对象岂不更好?

function extendCopy(p) {
  var c = {};
  for (vari in p) {
    c[i] = p[i];
  }
  c.uber = p;
  return c;
}

利用新的 extendCopy() 来实现一下前面的继承结构:

var shape = {
  name: ‘Shape‘,
  toString: function () {
    return this.name;
  }
};

var twoDee = extendCopy(shape);
twoDee.name = ‘2D shape‘;
twoDee.toString = function () {
  return this.uber.toString() + ‘, ‘ + this.name;
};

var triangle = extendCopy(twoDee);
triangle.name = ‘Triangle‘;
triangle.getArea = function () {
  return this.side * this.height / 2;
};

// 测试
triangle.side = 5;
triangle.height = 10;
triangle.getArea();
// 25
triangle.toString();
// "Shape, 2D shape, Triangle"

新的实现让我们不再需要定义子类的构造函数,但某些情形下你可能需要通过构造函数传入一些参数(譬如:创建 triangle 实例的同时传入 side,height),这要解决起来也是很简单的:

  • 增加一个 init() 来传参数
  • 在 extendCopy() 上添加第二个参数,用来传递这些信息

6. 深拷贝

你已经理解 Javascript 中的引用拷贝的内部原理:它们只是指针的拷贝,这被称之为浅拷贝。那么复制指针指向的内容就称之为深拷贝,它们的实现代码非常相似,深拷贝只是浅拷贝的一个“递归”版本:

function deepCopy(p, c) {
  c = c || {};
  for (vari in p) {
    if (p.hasOwnProperty(i)) {
      if (typeof p[i] === ‘object‘) {
        c[i] = Array.isArray(p[i]) ? [] : {};
        deepCopy(p[i], c[i]);
      } else {
        c[i] = p[i];
      }
    }
  }
  return c;
}

测试一下:

var parent = {
  numbers: [1, 2, 3],
  letters: [‘a‘, ‘b‘, ‘c‘],
  obj: {
    prop: 1
  },
  bool: true
};

var mydeep = deepCopy(parent);
var myshallow = extendCopy(parent);
mydeep.numbers.push(4,5,6);
// 6
mydeep.numbers;
// [1, 2, 3, 4, 5, 6]
parent.numbers;
// [1, 2, 3]
myshallow.numbers.push(10);
// 4
myshallow.numbers;
// [1, 2, 3, 10]
parent.numbers;
// [1, 2, 3, 10]
mydeep.numbers;
// [1, 2, 3, 4, 5, 6]

最后补充两点:

  • 使用 hasOwnProperty() 来排除非自有属性,以此保证不会附加多余的属性
  • Array.isArray() 是 ES5 的规范,如果你的 Javascript 环境中没有别惊慌,使用下面的 Polyfill:
if (Array.isArray !== "function") {
  Array.isArray = function (candidate) {
    return Object.prototype.toString.call(candidate) === ‘[object Array]‘;
  };
}

7. object()

基于从对象继承的模式,有人提出使用一个 object() 函数来实现:

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

如果需要对父操作,可以改为:

// 虽然是从实例对象继承,但这种模式内部使用 prototype 继承的方式
function object(o) {
  var n;
  function F() {}
  F.prototype = o;
  n = new F();
  n.uber = o;
  return n;
}

使用起来与 extendCopy() 一样,也是传入一个实例对象:

var triangle = object(twoDee);
triangle.name = ‘Triangle‘;
triangle.getArea = function () {
  return this.side * this.height / 2;
};

triangle.toString();
// "Shape, 2D shape, Triangle"

// 在 ES5 中内置了类似的函数 Object.create()
var square = Object.create(triangle);

8. prototype 继承 与 成员拷贝的混合使用

继承的时候,你总是想能够利用既有的功能,以此为基础进行扩展,同时利用前面的两种继承方式可以让我们很容易达成该目的。

  • 使用 prototype 继承来获取既有对象的功能
  • 使用成员拷贝来扩展新的实例对象
function objectPlus(o, stuff) {
  var n;
  function F() {}
  F.prototype = o;
  n = new F();
  n.uber = o;
  for (vari in stuff) {
    n[i] = stuff[i];
  }
  return n;
}

objectPlus() 函数的第一个参数用来 prototype 继承,第二个参数用来扩展,来看看具体的使用:

var shape = {
  name: ‘Shape‘,
  toString: function () {
    return this.name;
  }
};

var twoDee = objectPlus(shape, {
  name: ‘2D shape‘,
  toString: function () {
    return this.uber.toString() + ‘, ‘ + this.name;
  }
});

var triangle = objectPlus(twoDee, {
  name: ‘Triangle‘,
  getArea: function () {
    return this.side * this.height / 2;
  },
  side: 0,
  height: 0
});

// 测试
var my = objectPlus(triangle, {
  side: 4, height: 4
});
my.getArea();
// 8
my.toString();
// "Shape, 2D shape, Triangle, Triangle"

最后的输出出现了两个 Triangle,这是因为实例对象 my 继承自 Triangle,导致继承层数多了一层,如果创建时给 name 赋个值就能分辨出这个原由了:

objectPlus(triangle, {
  side: 4,
  height: 4,
  name: ‘My 4x4‘
}).toString();
// "Shape, 2D shape, Triangle, My 4x4"

objectPlus() 与 ES5 中的 Object.create() 非常相似,唯一不同的是第二个参数。详细可参考相关的文档。

9. 多重继承

多重继承就是子类有多个父类,在 OO 语言中许多都不支持,当你遇到是否引入多重继承时需要考虑再三,因为它可能给你带来便利的同时增加程序的复杂度,同时打断直观的继承链。
采用成员拷贝的做法能够很容易地实现多重继承,你甚至可以继承自无限多个父对象。下面的 multi() 函数接受一个父对象数组,内部使用 arguments 关键字来遍历这个数组,并逐一复制:

function multi() {
  var n = {}, stuff, j = 0, len = arguments.length;
  for (j = 0; j <len; j++) {
    stuff = arguments[j];
    for (vari in stuff) {
      if (stuff.hasOwnProperty(i)) {
        n[i] = stuff[i];
      }
    }
  }
  return n;
}

// 使用
var shape = {
  name: ‘Shape‘,
  toString: function () {
    return this.name;
  }
};
vartwoDee = {
  name: ‘2D shape‘,
  dimensions: 2
};
var triangle = multi(shape, twoDee, {
  name: ‘Triangle‘,
  getArea: function () {
    return this.side * this.height / 2;
},
  side: 5,
  height: 10
});

// 测试
triangle.getArea();
// 25
triangle.dimensions;
// 2
triangle.toString();
// "Triangle"

multi() 采用顺序遍历的方式复制类成员,所以如果父对象的成员有相同的名字,最后出现的作用在子对象上

混合体(Mixins)

混合体是一种多个对象的合体,它通常具有所有这些合体对象的功能(成员),但本质上并不是这些对象的子对象。(前文的 multi() 生成的结果就是一个混合体)

10. 寄生式继承(Parasitic inheritance)

寄生式继承:把一个对象上的所有功能移到新的实例对象上,并对新的实例对象进行扩展。

var twoD = {
  name: ‘2D shape‘,
  dimensions: 2
};

// 寄生式继承
function triangle(s, h) {
  // 使用 object() 来拷贝对象上的所有成员
  var that = object(twoD);
  // 修改/扩展
  that.name =‘Triangle‘;
  that.getArea = function () {
    return this.side * this.height / 2;
  };
  that.side = s;
  that.height = h;
  // 返回新实体
  return that;
}

使用时注意不用 new 关键字:

var t = triangle(5, 10);
t.dimensions;
// 2
vart2 = new triangle(5,5);
t2.getArea();
// 12.5

11. 构造函数的借用

这是本文介绍的最后一种实现继承的方式:子对象使用 call() 或 apply() 来调用父对象的构造函数。

如果你对 call() 或 apply() 不是非常了解,这里简短的介绍一下:
这两个方法让你调用函数的时候传入一个对象,并把该函数中的 this 与传入的这个对象进行绑定。

// 父
function Shape(id) {
  this.id = id;
}
Shape.prototype.name = ‘Shape‘;
Shape.prototype.toString = function () {
  return this.name;
};

// 子
function Triangle() {
  Shape.apply(this, arguments);
}
Triangle.prototype.name = ‘Triangle‘;

// 测试
var t = new Triangle(101);
t.name;
// "Triangle"
t.id;
// 101
t.toString();
// "[object Object]"

实例 Triangle 对象从 Shape 上继承了 id 属性,但是并没有得到父的 prototype 上的内容。根据前文的介绍,我们知道可以这样修改来解决这个问题:

// 子
function Triangle() {
  Shape.apply(this, arguments);
}
Triangle.prototype = new Shape();
Triangle.prototype.name = ‘Triangle‘;

这里有个小问题问题:父的构造函数被调用了两次,一次是 apply() 发起的,一次是设置 prototype 的时候。

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。