类和原型、构造函数及扩充

每个JavaScript对象都是一个属性集合,相互之间没有任何联系。在JavaScript中也可以定义对象的类,让每个对象都共享某些属性,这种“共享”的特性是非常有用的。类的成员或实例都包含一些属性,用以存放或定义它们的状态,其中有些属性定义了它们的行为(通常称为方法)。这些行为通常是由类定义的,而且为所有实例所共享。例如,假设有一个名为Complex的类用来表示复数,同时还定义了一些复数运算。一个Complex实例应当包含复数的实部和虚部(状态),同样Complex类还会定义复数的加法和乘法操作(行为)。

在JavaScript中,类的实现是基于其原型继承机制的。如果两个实例都从同一个原型对象上继承了属性,我们说它们是同一个类的实例。如果两个对象继承自同一个原型,往往意味着(但不是绝对)它们是由同一个构造函数创建并初始化的。定义类是模块开发和重用代码的有效方式之一。
  • 类和原型

在JavaScript中,类的所有实例对象都从同一个原型对象上继承属性。因此,原型对象是类的核心。下面例子给一个表示“值的范围”的类定义了原型对象,还定义了一个“工厂”函数用以创建并初始化类的实例。

一个简单的JavaScript类
//range.js:实现一个能表示值的范围的类
//这个工厂方法返回一个新的"范围对象"

function range(from,to){//使用inherit()函数来创建对象,这个对象继承自在下面定义的原型对象
//原型对象作为函数的一个属性存储,并定义所有"范围对象"所共享的方法(行为)
    var r=inherit(range.methods);//存储新的"范围对象"的起始位置和结束位置(状态)
//这两个属性是不可继承的,每个对象都拥有唯一的属性
    r.from=from;
    r.to=to;//返回这个新创建的对象
    return r;
}

//原型对象定义方法,这些方法为每个范围对象所继承
range.methods={//如果x在范围内,则返回true;否则返回false
//这个方法可以比较数字范围,也可以比较字符串和日期范围
    includes:function(x){
        return this.from<=x&&x<=this.to;},//对于范围内的每个整数都调用一次f
//这个方法只可用做数字范围
    foreach:function(f){
        for(var x=Math.ceil(this.from);x<=this.to;x++)f(x);
    },//返回表示这个范围的字符串
    toString:function(){return"("+this.from+"..."+this.to+")";}
};//这里是使用"范围对象"的一些例子

var r=range(1,3);//创建一个范围对象
r.includes(2);//=>true:2在这个范围内
r.foreach(console.log);//输出1 2 3
console.log(r);//输出(1...3)
在例中有一些代码是没有用的。这段代码定义了一个工厂方法range(),用来创建新的范围对象。我们注意到,这里给range()函数定义了一个属性range.methods,用以快捷地存放定义类的原型对象。把原型对象挂在函数上没什么大不了,但也不是惯用做法。再者,注意range()函数给每个范围对象都定义了from和to属性,用以定义范围的起始位置和结束位置,这两个属性是非共享的,当然也是不可继承的。最后,注意在range.methods中定义的那些可共享、可继承的方法都用到了from和to属性,而且使用了this关键字,为了指代它们,二者使用this关键字来指代调用这个方法的对象。任何类的方法都可以通过this的这种基本用法来读取对象的属性。
  • 类和构造函数

上例展示了在JavaScript中定义类的其中一种方法。但这种方法并不常用,毕竟它没有定义构造函数,构造函数是用来初始化新创建的对象的。使用new调用构造函数会自动创建一个新对象,因此构造函数本身只需初始化这个新对象的状态即可。调用构造函数的一个重要特征是,构造函数的prototye属性被用做新对象的原型。这意味着通过同一个构造函数创建的所有对象都继承自一个相同的对象,因此它们都是同一个类的成员。对之前的例子的“范围类”做了修改,使用构造函数代替工厂函数:

使用构造函数来定义“范围类”
//range2.js:表示值的范围的类的另一种实现
//这是一个构造函数,用以初始化新创建的"范围对象"
//注意,这里并没有创建并返回一个对象,仅仅是初始化

