探寻完美 之 JavaScript继承

本文并不想探讨JavaScript的面向对象特性(如果有兴趣,可参看我的《领悟面向对象JavaScript》),也不会涉及全部的面向对象概念,只是试图寻找一个还未被任何人发现的“宝藏”,即完美的JavaScript继承的实现方法。

在面向对象语言中,继承是指这样一种能力:它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。

在JavaScript中,按照实现方式,继承被分为如下五种(也有人说是七种,即组合继承细分为原型组合继承、寄生组合继承,另外再加上一个只能继承属性而无法继承方法的借用构造函数继承):
▪ 拷贝继承
▪ 原型继承
▪ 调用继承
▪ 寄生(parasitic)继承
▪ 组合(combination)继承

拷贝继承
通常来说,拷贝继承并不被大家所接受为“真正的继承”,因为这种继承的原理是拷贝简单对象的属性。jQuery框架中提供的就是拷贝继承,主要用于插件编程中的元数据部分,采用元数据的jQuery插件的重要原因是它可以让你使用定义之外的标签来覆写你的插件属性设置,外部只提供必要的而非全部的属性设置,然后通过继承让外部属性与插件默认属性“结合”,提供最大的设置灵活性。如:

var o = $.meta ? $.extend({}, opts, $this.data()) : opts;


jQuery框架提供的拷贝功能非常强大,支持简单对象的深拷贝与浅拷贝。但是,它还不是类级的继承,因为它的拷贝只作用于简单对象(如{name:'zcj',age:28})。

原型继承
在JavaScript中最容易理解的一个继承实现方式,主要是因为它发挥了原型链的概念,即利用原型让一个引用类型继承另一个应用类型的属性和方法。继承的实现比较简单,如父类为BaseClass,子类为SubClass时,继承实现如下:

SubClass.prototype = new BaseClass();

由示例代码可见,必须要有一个对象的实例以便让它作为另一个对象的基础。原型继承由于原型链的原因,子类的原型指向了父类的原型,所以子类实例的constructor就指向了父类,而子类的实例类型(instanceof)既是父类又是子类。抛开上述的小缺陷,危险的原型继承还会给我们带来更加恐怖的问题,如原型链断裂与引用类型的原型继承。

当原型继承发生后,如果希望使用子类的原型添加新的方法时,会发生原型链断裂。如:

SubClass.prototype = new BaseClass();
//使用原型添加新的方法, 上一行的继承无效, 原型链断裂
SubClass.prototype = {
    getValue: function() {
        //to do
    }
};

由于原型仅仅是以对象为模板构建副本,所以它是一种引用拷贝,包含引用类型值的原型属性会被所有的实例共享,那么一旦改变某个原型上引用类型的属性的值,将会彻底影响到这个类型创建的每一个实例。这种情况我在《领悟面向对象JavaScript》的原型继承章节有过详细的讨论,这里不再累述。

PS:2006年,道格拉斯•克罗克福德在文章《Prototypal Inheritance in JavaScript》中提出了一种不使用严格意义构造函数的原型继承法,虽然是优秀的,但是仍然存在应用类型属性值等缺陷。

调用继承
调用继承也是一种实现继承的简单方案,其实质是在子类函数体中调用父类的构造器方法,并使构造器方法执行于上下文上。也就是说,我们在父类构造器方法上通过this上下文所操作的内容实际上都是在操作子类实例化对象的内容。如:

function SubClass() {
 BaseClass.call(this); //当父类构造函数带有参数时, 一般使用apply替代call, 以便传参
}


在子类SubClass进行定义的时候,调用父类BaseClass的call方法并将子类的this作为参数传入,使父类的方法在子类的上下文上执行,这样的结果就是,父类中所有的通过this方式定义的公有成员都会被子类继承。

这种继承方式被很多人所接受,主要是因为它不存在原型继承的缺陷与问题,而且满足继承的所有特性,也另继承的终极目的--多态(polymorphisn)体现了出来。从各个角度来看,调用继承似乎就是最完美的方案了,那么这个“宝藏”究竟是不是我们的“圣杯”还是“杯具”呢?

调用继承的“圣杯”场景:

var BaseClass = function() {
    this.className = "Base";
    this.showName = function() {
        alert(this.className);
    };
};
var SubClass = function() {
    BaseClass.call(this);
    this.classDesc = "SubClass";
    this.showDesc = function() {
        alert(this.classDesc);
    };
    this.showName = function() {
        alert(this.className);
    };
};


