NIKO'S BLOG

面向对象三之继承

概要

面向对象的语言中大多支持两种继承方式:接口继承和实现继承。接口继承是继承方法签名,实现继承是继承是继承实际的方法。由于js的函数没有签名,所以只支持实现继承,而且实现继承主要是依靠原型链来实现。

正文

原型链

首先,何为原型链?我不得不说我看书上的那种表述性的文字真的很容易晕,还是大白话+code来的实际,那就先贴代码吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function SuperType(){ //超类型
this.property = true;
}
SuperType.prototype.getSuperValue = function(){
return this.property;
}
function SubType(){ //子类型
this.subproperty = false;
}
SubType.prototype = new SuperType(); //关键的一步
SubType.prototype.getSubValue = function(){
return this.subproperty;
}
var instance = new SubType();
console.log(instance.getSuperValue); //true
console.log(instance.getSubValue); //false

上面的代码分别定义了超类型和子类型两个构造函数(只是为了方便称呼,上面也做了相应的注释)。其中,在超类型的构造函数里定义了property属性,在超类型的原型上定义了getSuperValue方法。对于子类型,先在其构造函数内定义了subproperty属性,然后没有紧接着定义其原型方法,而是在这之前,把一个超类型的新实例对象赋给了子类型的原型属性,即把子类型的原型对象重新指向了超类型的一个实例。做完了这一步才定义了子类型的getSubValue方法。

从打印结果可以看出,虽然没有在子类型上定义getSuperValue方法,但是却可以通过子类型的实例来调用这个方法。这是为什么呢?就因为上面加粗部分那关键的一步,即把子类型的原型对象重新指向了超类型的一个实例。通过上一节的学习,我们知道了:原型中保存的属性和方法都能被其实例访问到。那现在子类型的原型就等于超类型的实例了(可能表述的不太准确,但是方便理解),通过超类型的实例又能访问到超类型原型中方法,子类型的实例自然就能访问到保存在超类型的原型中的方法(getSuperValue)了。这就像一个长长的链子,这个链子就叫原型链:

1
子类型的实例 => 子类型的原型 => 超类型的实例 => 超类型的原型

可以想象,如果超类型的原型又指向另一个超超类型的实例,继续往上可能还有超超超类型…那这个原型链就可以延伸的更长了。别忘了,所有对象的根都是Object类型,所以这个原型链的顶端也一定是它。就是这样一种原型链的机制,才使得所有的对象都继承了原始的那7个属性和方法,回顾一下吧,它们是:constructor属性、hasOwnProperty()方法、propertyIsEnumerable()方法、isPrototypeOf()方法、valueOf()方法、toString()方法、toLocaleString()方法。

谨慎地定义方法

如果把上面的示例代码加上一句,像下面这样,会是什么结果呢?

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
function SuperType(){ //超类型
this.property = true;
}
SuperType.prototype.getSuperValue = function(){
return this.property;
}
function SubType(){ //子类型
this.subproperty = false;
}
SubType.prototype = new SuperType(); //关键的一步 注意位置
SubType.prototype.getSubValue = function(){
return this.subproperty;
}
//添加的一句
SubType.prototype.getSuperValue = function(){
return false;
}
var instance = new SubType();
console.log(instance.getSuperValue); //false 注意这里值变了
console.log(instance.getSubValue); //false

上面添加的一句代码在子类型的原型上重写了getSuperValue方法,这个重写会屏蔽子类型的实例对在超类型原型上定义的getSuperValue方法的访问,因为在访问这个方法时,沿着原型链的搜索会在在搜索到子类型的原型上的getSuperValue方法后停止,并执行这个方法。

那如果我把那“关键的一步”放在子类型的方法定义之后会怎样呢?

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
function SuperType(){ //超类型
this.property = true;
}
SuperType.prototype.getSuperValue = function(){
return this.property;
}
function SubType(){ //子类型
this.subproperty = false;
}
SubType.prototype.getSubValue = function(){
return this.subproperty;
}
//添加的一句
SubType.prototype.getSuperValue = function(){
return false;
}
SubType.prototype = new SuperType(); //关键的一步 注意位置
var instance = new SubType();
console.log(instance.getSuperValue); //true
console.log(instance.getSubValue); //erro:instance.getSubValue is not a function