function Range(from,to){//存储"范围对象"的起始位置和结束位置(状态)
//这两个属性是不可继承的,每个对象都拥有唯一的属性
    this.from=from;
    this.to=to;
}

//所有的"范围对象"都继承自这个对象
//注意,属性的名字必须是"prototype"
Range.prototype={//如果x在范围内,则返回true;否则返回false
//这个方法可以比较数字范围,也可以比较字符串和日期范围
    includes:function(x){return this.from<=x&&x<=this.to;},//对于范围内的每个整数都调用一次f
//这个方法只可用于数字范围
    foreach:function(f){
        for(var x=Math.ceil(this.from);x<=this.to;x++)f(x);
    },//返回表示这个范围的字符串
    toString:function(){return"("+this.from+"..."+this.to+")";}
};//这里是使用"范围对象"的一些例子

var r=range(1,3);//创建一个范围对象
r.includes(2);//=>true:2在这个范围内
r.foreach(console.log);//输出1 2 3
console.log(r);//输出(1...3)
将两个例子做一个仔细的对比后,可以发现两种定义类的技术的差别。首先,注意当工厂函数range()转化为构造函数时被重命名为Range()。这里遵循了一个常见的编程约定:从某种意义上讲,定义构造函数既是定义类,并且类名首字母要大写。而普通的函数和方法都是首字母小写。

再者,注意Range()构造函数是通过new关键字调用的(在示例代码的末尾),而range()工厂函数则不必使用new。例前者通过调用普通函数来创建新对象,后者则使用构造函数调用来创建新对象。由于Range()构造函数是通过new关键字调用的,因此不必调用inherit()或其他什么逻辑来创建新对象。在调用构造函数之前就已经创建了新对象,通过this关键字可以获取这个新对象。Range()构造函数只不过是初始化this而已。构造函数甚至不必返回这个新创建的对象,构造函数会自动创建对象,然后将构造函数作为这个对象的方法来调用一次,最后返回这个新对象。事实上,构造函数的命名规则(首字母大写)和普通函数是如此不同还有另外一个原因,构造函数调用和普通函数调用是不尽相同的。构造函数就是用来“构造新对象”的,它必须通过关键字new调用,如果将构造函数用做普通函数的话,往往不会正常工作。开发者可以通过命名约定来(构造函数首字母大写,普通方法首字母小写)判断是否应当在函数之前冠以关键字mew。

两者之间还有一个非常重要的区别,就是原型对象的命名。在第一段示例代码中的原型是range.methods。这种命名方式很方便同时具有很好的语义,但又过于随意。在第二段示例代码中的原型是Range.prototype,这是一个强制的命名。对Range()构造函数的调用会自动使用Range.prototype作为新Range对象的原型。

最后,需要注意这两者中两种类定义方式的相同之处,两者的范围方法定义和调用方式是完全一样的。

构造函数和类的标识
上文提到,原型对象是类的唯一标识:当且仅当两个对象继承自同一个原型对象时,它们才是属于同一个类的实例。而初始化对象的状态的构造函数则不能作为类的标识,两个构造函数的prototype属性可能指向同一个原型对象。那么这两个构造函数创建的实例是属于同一个类的。

尽管构造函数不像原型那样基础,但构造函数是类的“外在表现”。很明显的,构造函数的名字通常用做类名。比如,我们说Range()构造函数创建Range对象。然而,更根本地讲,当使用instanceof运算符来检测对象是否属于某个类时会用到构造函数。假设这里有一个对象r,我们想知道r是否是Range对象,我们这样写:

r instanceof Range//如果r继承自Range.prototype,则返回true

实际上instanceof运算符并不会检查r是否是由Range()构造函数初始化而来,而会检查r是否继承自Range.prototype。不过,instanceof的语法则强化了“构造函数是类的公有标识”的概念。在本章的后面还会碰到对instanceof运算符的介绍。

constructor属性

在后面的例子中,将Range.prototype定义为一个新对象,这个对象包含类所需要的方法。其实没有必要新创建一个对象,用单个对象直接量的属性就可以方便地定义原型上的方法。任何JavaScript函数都可以用做构造函数,并且调用构造函数是需要用到一个prototye属性的。因此,每个JavaScript函数(ECMAScript 5中的Function.bind()方法返回的函数除外)都自动拥有一个prototype属性。这个属性的值是一个对象,这个对象包含唯一一个不可枚举属性constructor。constructor属性的值是一个函数对象:
var F=function(){};//这是一个函数对象

