每个JavaScript对象都是一个属性集合,相互之间没有任何联系。在JavaScript中也可以定义对象的类,让每个对象都共享某些属性,这种 “共享”的特性是非常有用的。类的成员或实例都包含一些属性,用以存放或定义它们的状态,其中有些属性定义了它们的行为(通常称为方法)。这些行为通常是由类定义的,而且为所有实例所共享。例如,假设有一个名为Complex的类用来表示复数,同时还定义了一些复数运算。一个Complex实例应当包含复数的实部和虚部(状态),同样Complex类还会定义复数的加法和乘法操作(行为)。
在JavaScript中,类的实现是基于其原型继承机制的。如果两个实例都从同一个原型对象上继承了属性,我们说它们是同一个类的实例。
如果两个对象继承自同一个原型,往往意味着(但不是绝对)它们是由同一个构造函数创建并初始化的。
如果你对诸如Java和C++这种强类型(强/弱类型是指类型检查的严格程度,为所有变量指定数据类型称为“强类型”。)的面向对象编程比较熟悉,你会发现JavaScript中的类和Java以及C++中的类有很大不同。尽管在写法上类似,而且在JavaScript中也能 “模拟”出很多经典的类的特性( 比如传统类的封装、继承和多态。),但是最好要理解JavaScript的类和基于原型的继承机制,以及和传统的Java(当然还有类似Java的语言)的类和基于类的继承机制的不同之处。
JavaScript中类的一个重要特性是“动态可继承"(dynamically extendable)。
我们可以将类看做是类型。
“鸭式辩型”(duck-typing)的编程哲学,它弱化了对象的类型,强化了对象的功能。
定义类是模块开发和重用代码的有效方式之一。
在JavaScript中,类的所有实例对象都从同一个原型对象上继承属性。因此,原型对象是类的核心。inherit( )这个函数返回一个新创建的对象,后者继承自某个原型对象。如果定义一个原型对象,然后通过inherit( )函数创建一个继承自它的对象,这样就定义了一个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; }, // 对于范围内的每个整数都调用一次十 // 这个方法只可用做数字范围 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 consol.log(r); // 输出 (1...3)在上例中有一些代码是没有用的。这段代码定义了一个工厂方法range( ),用来创建新的范围对象。我们注意到,这里给range( )函数定义了一个属性range.methods,用以快捷地存放定义类的原型对象。把原型对象挂在函数上没什么大不了,但也不是惯用做法。再者,注意range( )函数给每个范围对象都定义了from和to属性,用以定义范围的起始位置和结束位置,这两个属性是非共享的,当然也是不可继承的。最后,注意在range.methods中定义的那些可共享、可继承的方法都用到了from和to属性,而且使用了this关键字,为了指代它们,二者使用this关键字来指代调用这个方法的对象。任何类的方法都可以通过this的这种基本用法来读取对象的属性。
上例展示了在JavaScript中定义类的其中一种方法。但这种方法并不常用,毕竟它没有定义构造函数,构造函数是用来初始化新创建的对象的。使用关键字new来调用构造函数。使用new调用构造函数会自动创建一个新对象,因此构造函数本身只需初始化这个新对象的状态即可。调用构造函数的一个重要特征是,构造函数的 prototye属性被用做新对象的原型。这意味着通过同一个构造函数创建的所有对象都继承自一个相同的对象,因此它们都是同一个类的成员。下例对例上中的“范围类”做 了修改,使用构造函数代替工厂函数:
例:使用构造函数来定义“范围类” // rangez.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.methodso这种命名方式很方便同时具有很好的语义,但又过于随意。
在第二段示例代码中的原型是Range.prototype,这是一个强制的命名。对Range( )构造函数的调用会自动使用Range.prototype作为新Range对象的原型。
最后,需要注意在上面两例中两种类定义方式的相同之处,两者的范围方法定义和调用方式是完全一样的。
构造函数和类的标识
上文提到,原型对象是类的唯一标识:当且仅当两个对象继承自同一个原型对象时,它们才是属于同一个类的实例。而初始化对象的状态的构造函数则不能作为类的标识,两个构造函数的prototype属性可能指向同一个原型对象。那么这两个构造函数创建的实例是属于同一个类的。
尽管构造函数不像原型那样基础,但构造函数是类的“外在表现”。很明显的,构造函数的名字通常用做类名。比如,我们说Range( )构造函数创建Range对象。然而,更根本地讲,当使用instanceof运算符来检测对象是否属于某个类时会用到构造函数。假设这里有一个对象r,我们想知道r是否是Range对象,我们这样写:
r instanceof Range // 如果r继承自Range.prototype,则返回true实际上instanceof运算符并不会检査工是否是由Range( )构造函数初始化而来,而会检査r是否继承自Range.prototype( )。不过,instanceof的语法则强化了“构造函数是类的公有标识”的概念。
constructor属性
在上例中,将Range.prototype定义为一个新对象,这个对象包含类所需要的方法。其实没有必要新创建一个对象,用单个对象直接量的属性就可以方便地定义原型上的方法。任何JavaScript函数都可以用做构造函数,并且调用构造函数是需要用到一个prototye属性的。因此,每个JavaScript函数(ECMAScript 5中的Function.bind( )方法返回的函数除外)都自动拥有一个prototype属性。这个属性的值是一个对象,这个对象包含唯一个不可枚举属性constructoro 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( )构造函数作为示例,但实际上,例9-2中定义的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 + ")"; };