我们发现通过子类型的实例可以访问到getSuperValue方法,但是访问不到getSubValue方法了。这是为什么呢?因为这里定义的两个方法是挂在引擎自动创建的那个旧原型上的,在定义方法后再进行原型的重指向操作,实际上相当于定义的方法并没有挂在这个新原型上。通过原型链自然还能访问到超类型中定义的getSuperValue方法,但是已经访问不到定义在旧原型上的getSubValue方法了,自然就报错了。一句话来说就是:在通过原型链实现继承时,必须在用实例替换掉原型的操作后再进行定义方法的操作

如果把上面的代码再改一下,改成用字面量来进行方法定义的操作,结果如何呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function SuperType(){ //超类型
this.property = true;
}
SuperType.prototype.getSuperValue = function(){
return this.property;
}
function SubType(){ //子类型
this.subproperty = false;
}
SubType.prototype = new SuperType(); //关键的一步
//改成字面量写法
SubType.prototype = {
getSuperValue : function(){
return false;
}
}
var instance = new SubType();
console.log(instance.getSuperValue); //error

其实是一样的,因为上一节也说过使用字面量定义方法和一条一条给原型添加方法的区别了:用字面量定义方法会重写原型对象,原型就不再指向超类型的实例了。这破坏了原有的原型链,自然无法访问到超类型的原型中定义的方法,因此报错。记住:在通过原型链实现继承时,不能使用对象字面量来创建原型方法

原型链的问题

原型链的问题主要来自包含引用类型值的原型。通过上一节的内容我们知道:如果原型属性中包含引用类型的值,那么这个属性会被所有实例共享,通过一个实例改动这个属性,改动会反应在所有的实例上。所以,引用类型的属性一般是放在构造函数内作为实例属性而存在的,就像下面这样:

1
2
3
function SuperType(){
this.colors = ['red','green','blue'];
}

可是在原型链中,超类型的实例是作为子类型的原型存在的。也就是说,超类型的实例属性通过原型链变成了子类型的原型属性了,共享的问题就又出现了。看下面的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function SuperType(){
this.colors = ['red','green','blue'];
}
function SubType(){}
SubType.prototype = new SuperType();
var instance1 = new SubType();
instance1.colors.push('black');
console.log(instance1.colors); //red,green,blue,black
var instance2 = new SubType();
console.log(instance2.colors); //red,green,blue,black

原型链的另一个问题是:在创建子类型的实例时,不能向超类型的构造函数中传递参数。实际上,应该说是没有办法在不影响所有对象实例的情况下,给超类型的构造函数传递参数。嗯,这句话我不是很理解…也没查到关于这个地方的讨论,全是照抄一遍…mark一下,以后理解了再来改下吧…

总之,由于这两个原因,很少有人这样单独用原型链的…

借用构造函数

为了解决在原型链上包含引用类型值的原型的问题,可以使用借用构造函数技术(有时候也叫伪造对象或经典继承)。这种技术的基本思想很简单。让超类型的构造函数在子类型构造函数的作用域内执行,可以用call()apply()来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function SuperType(){
this.colors = ['red','green','blue'];
}
function SubType(){
//继承超类型
SuperType.call(this);
}
var instance1 = new SubType();
instance1.colors.push('black');
console.log(instance1.colors); //red,green,blue,black
var instance2 = new SubType();
console.log(instance2.colors); //red,green,blue

上面代码通过用call()实现在子类型的作用域内执行超类型函数来实现继承。在(将来)创建一个子类型的实例时,会在当前环境下执行超类型构造函数内的代码。这样,就在子类型的实例上初始化了超类型中定义的colors属性的一个副本,也就实现了继承。

传递参数

借用构造函数技术可以很好的实现传递参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
function SuperType(name,age){
this.name = name;
this.age = age;
}
function SubType(){
//继承超类型
SuperType.apply(this,arguments);
}
var instance = new SubType('nikolaus',23);
console.log(instance.name); //nikolaus
console.log(instance.age); //23