var p=F.prototype;//这是F相关联的原型对象

var c=p.constructor;//这是与原型相关联的函数

c===F//=>true:对于任意函数F.prototype.constructor==F
可以看到构造函数的原型中存在预先定义好的constructor属性,这意味着对象通常继承的constructor均指代它们的构造函数。由于构造函数是类的“公共标识”,因此这个constructor属性为对象提供了类。
var o=new F();//创建类F的一个对象

o.constructor===F//=>true,constructor属性指代这个类
如图所示,展示了构造函数和原型对象之间的关系,包括原型到构造函数的反向引用以及构造函数创建的实例。


                                  图 构造函数及其原型和实例
需要注意的是,前者用Range()构造函数作为示例,但实际上,后者中定义的Range类使用它自身的一个新对象重写预定义的Range.prototype对象。这个新定义的原型对象不含有constructor属性。因此Range类的实例也不含有constructor属性。我们可以通过补救措施来修正这个问题,显式给原型添加一个构造函数:
Range.prototype={
    constructor:Range,//显式设置构造函数反向引用
    includes:function(x){return this.from<=x&&x<=this.to;},
    foreach:function(f){
        for(var x=Math.ceil(this.from);x<=this.to;x++)f(x);
    },
    toString:function(){return"("+this.from+"..."+this.to+")";}
};
另一种常见的解决办法是使用预定义的原型对象,预定义的原型对象包含constructor属性,然后依次给原型对象添加方法:
//扩展预定义的Range.prototype对象,而不重写之
//这样就自动创建Range.prototype.constructor属性
Range.prototype.includes=function(x){return this.from<=x&&x<=this.to;};
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+")";
};
  • JavaScript中Java式的类继承

在JavaScript中定义类的步骤可以缩减为一个分三步的算法。第一步,先定义一个构造函数,并设置初始化新对象的实例属性。第二步,给构造函数的prototype对象定义实例的方法。第三步,给构造函数定义类字段和类属性。我们可以将这三个步骤封装进一个简单的defineClass()函数中:
//一个用以定义简单类的函数
function defineClass(constructor,//用以设置实例的属性的函数
                     methods,//实例的方法,复制至原型中
                     statics)//类属性,复制至构造函数中
{
    if(methods)extend(constructor.prototype,methods);
    if(statics)extend(constructor,statics);
    return constructor;
}

//这是Range类的另一个实现
var SimpleRange=
    defineClass(function(f,t){this.f=f;this.t=t;},
        {
            includes:function(x){return this.f<=x&&x<=this.t;},
            toString:function(){return this.f+"..."+this.t;}
        },
        {upto:function(t){return new SimpleRange(0,t);}});
下例中定义类的代码更长一些。这里定义了一个表示复数的类,这段代码展示了如何使用JavaScript来模拟实现Java式的类成员。例中的代码没有用到上面的defineClass()函数,而是“手动”来实现:

例:Complex.js:表示复数的类
/*
*Complex.js:
*这个文件定义了Complex类,用来描述复数
*回忆一下,复数是实数和虚数的和,并且虚数i是-1的平方根
*/
/*

*这个构造函数为它所创建的每个实例定义了实例字段r和i
*这两个字段分别保存复数的实部和虚部
*它们是对象的状态
*/
function Complex(real,imaginary){
    if(isNaN(real)||isNaN(imaginary))//确保两个实参都是数字
        throw new TypeError();//如果不都是数字则抛出错误
    this.r=real;//复数的实部
    this.i=imaginary;//复数的虚部
}/*


*类的实例方法定义为原型对象的函数值属性
*这里定义的方法可以被所有实例继承,并为它们提供共享的行为
*需要注意的是,JavaScript的实例方法必须使用关键字this
*来存取实例的字段
*/