调用继承的“杯具”场景:

var BaseClass = function() {
    this.className = "Base";
};
BaseClass.prototype = {
    showName: function() {
        alert(this.className);
    }
};
var SubClass = function() {
    BaseClass.call(this);
    this.classDesc = "SubClass";
};
SubClass.prototype = {
    showDesc: function() {
        alert(this.classDesc);
    },
    showName: function() {
        alert(this.className);
    }
};


其实,这两段代码希望完成的功能是一模一样的,只不过写法不同。很不巧的是,具有JavaScript经验的开发者都会选择“杯具”场景中的方式定义类,究其原因就是性能。

在高级面向对象语言中,类的存储是很优美的,对类的每个实例来说,其不同的部分就是用于描述其状态的属性,而它们的行为(即方法)都是相同的,所以在运行期只会为每个类实例的属性分配存储空间,方法则是每个类实例所共享的,是不占用存储空间的。

在JavaScript中,方法(Function)也是变量的一种,即使它和对象、数组都是按引用传递的,也是要被分配存储空间的。在“圣杯”场景中,所有Manager的实例都会被分配用于存储方法showPerson与showManager的空间,这绝对是一种巨额的内存浪费。而在“杯具”场景中,方法是在原型链上进行扩展的,这样就保证了类的所有实例都在原型链上共享了相同的方法,而不会为每个实例都分配存储方法的空间。

调用继承恰恰在正确的场景中出了问题,还真是个杯具啊。

寄生继承
这是一种非常不常见的实现继承的方案,是与原型继承紧密相关的一种方式,其本质与寄生构造函数和工厂模式很类似,即创建一个仅用于封装继承过程的函数,并在这个函数的内部以某种方式增强对象,最后返回这个函数。

由于不常见,所以我姑且把实例代码也省略了。因为其本质与原型继承紧密相关,所以也存在了对象方法无法复用的性能问题。

组合继承
所谓组合继承,其实就是综合使用了上述继承方式,最终达到性能、继承、多态均满意的结果的实现方案。目前已知的组合继承为原型调用继承和寄生调用继承两种,其实这两种方案的本质还是综合了原型继承与调用继承。

示例如下:

var BaseClass = function() {
    this.className = "Base";
};
BaseClass.prototype = {
    showName: function() {
        alert(this.className);
    }
};
var SubClass = function() {
    BaseClass.call(this); //第二次调用BaseClass()
    this.classDesc = "SubClass";
};
SubClass.prototype = {
    showDesc: function() {
        alert(this.classDesc);
    }
};
SubClass.prototype = new BaseClass(); //第一次调用BaseClass()


组合继承通过两次调用父类的构造函数,最终实现了一种新型的继承。在第一次调用父类的构造函数时,子类的原型得到了父类的实例属性(className)。当调用SubClass的构造函数时,将再次调用父类的构造函数,这样就在新的对象上创建了实例属性,于是这个实例属性就屏蔽了原型中的同名属性。

目前,基本上认为组合继承是JavaScript中实现继承的最完美方案。但是,个人认为这种说法还是片面了一些,因为这里存在不必要的性能消耗。从示例中可以看出,为了实现继承,出现了两次调用父类构造函数的情况。当需要很多子类实例的情况出现时,这种继承并不高效,因为它在子类的原型上创建了不必要且多余的属性。另外,因为重写原型,它还失去了默认的constructor属性。

在雅虎的YUI框架的YAHOO.lang.extend()中,实现了寄生调用继承。如果将上述示例改造为寄生调用继承,则代码如下:

function object(o) {
    function F(){}
    F.prototype = o;
    return new F();
}
function inheritPrototype(subC, superC) {
    var prototype = object(superC.prototype); //创建对象
    prototype.constructor = subC; //增强对象
    subC.prototype = prototype; //指定对象
}
var BaseClass = function() {
    this.className = "Base";
};
BaseClass.prototype = {
    showName: function() {
        alert(this.className);
    }
};
var SubClass = function() {
    BaseClass.call(this);
    this.classDesc = "SubClass";
};
inheritPrototype(SubClass, BaseClass);
SubClass.prototype.showDesc = function() {
    alert(this.classDesc);
};