通过上面的实例可以清楚的看到,用借用构造函数技术可以很好的实现传递参数。用apply()call()都可以。需要注意,子类型中另外定义的属性和方法应该在继承超类型的语句之后,这样可以避免超类型中的属性方法重写你另外定义的属性方法。

借用构造函数的问题

如果单独使用借用构造函数,也无法避免其存在的问题————方法都定义在构造函数里,则没有了复用性;定义在超类型的原型上的方法,对子类型而言又是不可见的。所以,也很少单独使用借用构造函数技术…

组合继承

组合继承(有时也叫伪经典继承),是将原型链击沉和借用构造函数继承结合起来使用的技术。其基本思想是用原型链来实现对原型属性和方法的继承,用借用构造函数来实现对实例属性的继承。这样,通过在原型上定义方法可以实现方法的复用,又保证了每个实例拥有自己的属性。

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
function SuperType(name) {
this.name = name;
this.colors = ['red','green','blue'];
}
SuperType.prototype.sayName = function(){
console.log(this.name);
}
function SubType(name,age){
SuperType.call(this,name);
this.age = age;
}
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType; //上一节说过,标准写法应该用Object.defineProperty()来定义
SubType.prototype.sayAge = function(){
console.log(this.age);
}
var instance1 = new SubType('nikolaus','23');
instance1.colors.push('black');
console.log(instance1.colors); //red,green,blue,black
instance1.sayName(); //nikolaus
instance1.sayAge(); //23
var instance2 = new SubType('liuyu','20');
console.log(instance2.colors); //red,green,blue,
instance2.sayName(); //liuyu
instance2.sayAge(); //20

上面的示例代码中,在超类型上定义了实例属性namecolors,其中name属性通过传参赋值,又在超类型的实例上定义了sayName()方法。然后通过借用构造函数实现了对实例属性namecolors的继承,并通过传参另外定义了一个实例属性age。接着通过原型链实现了对原型方法sayName()的继承,并另外定义了一个原型方法sayAge()。这样一来,两个实例都拥有了自己的实例属性,并且可以使用相同的方法。

组合继承弥补了原型链和借用构造函数各自的缺陷,融合了各自的优点,是最常用的继承模式。而且instanceofisPrototypeOf()也可以识别基于组合继承创建的对象。

原型式继承

原型式继承并没有使用严格意义上的构造函数,基本思想是:借助原型可以基于已有对象创建新对象,同时还不必因此创建自定义类型。如下:

1
2
3
4
5
function object(o){
function F(){};
F.prototype = o;
return new F();
}

上面的示例代码在一个函数中创建了一个临时构造函数,并把传入函数的作为参数的对象赋给临时构造函数的原型,然后返回这个临时类型的一个实例。这个过程实际上实现了对传入函数的作为参数的对象的浅复制。看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function object(o){
function F(){}
F.prototype = o;
return new F();
}
var person = {
name : 'nikolaus',
friends : ['张一','李二','王三']
};
var person2 = object(person);
person2.name = 'anton';
person2.friends.push('尼古拉斯·赵四')
var person3 = object(person);
person3.name = 'jan';
person3.friends.push('孙五');
console.log(person.friends) //张一,李二,王三,尼古拉斯·赵四,孙五

ECMAScript5对原型式继承有了底层的实现,即Object.creat()方法。这个方法接收两个参数:一个作为新对象原型的对象(就是上面例子中的对象o),(可选的)用来定义新对象属性的描述符对象(形式上和Object.defineproperty(obj,descriptor)方法中的第二个参数一致)。

把上面的例子重写一下就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function object(o){
function F(){}
F.prototype = o;
return new F();
}
var person = {
name : 'nikolaus',
friends : ['张一','李二','王三']
};
var person2 = Object.create(person);
person2.name = 'anton';
person2.friends.push('尼古拉斯·赵四')
var person3 = Object.create(person);
person3.name = 'jan';
person3.friends.push('孙五');
console.log(person.friends) //张一,李二,王三,尼古拉斯·赵四,孙五