//当前复数对象加上另外一个复数,并返回一个新的计算和值后的复数对象
Complex.prototype.add=function(that){
    return new Complex(this.r+that.r,this.i+that.i);
};//当前复数乘以另外一个复数,并返回一个新的计算乘积之后的复数对象
Complex.prototype.mul=function(that){
    return new Complex(this.r*that.r-this.i*that.i,this.r*that.i+this.i*that.r);
};//计算复数的模,复数的模定义为原点(0,0)到复平面的距离
Complex.prototype.mag=function(){
    return Math.sqrt(this.r*this.r+this.i*this.i);
};//复数的求负运算
Complex.prototype.neg=function(){
    return new Complex(-this.r,-this.i);
};//将复数对象转换为一个字符串
Complex.prototype.toString=function(){
    return"{"+this.r+","+this.i+"}";
};//检测当前复数对象是否和另外一个复数值相等
Complex.prototype.equals=function(that){
    return that!=null&&//必须有定义且不能是null
    that.constructor===Complex&&//并且必须是Complex的实例
    this.r===that.r&&this.i===that.i;//并且必须包含相同的值
};/*
*类字段(比如常量)和类方法直接定义为构造函数的属性
*需要注意的是,类的方法通常不使用关键字this,
*它们只对其参数进行操作
*/


//这里预定义了一些对复数运算有帮助的类字段
//它们的命名全都是大写,用以表明它们是常量
//(在ECMAScript 5中,还能设置这些类字段的属性为只读)
Complex.ZERO=new Complex(0,0);
Complex.ONE=new Complex(1,0);
Complex.I=new Complex(0,1);//这个类方法将由实例对象的toString方法返回的字符串格式解析为一个Complex对象

//或者抛出一个类型错误异常
Complex.parse=function(s){
    try{//假设解析成功
        var m=Complex._format.exec(s);//利用正则表达式进行匹配
        return new Complex(parseFloat(m[1]),parseFloat(m[2]));
    }catch(x){//如果解析失败则抛出异常
        throw new TypeError("Can't parse'"+s+"'as a complex number.");
    }
};//定义类的"私有"字段,这个字段在Complex.parse()中用到了
//下划线前缀表明它是类内部使用的,而不属于类的公有API的部分
Complex._format=/^\{([^,]+),([^}]+)\}$/;
从例中所定义的Complex类可以看出,我们用到了构造函数、实例字段、实例方法、类字段和类方法,看一下这段示例代码:
var c=new Complex(2,3);//使用构造函数创建新的对象

var d=new Complex(c.i,c.r);//用到了c的实例属性

c.add(d).toString();//=>"{5,5}":使用了实例的方法

//这个稍微复杂的表达式用到了类方法和类字段

Complex.parse(c.toString()).//将c转换为字符串

add(c.neg()).//加上它的负数

equals(Complex.ZERO)//结果应当永远是"零"
 
  • 类的扩充

JavaScript中基于原型的继承机制是动态的:对象从其原型继承属性,如果创建对象之后原型的属性发生改变,也会影响到继承这个原型的所有实例对象。这意味着我们可以通过给原型对象添加新方法来扩充JavaScript类。这里我们给例中的Complex类添加方法来计算复数的共轭复数。

//返回当前复数的共轭复数
Complex.prototype.conj=function(){return new Complex(this.r,-this.i);};
JavaScript内置类的原型对象也是一样如此“开放”,也就是说可以给数字、字符串、数组、函数等数据类型添加方法。
这里有一些其他的例子:
//多次调用这个函数f,传入一个迭代数
//比如,要输出"hello"三次:
//var n=3;
//n.times(function(n){console.log(n+"hello");});
Number.prototype.times=function(f,context){
    var n=Number(this);
    for(var i=0;i<n;i++)f.call(context,i);
};//如果不存在ES5的String.trim()方法的话,就定义它
//这个方法用以去除字符串开头和结尾的空格
String.prototype.trim=String.prototype.trim||function(){
    if(!this)return this;//空字符串不做处理
    return this.replace(/^\s+|\s+$/g,"");//使用正则表达式进行空格替换
};//返回函数的名字,如果它有(非标准的)name属性,则直接使用name属性
//否则,将函数转换为字符串然后从中提取名字
//如果是没有名字的函数,则返回一个空字符串
Function.prototype.getName=function(){
    return this.name||this.toString().match(/function\s*([^()*]\(/)[1];
};
 
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值