寄生组合模式主要靠inheritPrototype函数实现,它首先创建了父类原型的一个副本,然后为这个副本添加constructor属性,弥补了因重写原型而失去的默认constructor,最后将副本赋值给子类的原型。以这样的方式实现了一个高效的且保持原型链不变的理想继承模式。

但是,个人认为将寄生组合继承称为完美的继承还是有一点点的牵强,个人的理由是它不够优雅!从艺术的角度来看,完美的事务无一不是优雅的。在寄生组合继承中,在子类的原型链上添加方法的代码书写方式破坏了优雅,如示例代码中子类SubClass的showDesc方法的写法。可以预见的是,如果实际开发中我们要在子类的原型上添加N(代表很多)个方法,那么“子类.prototype”就要书写N次。可能有的朋友会说:反正代码都一样,复制粘贴就好了。OK,复制粘贴的确可以,因为毕竟它并不复杂,但是这样也无形中使js文件的尺寸增长了,对站点的性能将产生不利的影响。

那么,如果我们仍然使用寄生组合模式,并优化在子类原型上添加方法的书写方式,这样是否行得通呢?请看改造代码:

function object(o) {
    function F(){}
    F.prototype = o;
    return new F();
}
function inheritPrototype(subC, superC) {
    var prototype = object(superC.prototype); //创建对象
    prototype.constructor = subC; //增强对象
    subC.prototype = prototype; //指定对象
}
var BaseClass = function() {
    this.className = "Base";
};
BaseClass.prototype = {
    showName: function() {
        alert(this.className);
    }
};
var SubClass = function() {
    BaseClass.call(this);
    this.classDesc = "SubClass";
};
//位置一
SubClass.prototype = {
    showDesc: function() {
        alert(this.classDesc);
    }
};
inheritPrototype(SubClass, BaseClass); //位置二


可以看到,改写之后我将实现寄生组合继承的核心方法(inheritPrototype)的调用放到了位置二,以保证让继承可以正确实现。但这时会出现一个问题,我们会发现子类自身定义的方法不见了,这是因为在inheritPrototype中父类的原型重写了子类的原型导致的。解决这个问题的、理论上的方法就是将inheritPrototype方法的调用放到位置一上,即在子类原型上添加方法的动作发生之前,父类的原型重写子类原型的动作就已经完成了。但是这样做继承是不会被实现的,原因我们在原型继承章节中提到过,即原型链断裂。我之前在寄生继承章节中提到过,寄生继承是与原型继承紧密相关的,简单的说寄生继承可以理解为原型继承的一种“包装”,本质上并无变化。

完美继承
这里所谓的完美继承,其实也是应用了组合继承这个概念。由上文可知,寄生组合继承已经非常贴近于完美继承了,只是因为inheritPrototype的实现方式导致了一些额外的问题出现。所以,我的主要思路集中在对inheritPrototype的改造上,改造的实质就是抛弃使用父类原型副本重写子类原型的做法。将这个思路延伸,如果要实现在子类原型链上具有父类原型链上的属性(即方法,原因可参考调用继承章节中的“杯具”场景说明),那么一个理想的解决方法就是拷贝。所以,我为这种继承模式起名为“拷贝组合继承(copy combination inherit)”,实现方式如下:

function extend(subC, baseC) {
    for (var ptototypeName in baseC.prototype) {
        if (typeof(subC.prototype[ptototypeName]) === 'undefined') {
            subC.prototype[ptototypeName] = baseC.prototype[ptototypeName]; //原型属性的拷贝
        }
    }
    subC.prototype.constructor = subC; //增强
}
var BaseClass = function() {
    this.className = "Base";
};
BaseClass.prototype = {
    showName: function() {
        alert(this.className);
    }
};
var SubClass = function() {
    BaseClass.call(this); //只执行一次父类构造函数
    this.classDesc = "SubClass";
};
SubClass.prototype = {
    showDesc: function() {
        alert(this.classDesc);
    }
};
extend(SubClass, BaseClass); //不破坏子类原型链的位置二


这种方式不仅兼顾了效率、继承实现、多态,而且不创建父类原型副本,即不发生原型的重写,避免了寄生组合继承存在的缺陷。另外,拷贝组合继承显而易见的对多重继承的实现提供了更优的支持。

posted on 2011-04-21 11:19  村长赵大宝  阅读(938)  评论(1编辑  收藏  举报

导航