JavaScript面向对象(3)——原型与基于构造函数的继承模式(原型链)

       很多同学甚至在相当长的时间里,都忽略了JavaScript也可以进行面向对象编程这个事实。一方面是因为,在入门阶段我们所实现的各种页面交互功能,都非常顺理成章地使用过程式程序设计解决了,我们只需要写一些方法,然后将事件绑定在页面中的DOM节点上便可以完成。尤其像我这类一开始C++这类语言没好好学,第一门主力语言就是JavaScript的同学来说,过程化程序设计的思维似乎更加根深蒂固。另一方面,就算是对于Java、C++等语言的程序员来说,JavaScript的面向对象也是一个异类:JavaScript中没有class的概念(在ES5及之前版本中没有,ES6会单独介绍),其基于prototype的继承模式也与传统面向对象语言不同,而JavaScript的弱类型特性更会令这里面的很多人抓狂。当然,在熟悉了之后,这种灵活性也会带来很多好处。总之,封装、继承、多态、聚合这些面向对象的基本特性JavaScript都有其自己的实现方式,这些知识的学习是从入门级JS程序员进阶的必经之路。

JavaScript面向对象(1)——谈谈对象

JavaScript面向对象(2)——谈谈函数(函数、对象、闭包)

JavaScript面向对象(3)——原型与基于构造函数的继承模式(原型链)

JavaScript面向对象(4)——最佳继承模式(深拷贝、多重继承、构造器借用、组合寄生式继承)



一、简述面向对象编程的四个基本特性 

      封装:面向对象程序设计的主要优点就是,对象可以对自己属性和方法进行访问控制,只公开其他对象与之交互所必须的接口,只提供数据交换而隐藏实现细节。比如调用一个解方程的方法,你只需要正确的解,而不需要知道它到底使用了哪种方法求解。这将会带来很多好处,比如在调试过程中发现某个值出现了问题,那么你可以直接根据访问权限得知是哪里修改了这个量,而在过程式编程中,就只能逐步调试来观察哪一步出现问题了。这种将属性和行为包含进对象并进行访问权限控制的特性就是封装。公共属性和方法则就是接口。 在JavaScript中,访问控制的实现由闭包来完成,之前介绍过:JavaScript闭包

      继承:继承的实质上是一种代码重用,可以说算是面向对象程序设计中最强大的一个特性了。继承运行通过组织类并抽取各个类的共性来定义类之间的关系,实现了更好的整体设计。继承关系也使得类的设计更容易抽象自现实世界。比如有鸡和狗两个类,它们作为动物的共性可以通过继承动物类的方法直接继承,而它们各自的特性则在自身类中定义。这里假设A类继承自B类,则称B是A的超类,A是B的子类。 

      多态:多态和继承是息息相关的。在某些类的设计中,子类从父类继承了接口,然而由于每个子类都是单独的实体,它们可能需要对同一个消息做出不同响应,多态便实现了这一点。它是指子类继承父类的方法时,自身提供了该方法的一个实现并覆盖自父类继承的实现。那么如此一来对同一个方法,各个子类都可以提供各自的实现方法。

在JavaScript中,在本层提供同名方法,即可覆盖继承链上的父级方法,实现多态。

      聚合:聚合是继承之外另一个由类构造类的方法。在解释聚合概念时,最经典的举例便是汽车了。汽车是由各个组件组装而成的,比如动力系统、装饰系统、控制系统构成了汽车,而发动机、油箱、传动装置。。。。。。又构成了动力系统,以此类推。这里要是按照继承关系去分析,就显然不恰当。JavaScript中聚合的实现则是由下一篇中拷贝与深拷贝这里的知识完成。


二、承担类的作用:构造函数与原型对象

              在ES5及之前的版本中,JavaScript中是没有class的概念的(ES6中添加的class特性,其实也只是一个语法糖而已)。而构造函数和原型对象就承担了类似于类的作用:作为对象的范本以方便构造大量性质相同的对象。关于构造函数的性质,请回顾之前的章节:JavaScript构造函数

//使用构造函数和原型对象构造一个类
function Range(from, to){
 this.from = from;
 this.to = to;
}
Range.prototype = {
 includes: function(x){
  return this.from <= x && this.to >= x;
 },
 foreach: function(f){
  for(var x = Math.ceil(this.from);x <= this.to;x++) f(x);
 },
 toString: function(){
  return "(" + this.from + "..."+ this.to + ")";
 } 
}
var obj = new Range(1,10);
obj.includes(5); // true
obj.foreach(function(x){console.log(x + 1);}); // 2,3,4,5,6,7,8,9,10,11
obj.toString(); // "(1...10)"

             另外,还可以使用工厂函数来构造一个类,但这种方法并不常用:

//使用工厂函数来构造一个类
function range(from, to){
	var r = inherit(range.methods);
	r.from = from;
	r.to = to;
	return r;
}
range.methods = {
	includes: function(x){
		return this.from <= x && this.to >= x;
	},
	foreach: function(f){
		for(var x = Math.ceil(this.from);x <= this.to;x++) f(x);
	},
	toString: function(){
		return "(" + this.from + "..."+ this.to + ")";
	} 
}
var obj = range(1,10);
obj.includes(5); // true
obj.foreach(function(x){console.log(x + 1);}); // 2,3,4,5,6,7,8,9,10,11
obj.toString(); // "(1...10)"*/

             大家可以自己体会一下这两个方法的差异之处。另外,这两个demo中都显式指定了构造函数的原型对象,其实这不是必须的。在JavaScript中每个函数都自动拥有一个prototype属性,其值为一个对象,包含唯一一个不可枚举的constructor属性,这个属性中存着该函数。上面显式指定的原型对象中并不包含constructor属性,此时可以通过修改该对象来手动添加该引用;或者不显式指定原型对象,直接向默认拥有的原型对象中添加类的行为。这里便体现了JavaScript基于原型的继承机制中另一个特点:动态性。在定义完类,实例化对象之后,依然可以随时修改原型对象以增减特性,所有的实例对象都会直接动态继承下来这些修改。比如:

//向自定义创建的原型对象中添加constructor引用
Range.prototype.constructor = Range;
//修改默认生成的原型对象以添加类的行为
function Range(from ,to){
	this.from = from;
	this.to = to;
}
Range.prototype.includes = function(x){
	return this.from <= x && this.to >= x;
}
Range.prototype.foreach = function (f) {
	for(var x = Math.ceil(this.from);x <= this.to;x++) f(x);
}
Range.prototype.toString = function(){
	return "(" + this.from + "..."+ this.to + ")";
}

             其中,原型对象是类的唯一标识,构造函数不能作为类的标识,两个不同的构造函数,可以指向同一个原型对象。尽管不能作为类的标识,不过构造函数一般情况下会被用作类名。实际上这也并不矛盾,通过下面两种检查对象的类的方法就可以知道:

             有两种方法可以检查对象的类:instanceof运算符与constructor属性:

//检查对象的类
function Person(name){
    this.name = name;
    this.getName = function(){
        console.log(this.name);
    }
}
var jeff = new Person("jeff");//Person {name: "jeff", getName: function}

console.log(jeff instanceof Person);// true
console.log(jeff.constructor === Person);// true

               instanceof运算符并不会判断该对象是不是由该构造函数创建,而是会判断该对象是否继承自构造函数的原型对象。

               constructor属性是每个对象在创建时都自动拥有的,其值为创建该对象的构造函数的引用。通过字面量形式或者Object()构造函数创建的对象,其constructor属性指向Object,其余通过自定义构造函数创建的对象,其constructor属性指向创建它的构造函数。

function Person(name){
    this.name = name;
    this.getName = function(){
        console.log(this.name);
    }
}
var jeff1 = new Person("jeff");//Person {name: "jeff", getName: function}
var jeff2 = {
    name: "jeff",
    getName: function(){
        console.log(this.name);
    }
}
console.log(jeff1.constructor);// function Person(name){ ...}
console.log(jeff2.constructor);// function Object(){ [native code] }

               由于constructor属性可以被修改,有时可能不准确,一般情况下更推荐使用instanceof运算符检查对象的类。


               综上,构造函数、原型对象与实例之间有着如下的三角关系:构造函数的prototype指向原型对象,原型对象的constructor属性指向构造函数;构造函数生成实例对象,实例对象的__proto__属性指向原型对象,并从原型对象继承属性:


        总结下,在传统的Java式面向对象编程语言中,有这四个最基本的概念:类字段、类方法、实例字段、实例方法。在JavaScript中,这四种类成员类型都有对应的体现。实例字段和实例方法自然不用多说,直接修改实例对象,所添加的值属性和方法属性就对应着实例字段和实例方法了。而类字段和类方法则是在构造函数和原型对象中所定义的方法与字段,一般来说,会将方法和静态属性定义在原型对象中,而动态属性定义在构造函数中。另外,若字段为私有字段,则可以通过闭包来实现。


三、基于构造函数的继承方式

        1、原型链继承:这是ECMA默认的继承方式:

//基于原型链的继承
//Biont类
function Biont(){}
Biont.prototype.className = 'Biont';
Biont.prototype.getClass = function(){ return this.className };

//Animal类
function Animal(){}
Animal.prototype = new Biont();
Animal.prototype.constructor = Animal;
Animal.prototype.className = 'Animal';
Animal.prototype.getClass = function(){
	var res = [],
	temp = this.__proto__;
	while(temp.className != 'Biont'){
		res.push(temp.className);
		temp = temp.__proto__;
	}
	res.push('Biont');
	console.log(res.reverse().join('->'));
}

//Bird类
function Bird(){}
Bird.prototype = new Animal();
Bird.prototype.constructor = Bird;
Bird.prototype.className = 'Bird';

//测试
var a = new Bird();
console.log(a.name);  // "Bird"
a.getClass();         // Biont->Animal->Bird
a instanceof Bird;    // true
a instanceof Animal;  // true
a instanceof Biont;   // true

        在Bird类中,我们并没有定义getClass方法,它继承了父类的方法。在这个例子中,