如果第二个参数就是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function object(o){
function F(){}
F.prototype = o;
return new F();
}
var person = {
name : 'nikolaus',
friends : ['张一','李二','王三']
};
var person2 = Object.create(person,{
name : {
value : 'anton'
}
});

原型式继承应用场景是:如果已经有了一个对象,想要创建一个新的类似的对象,但又不想创建一个新的自定义类型时,则可以用原型式继承。不过上面的例子也可以看到,对于引用类型的值,仍然是被多个实例共享的,这也是原型链继承中的问题。

寄生式继承

寄生式继承一定程度上是依赖于原型式继承的。基本思路与寄生构造函数和工厂模式类型,即创建一个只是用来封装的函数,在这个函数内部以某种方式来增强对象,然后返回增强后的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function object(o){
function F(){}
F.prototype = o;
return new F();
}
//寄生
function createAnother = function(original){
//调用函数放回一个对象
var clone = object(original);
//以某种方式增强对象
clone.sayHi = function(){
console.log('Hi!');
}
//返回增强后的对象
return clone;
}

上面的实例代码中基于原型式继承返回了一个对象(当然这并不是必须的,所有能返回对象的函数都可以),然后用某种方式增强了这个对象(为这个对象添加了一个方法),最后返回增强后的对象。

1
2
3
4
5
6
7
var person = {
name : 'nikolaus',
friends : ['张一','李二','王三']
};
var person2 = createAnother(person);
person2.sayHi(); //Hi!

寄生式继承的应用场景貌似也不多啊,它在一定程度上是对原型式继承的增强,一般在不想创建一个新的自定义类型的时候可以使用。但是它有着借用构造函数模式所存在的问题,方法没有了复用性。

寄生组合式继承

组合继承虽然是Javascript中最常用的继承模式,但是这种模式也有问题存在:无论什么时候,都会调用两次构造函数,一次是在创建子类型原型的时候,另一次是在子类型构造函数内部。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function SuperType(name) {
this.name = name;
this.colors = ['red','green','blue'];
}
SuperType.prototype.sayName = function(){
console.log(this.name);
}
function SubType(name,age){
SuperType.call(this,name); //第二次调用超类型构造函数
this.age = age;
}
SubType.prototype = new SuperType(); //第一次调用超类型构造函数
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function(){
console.log(this.age);
}

第一次调用超类型构造函数是,为子类型的原型添加了两个原型属性namecolors。第二次在子类型构造函数内部调用超类型构造函数时,又在子类型的实例上添加了两个实例属性namecolors。这两组属性的值是一样的,只不过实例属性会屏蔽原型属性

要解决这种问题,就可以用寄生组合式继承。其基本思路是:不必为指定子类型的原型而调用一次超类型构造函数,我们所需要的无非就是超类型原型的一个副本而已。寄生组合式继承的基本模式如下:

1
2
3
4
5
6
7
8
9
10
function inheritPrototype(superType,subType){
//借用object()函数拿到超类型原型的副本,这里并没有调用超类型构造函数
var prototype = object(superType.prototype);
//把超类型原型的副本赋给子类型的原型
subType.prototype = prototype;
//重写子类型原型的构造器属性
subType.prototype.constructor = subType;
}

把上面的例子用寄生组合式继承重写一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function SuperType(name) {
this.name = name;
this.colors = ['red','green','blue'];
}
SuperType.prototype.sayName = function(){
console.log(this.name);
}
function SubType(name,age){
SuperType.call(this,name);
this.age = age;
}
inheritPrototype(SuperType,SubType); //替换原有的定义子类型原型的语句
SubType.prototype.sayAge = function(){
console.log(this.age);
}

这样重写后,超类型构造函数只调用了一次,子类型的原型上也没有了多余的namecolors属性,但原型链仍然保持不变。开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式。

笔者注

博客内文章若不另外注明,均为原创。转载请注明出处,谢谢。

博客内容主要是对新知识的归纳、总结。记录博客的目的也是为了方便自己日后的巩固复习。欢迎大家提出文章中理解错误的地方,不胜感激。若文章对你有帮助,将是我的荣幸。