Animal.prototype = new Biont();
Animal.prototype.constructor = Animal;
是最关键的部分,也就是建立继承关系的部分了,这里用父类的一个实例对象去充当子类的原型对象,通过该实体的属性完成相关的继承工作。在每个对象中,都默认拥有__proto__属性,该属性指向实例对象的原型对象。通过该属性将层层原型对象相连,最后链尾则是Object对象(Js中最高级父对象,所有对象都继承自它),这便是原型链了。另外由于这种继承方式是通过父类的实体实现的,在继承完成之后,对父类构造函数做任何修改甚至是删除都不会影响子类。

        在红色部分还进行了一项工作,将原型对象的constructor属性进行了修复,使其为正确的值。

        下图是基于上面demo的图示

 

        2、通过传递原型对象的引用实现继承

//基于原型对象引用传递的继承
//Biont类
function Biont(){}
Biont.prototype.className = 'Biont';
Biont.prototype.getClass = function(){ return this.className };

//Animal类
function Animal(){}
Animal.prototype = Biont.prototype;
Animal.prototype.constructor = Animal;
Animal.prototype.className = 'Animal';

//Bird类
function Bird(){}
Bird.prototype = Animal.prototype;
Bird.prototype.constructor = Bird;
Bird.prototype.className = 'Bird';

//测试
var a = new Bird();
a.getClass(); 		  // "Bird"
a instanceof Bird;    // true
a instanceof Animal;  // true
a instanceof Biont;   // true
var b = new Animal();
b.getClass();         //  "Bird"
        在原型链继承中,调用方法经常要顺着原型链向上搜索好几层。为了减少这种方法搜索的次数,修改程序后有了这样的继承方法:通过直接传递原型对象的引用来实现继承。这样我们便不用通过父类的实例对象来实现继承。但是这样做有明显的硬伤,因为直接传递了原型对象的引用,一旦子对象对原型对象进行了修改,会导致父对象也被改变。这一点在测试中Animal对象b调用getClass方法却得到了‘Bird’的返回值中有所体现。这种方式在效率上会有最好的表现,但是因为这个硬伤,使用环境非常有限。为了解决这个问题,可以在方法中引入临时构造器:

//基于原型对象引用传递与临时构造器的继承
//Biont类
function Biont(){}
Biont.prototype.className = 'Biont';
Biont.prototype.getClass = function(){ return this.className };

//Animal类
function Animal(){}
var F = function(){};
F.prototype = Biont.prototype;
Animal.prototype = new F();
Animal.prototype.constructor = Animal;
Animal.prototype.className = 'Animal';

//Bird类
function Bird(){}
var F = function(){};
F.prototype = Animal.prototype;
Bird.prototype = new F();
Bird.prototype.constructor = Bird;
Bird.prototype.className = 'Bird';

//测试
var a = new Bird();
a.getClass(); 		  // "Bird"
a instanceof Bird;    // true
a instanceof Animal;  // true
a instanceof Biont;   // true
var b = new Animal();
b.getClass();         //  "Animal"
        这里我们引入了空的构造函数F(),使其充当中介完成继承。之前的负作用也得到了消除,并得到了与原型链法一直的对象链。这种方法看起来和原型链法是很相似的,它们的区别在于,这种方法只会继承来自原型对象的属性,对于构造函数添加进对象的属性则不会被继承。


        对继承方法,我们可以进行封装:

//对基于原型对象引用与临时构造器法进行封装
function extend(Child, Parent){
	var F = function(){};
	F.prototype = Parent.prototype;
	Child.prototype = new F();
	Child.prototype.constructor = Child;
	Child.father = Parent.prototype;
}
        这里还给子类添加了father属性,使得其可以更方便的访问父类。(通过__proto__属性的话会多一层访问)

        3、通过属性拷贝实现继承

//通过属性拷贝实现继承
function extend(Child, Parent){
	var p = Parent.prototype,
		c = Child.prototype;
	for (var i in p) c[i] = p[i];
	c.father = p;
}
function Biont(){};
Biont.prototype.className = 'Biont';
Biont.prototype.getClass = function(){ return this.className };

function Animal(){};
extend(Animal, Biont);
Animal.prototype.className = 'Animal';

function Bird(){};
extend(Bird, Animal);
Bird.prototype.className = 'Bird';

//测试
var a = new Bird();
a.getClass();        // 'Bird'
a instanceof Bird;   // true
a instanceof Animal; // false
a instanceof Biont;  // false
Biont.prototype.getClass === Bird.prototype.getClass; // true

        这个方法是通过直接将父类原型对象的全部属性遍历并复制给子类来实现继承的。这种方法较为独特。一方面,它可以提升效率(所有继承下来的属性都在该类的原型对象中了,不用沿着继承链向上查找),但全部复制也意味着实现的过程效率较低。另一个需要注意的点是,这种方法只适用与值为原始值属性的继承。因为在JavaScript中,只有原始值才可以复制,对象(函数、数组)是不能直接复制的。在这种情况下,复制下来的只是对象的引用。这样带来的后果是,一旦在子类中修改这类属性,父类的相应属性也会变动。 所以这种方法只适合于只包含基本数据类型的对象继承,其他情况下并无优势。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值