每个JavaScript对象都是一个属性集合,相互之间没有任何联系。在JavaScript中也可以定义对象的类,让每个对象都共享某些属性,这种“共享”的特性是非常有用的。类的成员或实例都包含一些属性,用以存放或定义它们的状态,其中有些属性定义了它们的行为(通常称为方法)。这些行为通常是由类定义的,而且为所有实例所共享。
在JavaScript中,类的实现是基于其原型继承机制的。如果两个实例都从同一个原型对象上继承了属性,我们说它们是同一个类的实例,并且往往意味着(但不是绝对)它们是由同一个构造函数创建并初始化的。
一、类和原型
在JavaScript中,类的所有实例对象都从同一个原型对象上继承属性。因此,原型对象是类的核心。我们在前文中定义了一个inherit()
函数,这个函数返回一个新创建的对象,后者继承自某个原型对象。如果定义了一个原型对象,然后通过inherit()
函数创建一个继承自它的对象,这样就定义了一个JavaScript类。通常,类的实例还需要进一步的初始化,通常是通过定义一个函数来创建并初始化这个新对象。下方代码给一个表示“值的范围”的类定义了原型对象,还定义了一个“工厂”函数用以创建并初始化类的实例。
1 | // range.js 实现一个能表示值的范围的类 |
这段代码定义了一个工厂方法range()
,用来创建新的范围对象。我们注意到,这里给range()
函数定义了一个属性range.methods
,用以快捷地存放定义类的原型对象。把原型对象挂在函数上没什么大不了,但也不是惯用做法。再者,注意range()
函数给每个范围对象都定义了from
和to
属性,用以定义范围的起始位置和结束位置,这两个属性是非共享的,当然也是不可继承的。最后,注意在range.methods
中定义的那些可共享、可继承的方法都用到了from
和to
属性,而且使用了this
关键字,为了指代它们,二者使用this
关键字来指代调用这个方法的对象。任何类的方法都可以通过this
的这种基本用法来读取对象的属性。
二、类和构造函数
上文示例中展示了在JavaScript中定义类的其中一种方法。但这种方法并不常用,毕竟它没有定义构造函数,构造函数是用来初始化新创建的对象的。使用new
关键字调用构造函数会自动创建一个新对象,因此构造函数本身只需初始化这个新对象的状态即可。调用构造函数的一个重要特征是,构造函数的prototype
属性被用做新对象的原型。这意味着通过同一个构造函数创建的所有对象都继承自一个相同的对象,因此它们都是同一个类的成员。下文示例对前面代码进行了修改,使用构造函数代替工厂函数
1 | // range2.js |
将两个代码进行对比,可以发现两种定义类的技术的差别。首先,注意当工程函数range()
转化为构造函数时被重命名为Range()
。这里遵循了一个常见的编程约定:定义构造函数即是定义类,并且类名首字母要大写。而普通的函数和方法都是首字母小写。
再者,注意Range()
构造函数是通过new
关键字调用的,而range()
工厂函数则不必使用new。由于Range()
构造函数是通过new
关键字调用的,因此不必调用inherit()
或其他什么逻辑来创建新对象。Range()
构造函数只不过就是初始化this
而已。构造函数甚至不必返回这个新创建的对象,构造函数会自动创建对象,然后将构造函数作为这个对象的方法来调用一次,最后返回这个新对象。
构造函数就是用来“构造新对象”的,它必须通过关键字
new
来调用,如果将构造函数用作普通函数的话,往往不会正常工作。开发者可以通过命名约定(构造函数首字母大写,普通方法首字母小写)来判断是否应当在函数之前冠以关键字new
。
两个代码之间还有一个非常重要的区别,就是原型对象的命名,在第一段示例代码中的原型是range.methods
。这种命名方式很方便同时具有很好的语义,但又过于随意。在第二段代码中的原型是Range.prototype
,这是一个强制的命名。对Range()
构造函数的调用会自动使用Range.prototype
作为新Range
对象的原型。
1. 构造函数和类的标识
上文提到,原型对象是类的唯一标识:当且仅当两个对象继承自同一个原型对象时,它们才是属于同一个类的实例。而初始化对象的状态的构造函数则不能作为类的标识,如果两个构造函数的prototype
属性指向同一个原型对象,那么这两个构造函数创建的实例是属于同一个类的。
尽管构造函数不像原型那样基础,但构造函数是类的“外在表现”。很明显的,构造函数的名字通常用做类名。比如,我们说Range()
构造函数创建Range
对象。然而,更根本的讲,当使用instanceof
运算符来检测对象是否属于某个类时会用到构造函数
1 | // 如果r继承自Range.prototype,则返回true |
实际上instanceof
运算符并不会检查r
是否由Range()
构造函数初始化而来,而是会检查r
是否继承自Range.prototype
。不过,instanceof
的语法则强化了“构造函数是类的公有标识”的概念。本文后面还会对instanceof
运算符进行介绍。
2. constructor属性
上文实例中,将Range.prototype
定义为一个新对象,这个对象包含类所需要的方法。其实没有必要新创建一个对象,用单个对象直接量的属性就可以方便地定义原型上的方法。任何JavaScript函数都可以用作构造函数,并且调用构造函数是需要用到一个prototype
属性的。因此,每个JavaScript函数(ES5中的Function.bind()
方法返回的函数除外)都自动拥有一个prototype
属性。这个属性的值是一个对象,这个对象包含唯一一个不可枚举属性constructor
。constructor
属性的值是一个函数对象。
1 | var F = function() {}; // 这是一个函数对象 |
可以看到构造函数的原型中存在预先定义好的constructor
属性,这意味着对象通常继承的constructor
均指代它们的构造函数。由于构造函数是类的“公共标识”,因此这个constructor
属性为对象提供了类。
1 | var o = new F(); // 创建类F的一个对象 |
需要注意的是,示例2定义的Range
类使用它自身的一个新对象重写预定义的Range.prototype
对象。这个新定义的原型对象不含有constructor
属性。因此Range
类的实例也不含有constructor
属性,我们可以通过补救措施来修正这个问题,显式给原型添加一个构造函数
1 | Range.prototype = { |
另一种常见的解决办法是使用预定义的原型对象,预定义的原型对象包含constructor
属性,然后依次给原型对象添加方法
1 | // 扩展预定义的Range.prototype对象,而不重写之 |
三、JavaScript中Java式的类继承
Java或其他类似强类型面向对象语言的类可能是这个样子:
实例字段
它们是基于实例的属性或变量,用以保存独立对象的状态。
实例方法
它们是类的所有实例所共享的方法,由每个独立的实例调用。
类字段
这些属性或变量是属于类的,而不是属于类的某个实例的。
类方法
这些方法是属于某个类的,而不是属于类的某个实例的。
JavaScript和Java的一个不同之处在于,JavaScript中的函数都是以值的形式出现的,方法和字段之间并没有太大的区别。如果属性值是函数,那么这个属性就定义一个方法;否则,它只是一个普通的属性或“字段”。尽管存在诸多差异,我们还是可以用JavaScript模拟出Java中的这四种类成员类型。JavaScript中的类牵扯三种不同的对象,三种对象的属性的行为和下面三种类成员非常相似:
构造函数对象
构造函数(对象)为JavaScript的类定义了名字。任何添加到这个构造函数对象的属性都是类字段和类方法(如果属性值是函数的话就是类方法)。
原型对象
原型对象的属性被类的所有实例所继承,如果原型对象的属性值是函数的话,这个函数就作为类的实例的方法来调用。
实例对象
类的每个实例都是一个独立的对象,直接给这个实例定义的属性是不会为所有实例对象所共享的。定义在实例上的非函数属性,实际上是实例的字段。
在JavaScript中定义类的步骤可以缩减为一个分三步的算法。第一步,先定义一个构造函数,并设置初始化新对象的实例属性。第二步,给构造函数的prototype
对象定义实例的方法。第三步,给构造函数定义类字段和类属性。
1 | function defineClass(constructor, // 用以设置实例的属性的函数 |
尽管JavaScript可以模拟出Java式的类成员,但Java中有很多重要的特性是无法在JavaScript类中模拟的。首先,对于Java类的实例方法来说,实例字段可以用作局部变量,而不需要使用关键字this
来引用它们。JavaScript是没办法模拟这个特性的,但可以使用with
语句来近似地实现这个功能(但并不推荐):
1 | Complex.prototype.toString = function() { |
在Java中可以使用final
声明字段为常量,并且可以将字段和方法声明为private
,用以表示它们是私有成员且在类的外面是不可见的。在JavaScript中没有这些关键字,关于这个问题我们在后文中还会碰到:私有属性可以使用闭包里的局部变量来模拟,常量属性可以在ES5中直接实现。
四、类的扩充
JavaScript中基于原型的继承机制是动态的:对象从其原型继承属性,如果创建对象之后原型的属性发生改变,也会影响到继承这个原型的所有实例对象。这意味着我们可以通过给原型对象添加新方法来扩充JavaScript类。
JavaScript内置类的原型对象也是一样如此“开放”,也就是说可以给数字、字符串、数组、函数等数据类型添加方法。
1 | if (!Function.prototype.bind) { |
还有一些其他例子
1 | // 多次调用这个函数f,传入一个迭代数 |
可以给Object.prototype
添加方法,从而使所有的对象都可以调用这些方法。但这种做法并不推荐,因为ES5之前,无法将这些新增的方法设置为不可枚举的,如果给Object.prototype
添加属性,这些属性是可以被for/in
循环遍历到的。后面我们会给出一个ES5的例子,其中使用Object.defineProperty()
方法可以安全地扩充Object.prototype
。
然后并不是所有的宿主环境可以使用Object.defineProperty()
,这跟ECMAScript的具体实现有关。比如在很多Web浏览器中,可以给HTMLElement.prototype
添加方法,这样当前文档中表示HTML标记的所有对象就都可以继承这些方法。但IE则不支持这样做。
五、类和类型
JavaScript定义了少量的数据类型:null、undefined、布尔值、数字、字符串、函数和对象
。typeof
运算符可以得出值的类型。然而,我们往往更希望将类作为类型来对待,这样就可以根据对象所属的类来区分它们。JavaScript语言核心中的内置对象可以根据它们的class
属性来区分彼此。但当我们使用本文中提到的技术来定义类的话,实例对象的class
属性都是“Object
”,此时无法根据class
属性进行区分。
接下来我们介绍三种用以检测任意对象的类的技术:instanceof
运算符、constructor
属性,以及构造函数的名字。
1. instanceof
instanceof
操作符左侧是待检测其类的对象,右侧是定义类的构造函数。这里的继承可以不是直接继承。如果o
所继承的对象继承自另一个对象,后一个对象继承自c.prototype
,o instanceof c
值同样是true
。
正如本文前面所讲的,构造函数是类的公共个标识,但原型是唯一的标识。尽管instanceof
运算符的右侧是构造函数,但计算过程中实际上是检测了对象的继承关系,而不是检测创建对象的构造函数。
如果想检测对象的原型链上是否存在某个特定的原型对象,可以使用isPrototypeOf()
方法
1 | range.methods.isPrototypeOf(r); |
instanceof
运算符和isPrototypeOf()
方法的缺点是,我们无法通过对象类获得类名,只能检测对象是否属于指定的类名。
2. constructor
另一种识别对象是否属于某个类的方法是使用constructor
属性,因为构造函数是类的公共标识,所以最直接的方法就是使用constructor
属性
1 | function typeAndValue(x) { |
需要注意的是,在代码中关键字case
后的表达式都是函数,如果改用typeof
运算符获取到对象的class
属性的话,它们应当改为字符串。
使用constructor
属性检测对象属于某个类的技术的不足之处和instanceof
一样。在多个执行上下文的场景中它是无法正常工作的。
同样,在JavaScript中也并非所有的对象都包含constructor
属性,在每个新创建的函数原型上默认会有constructor
属性,但我们常常会忽略原型上的constructor
属性。
3. 构造函数的名称
使用instanceof
和constructor
属性来检测对象所属的类有一个主要的问题,在多个执行上下文中存在构造函数的多个副本的时候,这两种方法的检测结果会出错。多个执行上下文中的函数看起来是一模一样的,但它们是相互独立的对象,因此彼此也不相等。
一种可能的解决方案是使用构造函数的名字而不是构造函数本身作为类标识符。一个窗口里的Array
构造函数和另一个窗口的Array
构造函数是不相等的,但是它们的名字是一样的。
1 | /* |
这种使用构造函数名字来识别对象的类的做法和使用constructor
属性一样有一个问题:并不是所有的对象都有constructor
属性。此外,并不是所有的函数都有名字。如果使用不带名字的函数定义表达式定义一个构造函数,getName()
方法则会返回空字符串
1 | // 这个构造函数没有名字 |
4. 鸭式辩型
上文所描述的检测对象的类的各种技术多少都会有些问题,至少在客户端JavaScript中是如此。解决办法就是规避掉这些问题:不要关注“对象的类是什么”,而是关注“对象能做什么”。这种思考问题的方式在Python和Ruby中非常普遍,成为“鸭式辩型”
像鸭子一样走路、有用并且嘎嘎叫的鸟就是鸭子
对于JavaScript来说,这句话可以理解为“如果一个对象可以像鸭子一样走路、游泳并且嘎嘎叫,就认为这个对象是鸭子,哪怕它并不是从鸭子类的原型对象继承而来的”。
我们拿前文中的Range
类来举例,起初定义这个类用以描述数字的范围。但要注意,Range()
构造函数并没有对实参进行类型检查以确保实参是数字类型。但却将参数使用“>
”运算符进行比较运算,因为这里假定它们是可比较的。同样,includes()
方法使用“<=
”运算符进行比较,但没有对范围的结束点进行类似的假设。因为类并没有强制使用特定的类型,它的includes()
方法可以作用于任何结束点,只要结束点可以用关系运算符执行比较运算。
1 | var lowercase = new Range('a', 'z'); |
Range
类的foreach()
方法中也没有显式的检测表示范围的结束点的类型,但Math.ceil()
和“++
”运算符表明它只能对数字结束点进行操作。
另外一个例子,我们之前讨论的类数组对象。在很多场景下,我们并不知道一个对象是否真的是Array
实例,当然是可以通过判断是否包含非负的length
属性来得知是否是Array
的实例。我们说“包含一个值是非负整数的length”是数组的一个特性——“会走路”,任何具有“会走路”这个特征的对象都可以当作数组来对待。然而必须要了解的是,真正数组的length
属性具有一些独有的行为:当添加新元素时,数组的长度会自动更新,并且当给length
属性设置一个更小的整数时,数组会被自动截断。我们说这些特征是“会游泳”和“嘎嘎叫”。如果所实现的代码需要“会游泳”且能“嘎嘎叫”,则不能使用只“会走路”的类似数组的对象。
上文所讲到的鸭式辩型的例子提到了进行对象的“<
”运算符的职责以及length
属性的特殊行为。但当我们提到鸭式辩型时,往往是说检测对象是否实现了一个或多个方法。一个强类型的triathlon()
函数所需要的参数必须是TriAthlete
对象。而一中“鸭式辩型”式的做法是,只要对象包含walk()
、swin()
和bike()
这三个方法就可以作为参数传入。同里,可以重新设计Range
类,使用结束点对象的compareTo()
和succ()
方法来替代“<
”和“++
”运算符。
鸭式辩型的实现方法让人感觉太“放任自流”:仅仅是假设输入对象实现了必要的方法,根本没有执行进一步的检查。如果输入对象没有遵循“假设”,那么当代吗试图调用那些不存在的方法时就会报错。另一种实现方法是对输入对象进行检查。但不是检查它们的类,而是用适当的名字来检查它们所实现的方法。这样可以将非法输入尽可能早地拦截在外,并可给出带有更多提示信息的报错。
下文示例中按照鸭式辩型的理念定义了quacks()
函数。quacks()
函数用以检查一个对象是否实现了剩下参数所表示的方法。对于除第一个参数外的每个参数,如果是字符串的话则直接检查是否存在以它命名的方法;如果是对象的话则检查第一个对象中的方法是否在这个对象中也具有同名的方法;如果参数是函数,则假定它是构造函数,函数将检查第一个对象实现的方法是否在构造函数的原型对象中也具有同名的方法。
1 | // 利用鸭式辩型实现的函数 |
关于这个quacks()
函数还有一些地方是需要尤为注意的。首先,这里只是通过特定的名称来检测对象是否含有一个或多个值为函数的属性。我们无法得知这些已经存在的属性的细节信息,如果,函数是干什么用的?它们需要多少参数?参数类型是什么?然而这是鸭式辩型的本质所在,如果使用鸭式辩型而不是强制的类型检测的方式定义API,那么创建的API应当更具灵活性才可以,这样才能确保你提供给用户的API更加安全可靠。关于quacks()
函数还有另一问题需要注意,就是它不能应用于内置类。比如,不能通过quacks(o, Array)
来检测o是否实现了Array
中所有同名的方法。原因是内置类的方法都是不可枚举的,quacks()
中的for/in
循环无法遍历到它们(注意,ES5中有一个补救办法,就是使用Object.getOwnPropertyNames()
)。
六、JavaScript中的面向对象技术
到目前为止,我们讨论了JavaScript中类的基础知识:原型对象的重要性、它和构造函数之间的联系、instanceof
运算符如何工作等。下面我们举一个例子,介绍如何利用JavaScript中的类进行编程。
1. 集合类
集合(set
)是一种数据结构,用以表示非重复值的无序集合。集合的基础方法包括添加值、检测值是否在集合中,这种集合需要一种通用的实现,以保证操作效率。JavaScript的对象是属性名以及与之对应的值的基本集合。因此将对象只用做字符串的集合是大材小用。下面的例子实现了一个更加通用的Set
类,它实现了从JavaScript值到唯一字符串的映射,然后将字符串用作属性名。对象和函数都不具备如此简明可靠的唯一字符串表示。因此集合类必须给集合中的每一个对象或函数定义一个唯一的属性标识。
1 | function Set() { // 这是一个构造函数 |
2. 枚举类型
枚举类型(enumerated type
)是一种类型,它是值的有限集合,如果值定义为这个类型则该值是可列出的。Enum
是ES5中的保留字,很有可能在将来JavaScript就会内置支持枚举类型。
下文示例中包含一个单独函数enumeration()
。但它不是构造函数,它并没有定义一个名叫“enumeration
”的类。相反,它是一个工厂方法,每次调用它都会创建并返回一个新的类
1 | // 使用4个值创建新的Coin类:Coin、Penny、Coin.Nickel等 |
如果用这个枚举类型来实现一个“hello world
”小程序的话,就可以使用枚举类型来表示一副扑克牌。
1 | function Card(suit, rank) { |
3. 标准转换方法
对象类型转换时,有一些方法是在需要做类型转换时由JavaScript解释器自动调用的。不需要为定义的每个类都实现这些方法,但这些方法的确非常重要,如果没有为自定义的类实现这些方法,也应当是有意为之,而不应当因为疏忽而漏掉了它们。
最重要的方法首当toString()
,这个方法的作用是返回一个可以表示这个对象的字符串。在希望使用字符串的地方用到对象的话(比如将对象用作属性名或使用“+
”运算符来进行字符串连接运算),JavaScript会自动调用这个方法。如果没有实现这个方法,类会默认从Object.prototype
中继承toString()
方法,这个方法的运算结果是“[object Object]
”,这个字符串用处不大。toString()
方法应当返回一个可读的字符串,这样最终用户才能将这个输出值利用起来,然而有时候并不一定非要如此,不管怎样,可以返回可读字符串的toString()
方法也会让程序调试变得更加轻松。
toLocaleString()
和toString()
类似:toLocaleString()
是以本地敏感性(locale-sensitive
)的方式来将对象转换为字符串。默认情况下,对象所继承的toLocaleString()
方法只是简单地调用toString()
方法。有一些内置类型包含有用的toLocaleString()
方法用以实际上返回本地化相关的字符串。如果需要为对象到字符串的转换定义toString()
方法,那么同样需要定义toLocaleString()
方法用以处理本地化的对象到字符串的转换。
第三个方法是valueOf()
,它用来将对象转换为原始值。比如,当数学运算符(除了“+
”运算符)和关系运算符作用于数字文本表示的对象时,会自动调用valueOf()
方法。大多数对象都没有合适的原始值来表示它们,也没有定义这个方法。
第四个方法是toJSON()
,这个方法是由JSON.stringify()
自动调用的。JSON格式用于序列化良好的数据结构,而且可以处理JavaScript原始值、数组和纯对象。它和类无关,当对一个对象执行序列化操作时,它会忽略对象的原型和构造函数。比如将Range
对象或Complex
对象作为参数传入JSON.stringify()
,会返回诸如{"form": 1, "to": 3}
或{"r": 1, "i": -1}
这种字符串。如果将这些字符串传入JSON.parse()
,则会得到一个和Range
对象和Complex
对象具有相同属性的纯对象,但这个对象不会包含从Range
和Complex
继承来的方法。
上文实例中的Set
类并没有定义上述方法中的任何一个,JavaScript中没有哪个原始值可以表示集合,因此也没有必要定义valueOf()
方法,但该类应当包含toString()
、toLocaleString()
和toJSON()
方法。可以用如下代码来实现,注意extend()
函数的用法,这里使用extend()
来向Set.prototype
来添加方法
1 | // 将这些方法添加至Set类的原型对象中 |
4. 比较方法
JavaScript的相等运算符比较对象时,比较的是引用而不是值。也就是说,给定两个对象引用,如果要看它们是否指向同一个对象,不是检查这两个对象是否具有相同的属性名和相同的属性值,而是直接比较这两个单独的对象是否相等,或者比较它们的顺序(就像“<
”和“>
”运算符进行的比较一样)。如果定义一个类,并且希望比较类的实例,应该定义合适的方法来执行比较操作。
为了能让自定义类的实例具备比较的功能,定义一个名叫equals()
实例方法,这个方法只能接收一个实参,如果这个实参和调用此方法的对象相等的话则返回true
。当然,这里所说的相等的含义是根据类的上下文来决定的。相对简单的类,可以通过简单地比较它们的constructor
属性来确保两个对象是相同类型,然后比较两个对象的实例属性以保证它们的值相等。我们可以轻易地为Range
类也实现类似的方法
1 | // Range类重写它的constructor属性,现在将它添加进去 |
给Set
类定义equals()
方法稍微有些复杂,不能简单地比较两个集合的values
属性,还要进行更深层次的比较
1 | Set.prototype.equals = function(that) { |
对于某些类来说,往往需要比较一个实例“大于”或者“小于”另外一个实例。比如,我们可能会基于Range
对象的下边界来定义实例的大小关系。枚举类型可以根据名字的字母表顺序来定义实例的大小,也可以根据它包含的数值来定义大小,另一方面,Set
对象其实是无法排序的。
如果将对象用于JavaScript的关系比较运算符,比如“<
”和“>
”,JavaScript会首先调用对象valueOf()
方法,如果这个方法返回一个原始值,则直接比较原始值。上文中的enumeration()
方法所返回的枚举类型包含valueOf()
方法,因此可以使用关系运算符对它们做有意义的比较。但大多数类并没有valueOf()
方法,为了按照显式定义的规则来比较这些类型的对象,可以定义一个名叫compareTo()
的方法。
compareTo()
方法应当只能接收一个参数,这个方法将这个参数和调用它的对象进行比较。如果this
对象小于参数对象,compareTo()
应当返回比0小的值。如果this
对象大于参数对象,应当返回比0大的值。如果两个对象相等,应该返回0。
1 | Range.prototype.compareTo = function(that) { |
上文提到的equals()
方法对其参数执行了类型检查,如果参数类型不合法则返回false
。compareTo()
方法并没有返回一个表示“这两个值不能比较”的值,由于compareTo()
没有对参数做任何类型检查,因此如果给compareTo()
方法传入错误类型的参数,往往会抛出异常。
注意,如果两个范围对象的下边界相等,为Range
类定义的compareTo()
方法会返回0。这意味着就compareTo()
而言,任何两个起始点相同的Range
对象都相等。这个相等概念的定义和equals()
方法定义的相等概念是相背的,equals()
要求两个端点均相等才算相等。最好将Range
类的equals()
和compareTo()
方法中处理相等的逻辑保持一致。这里是Range
类修正后的compareTo()
方法,它的比较逻辑和equals()
保持一致,但当传入不可比较的值时仍然会报错
1 | // 根据下边界来对Range对象排序,如果下边界相等则比较上边界 |
5. 方法借用
JavaScript中的方法没有什么特别:无非是一些简单的函数,赋值给了对象的属性,可以通过对象来调用它。一个函数可以赋值给两个属性,然后作为两个方法来调用它。比如,我们在Set类中就这样做了,将toArray()
方法创建了一个副本,并让它可以和toJSON()
方法一样完成同样的功能。
多个类中的方法可以共用一个单独的函数。比如,Array
类通常定义了一些内置方法,如果定义了一个类,它的实例是数组类的对象,则可以从Array.prototype
中将函数复制至所定义的类的原型对象中,如果以经典的面向对象语言的视角来看JavaScript的话,把一个类的方法用到其他的类中的做法也称作“多重继承”(multiple inheritance
)。然而,JavaScript并不是经典的面向对象语言,我们更倾向于将这种方法重用称作为“方法借用”(borrowing
)。
不仅Array
的方法可以借用,还可以自定义泛型方法(generic method
)。比如我们定义泛型方法toString()
和equals()
,可以被Range
、Complex
和Card
这些简单的类使用。如果Range
类没有定义equals()
方法,可以这样借用泛型方法equals()
1 | Range.prototype.equals = generic.equals; |
注意,generic.equals()
只会执行浅比较,因此这个方法并不适用于其实例太复杂的类,它们的实例属性通过其equals()
方法指代对象。同样需要注意,这个方法包含一些特殊情况的程序逻辑,以处理新增至Set
对象中的属性
1 | var generic = { |
6. 私有状态
在经典的面向对象编程中,经常需要将对象的某个状态封装或隐藏在对象内,只有通过对象的方法才能访问这些状态,对外只暴露一些重要的状态变量可以直接读写。为了实现这个目的,类似Java的编程语言允许声明类的“私有”实例字段,这些私有实例字段只能被类的实例方法访问,且在类的外部是不可见的。
我们可以通过将变量(或参数)闭包在一个构造函数内来模拟实现私有实例字段,调用构造函数会创建一个实例。为了做到这一点,需要在构造函数内定义一个函数(因此这个函数可以访问构造函数内部的参数和变量),并将这个函数赋值给新创建对象的属性。下文实例展示了对Range
类的另一种封装,新版的类的实例包含from()
和to()
方法用以返回范围的端点,而不是用from
和to
属性来获取端点。这里的from()
和to()
方法是定义在每个Range
对象上的,而不是从原型中继承来的。其他的Range
方法还是和之前一样定义在原型中,但获取端点的方式从之前直接从属性读取变成了通过from()
和to()
方法来读取。
1 | function Range(from, to) { |
这个新的Range
类定义了用以读取范围端点的方法,但没有定义设置端点的方法或属性。这让类的实例看起来是不可修改的,如果使用正确的话,一旦创建Range
对象,端点数据就不可修改了。除非使用ES5中的某些特性,但from
和to
属性依然是可写的,并且Range
对象实际上并不是真正不可修改的
1 | var r = new Range(1, 5); // 一个不可修改的范围 |
但要注意的是,这种封装技术造成了更多的系统开销,使用闭包来封装类的状态的类一定会比不使用封装的状态变量的等价类运行速度更慢,并占用更多内存。
7. 构造函数的重载和工厂方法
有时候,我们希望对象的初始化有多种方式。比如,我们想通过半径和角度(极坐标)来初始化一个Complex
对象,而不是通过实部和虚部来初始化,或者通过元素组成的数组来初始化一个Set
对象,而不是通过传入构造函数的参数来初始化它。
有一个方法可以实现,通过重载(overload
)这个构造函数来让它根据传入的参数的不同来执行不同的初始化方法。下面就是重载Set
构造函数的例子
1 | function Set() { |
这段代码所定义的Set
构造函数可以显式将一组元素作为参数列表传入,也可以传入元素组成的数组。但是这个构造函数有多义性,如果集合的某个成员是一个数组就无法通过这个构造函数来创建这个集合了(为了做到这一点,需要创建一个空集合,然后显式调用add()
方法)。
在使用极坐标来初始化复数的例子中,实际上并没有看到有函数重载。代表复数两个纬度的数字都是浮点数,除非给构造函数传入第三个参数,否则构造函数无法识别到底传入的是极坐标参数还是直角坐标参数。相反,可以写一个工厂方法——一个类的方法用以返回类的一个实例。
1 | Complex.polar = function(r, theta) { |
可以给工厂方法定义任意的名字,不同名字的工厂方法用以执行不同的初始化。但由于构造函数是类的公有标识,因此每个类只能有一个构造函数。但这并不是一个“必须遵守”的规则。在JavaScript中是可以定义多个构造函数继承自一个原型对象的,如果这样做的话,由这些构造函数的任意一个所创建的对象都属于同一类型(并不推荐这种技术)。
1 | // Set类的一个辅助构造函数 |
七、子类
在面向对象编程中,类B可以继承自另外一个类A
。我们将A
称为父类(superclass
),将B
称为子类(subclass
)。B
的实例从A
继承了所有的实例方法。类B
可以定义自己的实例方法,有些方法可以重载A
中的同名方法,如果B
的方法重载了A
中的方法,B
中的重载方法可能会调用A
中的重载方法,这种做法称为“方法链”(method chaining
)。同样,子类的构造函数B()
有时候需要调用父类的构造函数A()
,这种做法称为“构造函数链”(constructor chaining
)。子类还可以有子类,当涉及类的层次结构时,往往需要定义抽象类(abstract class
)。抽象类中定义的方法没有实现。抽象类中的抽象方法是在抽象类的具体子类中实现的。
在JavaScript中创建子类的关键之处在于,采用合适的方法对原型对象进行初始化。如果类B
继承自类A
,B.prototype
必须是A.prototype
的后嗣。B
的实例继承自B.prototype
,后者也同样继承自A.prototype
。本节将对刚才提到的子类相关的术语做一一讲解,还会介绍类继承的替代方案:“组合”(composition
)。
我们从上文的Set
类开始讲解,本节将会讨论如何定义子类,如果实现构造函数链并重载方法,如果使用组合来代替继承,以及最后如果通过抽象类从实现中提炼出接口。
1. 定义子类
JavaScript的对象可以从类的原型对象中继承属性(通常继承的是方法)。如果O
是类B
的实例,B
是A
的子类,那么O
也一定从A
中继承了属性。为此,首先要确保B
的原型对象继承自A
的原型对象。通过inherit()
函数,可以这样实现
1 | B.prototype = inherit(A.prototype); // 子类派生自父类 |
这两行代码是在JavaScript中创建子类的关键。如果不这样做,原型对象仅仅是一个普通对象,它只继承自Object.prototype
,这意味着我们的类和所有的类一样是Object
的子类。如果将这两行代码添加至defineClass()
函数中,可以将它变成defineSubClass()
函数和Function.prototype.extend()
方法
1 | function defineSubClass(superclass, // 父类的构造函数 |
下文实例中展示了不使用defineSubClass()
函数如何“手动”实现子类。这里定义了Set
的子类SingletonSet
。SingletonSet
是一个特殊的集合,它是只读的,而且含有单独的常量成员。
1 | // 构造函数 |
这里的SingletonSet
类是一个比较简单的实现,它包含5个简单的方法定义。它实现了5个核心的Set
方法,但从它的父类中继承了toString()
、toArray()
和equals()
方法。定义子类就是为了继承这些方法。比如,Set
类的equals()
方法用来对Set
实例进行比较,只要Set
的实例包含size()
和foreach()
方法,就可以通过equals()
比较。因为SingletonSet
是Set
的子类,所以它自动继承了equals()
的实现,不用再实现一次。当然,如果想要最简单的实现方式,那么给SingletonSet
类定义它自己的equals()
版本会更高效一些
1 | SingletonSet.prototype.equals = function(that) { |
需要注意的是,SingletonSet
不是将Set
中的方法列表静态地借用过来,而是动态地从Set
类继承方法。如果给Set.prototype
添加新的方法,Set
和SingletonSet
的所有实例就会立即拥有这个方法(假定Singleton
没有定义与之同名的方法)。
2. 构造函数和方法链
前文中SingletonSet
类定义了全新的集合实现,而且将它继承自其父类的和新方法全部替换。然而定义子类时,我们往往希望对付类的行为进行修改或扩充,而不是完全替换掉它们。为了做到这一点,构造函数和子类的方法需要调用或链接到父类构造函数和父类方法。
下文示例对此做了展示。它定义了Set
的子类NonNullSet
,它不允许null
和undefined
作为它的成员。为了使用这种方式对成员做限制,NonNullSet
需要在其add()
方法中对null
和undefined
值做检测。但它需要完全重新实现一个add()
方法,因此它调用父类中的这个方法。注意,NonNullSet()
构造函数同样不需要重新实现,它只须将它的参数传入父类构造函数(作为函数来调用它,而不是通过构造函数来调用),通过父类的构造函数来初始化新创建的对象。
1 | // NonNullSet是Set的子类,它的成员不能是null和undefined |
让我们将这个非null
集合的概念推而广之,称为“过滤后的集合”,这个集合中的成员必须首先传入一个过滤函数再执行添加操作。为此,定义一个类工厂函数,传入一个过滤函数,返回一个新的Set
子类。实际上,可以对此做进一步的通用化的处理,定义一个可以接收两个参数的类工厂:子类和用于add()
方法的过滤函数。这个工厂方法称为filteredsetSubclass()
,并通过这样的方法来使用它
1 | // 定义一个只能保存字符串的“集合”类 |
下文示例是这个类工厂的实现代码,注意,这个例子中的方法链和构造函数链和NonNullSet
中的实现是一样的
1 | // 这个函数返回具体的Set类的子类,并重写该类的add()方法用以对添加的元素做特殊的处理 |
最后,值得强调的是,类似这种创建类工厂的能力是JavaScript语言动态特性的一个体现,类工厂是一种强大和有用的特性,这在Java和C++等语言中是没有的。
3. 组合vs子类
在前文中,定义的集合可以根据特定的标准对集合成员进行限制,而且使用了子类的技术来实现这种功能,所创建的自定义子类使用了特定的过滤函数来对集合中的成员做限制。父类和过滤函数的每个组合都需要创建一个新的类。
然而还有另一种更好的方法来完成这种需求,即面向对象编程中一条广为人知的设计原则:“组合优于继承”。这样,可以利用组合的原理定义一个新的集合实现,它“包装”了另外一个集合对象,在将受限制的成员过滤掉之后会用到这个(包装的)集合对象
1 | /* |
在这个例子中使用组合的一个好处是,只须创建一个单独的FilteredSet
子类即可。可以利用这个类的实例来创建任意带有成员限制的集合实例。比如,不用上文中定义的NonNullSet
类,可以这样做
1 | var s = new FilteredSet(new Set(), function(x) { |
甚至还可以对已经过滤后的集合进行过滤
1 | var t = new FilteredSet(s, { |
4. 类的层次结构和抽象类
在上节中给出了“组合优于继承”的原则,但为了将这条原则阐述清除,创建了Set
的子类。这样做的原因是最终得到的类是Set
的实例,它会从Set
继承有用的辅助方法,比如toString()
和equals()
。尽管这是一个很实际的原因,但不用创建类似Set
类这种具体类的子类也可以很好的用组合来实现“范围”。上文中的SingletonSet
类可以有另外一种类似的实现,这个类还是继承自Set
,因此它可以继承很多辅助方法,但它的实现和其父类的实现完全不一样。SingletonSet
并不是Set
类的专用版本,而是完全不同的另一种Set
。在类层次结构中SingletonSet
和Set
应当是兄弟关系,而非父子关系。
不管是在经典的面向对象语言中还是在JavaScript中,通行的解决办法是“从实现中抽离出接口”。假定定义了一个AbstractSet
类,其中定义了一些辅助方法比如toString()
,但并没有实现诸如foreach()
这样的和新方法。这样,实现的Set
、SingletonSet
和FilteredSet
都是这个抽象类的子类,FilteredSet
和SingletonSet
都不必再实现某个不相关的类的子类了。
下文示例在这个思路上更进一步,定义了一个层次结构的抽象的集合类。AbstractSet
只定义了一个抽象方法:contains()
。任何类只要“声称”自己是一个表示范围的类,就必须至少定义这个contains()
方法。然后,定义AbstractSet
的子类AbstractEnumerableSet
。这个类增加了抽象的size()
和foreach()
方法,而且定义了一些有用的非抽象方法(toString()
、toArray()
、equals()
等),AbstractEnumerableSet
并没有定义add()
和remove()
方法,它只代表只读集合。SingletonSet
可以实现为非抽象子类。最后,定义了AbstractEnumerableSet
的子类AbstractWritableSet
。这个final
抽象集合定义了抽象方法add()
和remove()
,并实现了诸如union()
和intersection()
等非具体方法,这两个方法调用了add()
和remove()
。AbstractWritableSet
是Set
和FilteredSet
类相应的父类。但这个例子中并没有实现它,而是实现了一个新的名叫ArraySet
的非抽象类。
1 | // 这个函数可用作任何抽象方法,非常方便 |
八、ES5中的类
ES5给属性特性增加了方法支持(getter
、setter
、可枚举性
、可写性
和可配置性
),而且增加了对象可扩展性的限制,这些方法同时非常适合用于类的定义。
1. 让属性不可枚举
上文中的Set
类使用了一个小技巧,将对象存储为“集合”的成员:它给添加至这个“集合”的任何对象定义了“对象id”属性。之后如果在for/in
循环中对这个对象做遍历,这个新添加的属性也会遍历到。ES5中可以通过设置属性为“不可枚举”(nonenumerable
)来让属性不会遍历到。下文示例展示了如果通过Object.defineProperty()
来做到这一点,同时也展示了如何定义一个getter
函数以检测对象是否是可扩展的(extensible
)
1 | // 将代码包装在一个匿名函数中,这样定义的变量就在这个函数作用域内 |
2. 定义不可变的类
除了可以设置属性为不可枚举的,ES5还可以设置属性为只读的,当我们希望类的实例都是不可变的,这个特性非常有帮助。下文示例使用Object.defineProperties()
和Object.create()
定义不可变的Range
类。它同样适用Object.defineProperties()
来为类创建原型对象,并将(原型对象的)实例方法设置为不可枚举的,就像内置类的方法一样。不仅如此,它还将这些实例方法设置为“只读”和“不可删除”的,这样就可以防止对类中做任何修改。最后还展示了一个有趣的技巧,其中实现的构造函数也可以用作工厂函数,这样不论调用函数之前是否带有new
关键字,都可以正确的创建实例
1 | // 这个方法可以使用new调用,也可以省略new,它可以用作构造函数也可以用作工厂函数 |
代码示例中用到了Object.defineProperties()
和Object.create()
来定义不可变的和不可枚举的属性。这两个方法非常强大,但属性描述符对象让代码的可读性变得更差。另一种改进的做法是将修改这个已定义属性的特性的操作定义为一个工具函数。
1 | // 将o的指定名字(或所有)的属性设置为不可写的和不可配置的 |
Object.defineProperty()
和Object.defineProperties()
可以用来创建新属性,也可以修改已有属性的特性。当用它们创建新属性时,默认的属性特性的值都是false
。但当用它们修改已经存在的属性时,默认的属性特性依然保持不变。
使用这些工具函数,就可以充分利用ES5的特性来实现一个不可变的类,而且不用动态地修改这个类。下文示例的Range
类就用到刚才定义的工具函数
1 | function Range(from, to) { // 不可变的类Range的构造函数 |
3. 封装对象状态
构造函数中的变量和参数可以用作它创建的对象的私有状态,该方法在ES3中的一个缺点是,访问这些私有状态的存取器方法是可以替换的,在ES5中可以通过定义属性getter
和setter
方法将状态变量更健壮地封装起来,这两个方法是无法删除的
1 | // 这个版本的Range类是可变的,但将端点变量进行了良好的封装 |
4. 防止类的扩展
通常认为,通过给原型对象添加方法可以动态地对类进行扩展,这是JavaScript本身的特性。ES5可以根据需要对此特性加以限制。Object.preventExtension()
方法可以将对象设置为不可扩展的,也就是说不能给对象添加任何新属性。Object.seal()
则更加强大,它除了能阻止用户给对象添加新属性,还能将当前已有的属性设置为不可配置的,这样就不能删除属性了(但不可配置的属性可以是可写的,也可以转换为只读属性)。可以通过这样一句简单的代码来阻止对Object.prototype
的扩展
1 | Object.seal(Object.prototype); |
JavaScript的另外一个动态特性是“对象的方法可以随时替换”
1 | var original_sort_method = Array.prototype.sort; |
可以通过将实例方法设置为只读来防止这类修改,一种方法是使用上面代码所定义的freezeProps()
工具函数,另外一种方法是使用Object.freeze()
,它的功能和Object.seal()
完全一样,它同样会把所有属性都设置为只读的和不可配置的。
理解类的只读属性的特性至关重要。如果对象o
继承了只读属性p
,那么给o.p
的赋值操作将会失败,就不会给o
创建新属性。如果你想重写一个继承来的只读属性,就必须使用Object.defineProperty()
、Object.defineProperties()
或Object.create()
来创建这个新属性。也就是说,如果将类的实例方法设置为只读的,那么重写它的子类的方法的难度会更大。
这种锁定原型对象的做法往往没有必要,但的确有一些场景是需要阻止对象的扩展的。回想一下前文的enumeration()
,这是一个类工厂函数。这个函数将枚举类型的每个实例都保存在构造函数对象的属性里,以及构造函数的values
数组中。这些属性和数组是表示枚举类型实例的正式实例列表,是可以执行“冻结”(freezing
)操作的,这样就不能给它添加新的实例,已有的实例也无法删除或修改。可以给enumeration()
函数添加几行简单的代码
1 | Object.freeze(enumeration.values); |
需要注意的是,通过在枚举类型中调用Object.freeze()
,示例中定义的objectId
属性之后也无法使用了。这个问题的解决办法是,在枚举类型被“冻结”之前读取一次它的objectId
属性(调用潜在的存取器方法并设置其内部属性)。
5. 子类和ES5
下文示例中使用ES5的特性来实现子类,这里使用上文中AbstractWritableSet
类来做进一步说明,来定义这个类的子类StringSet
。下面这个例子的最大特点是使用Object.create()
创建原型对象,这个原型对象继承自父类的原型,同时给新创建的对象定义属性。这种实现方法的困难之处在于,正如上文说提到的,它需要使用难看的属性描述符。
这个例子中另外一个有趣之处在于,使用Object.create()
创建对象时传入了参数null
,这个创建的对象没有继承任何成员。这个对象用来存储集合的成员,同时,这个对象没有原型,这样我们就能对它直接使用in
操作符,而不须使用hasOwnProperty()
方法。
1 | function StringSet() { |
6. 属性描述符
本节给出一个例子,用来讲述基于ES5如何对属性描述进行各种操作,下文示例中给Object.prototype
添加了properties()
方法(这个方法是不可枚举的)。这个方法的返回值是一个对象,用以表示属性的列表,并定义了有用的方法用来输出属性和属性特性(对于调试非常有用),用来获得属性描述符(当复制属性同时复制属性特性时非常有用)以及用来设置属性的特性(是上文定义的hideProps()
和freezeProps()
函数不错的替代方案)。这个例子展示了ES5的大多数属性相关的特性,同时使用了一种模块编程技术。
1 | /* |
九、模块
将代码组织到类中的一个重要原因是,让代码更加“模块化”,可以在很多不同场景中实现代码的重用。但类不是唯一的模块化代码的方式。一般来讲,模块是一个独立的JavaScript文件。模块文件可以包含一个类定义、一组相关的类、一个实用函数库或者是一些待执行的代码。只要以模块的形式编写代码,任何JavaScript代码段就可以当作是一个模块。
很多JavaScript库和客户端编程框架都包含一些模块系统,CommonJS服务器端JavaScript标准规范创建了一个模块规范,后者同样使用require()
函数,这种模块系统通常用来处理模块加载和依赖性管理。
模块化的目标是支持大规模的程序开发,处理分散源中代码的组装,并且能让代码正确运行,哪怕包含了作者所不期望出现的模块代码,也可以正确执行代码。为了做到这一点,不同的模块必须避免修改全局执行上下文,因此后续模块应当在它们所期望运行的原始(或接近原始)上下文中执行。这实际上意味着模块应当尽可能少地定义全局标识,理想状况是,所有模块都不应当定义超过一个(全局标识)。接下来我们给出的一中简单的方法可以做到这一点,
1. 用作命名空间的对象
在模块创建过程中避免污染全局变量的一种方法是使用一个对象作为命名空间。它将函数和值作为命名空间并将属性存储起来(可以通过全局变量引用),而不是定义全局函数和变量。例如我们前文定义的Set
类,它定义了一个全局构造函数Set()
。然后给这个类定义了很多实例方法,但将这些实例方法存储为Set.prototype
的属性,因此这些方法不是全局的。示例代码里也包含一个_v2s()
工具函数,但也没有定义它为全局函数,而是把它存储为Set
的属性。
模块的作者并不知道他的模块会和哪些其他模块一起工作,因此尤为注意这种命名空间的用法带来的命名冲突。然而,使用这个模块的开发者是知道它用了哪些模块、用到了哪些名字的。程序员并不一定要严格遵守命名空间的写法,只需要将常用的值“导入”到全局命名空间中。我们如果要经常使用sets
命名空间中的Set
类,可以这样导入
1 | var Set = sets.Set; // 将Set导入到全局命名空间中 |
有时模块作者会使用更深层嵌套的命名空间。如果sets
模块是另外一组更大的模块集合的话,它的命名空间可能会是collections.sets
,模块代码的开始会这样写
1 | var collections; // 声明(或重新声明)这个全局变量 |
最顶层的命名空间往往用来标识创建模块的作者或组织,并避免命名空间的明明冲突。使用很长的命名空间来导入模块的方式非常重要,然而程序员往往将整个模块导入全局命名空间,而不是导入单独的类。
1 | var sets = collections.sets; |
2. 作为私有命名空间的函数
模块对外导出一些公用API,这些API是提供给其他程序员使用的,它包括函数、类、属性和方法。但模块的实现往往需要一些额外的辅助函数和方法,这些函数和方法并不需要在模块外部可见。比如,Set
类中的_v2s()
函数,模块作者不希望Set
类的用户在某时刻调用这个函数,因此这个方法最好在类的外部是不可访问的。
可以通过将模块定义在某个函数的内部来实现。在一个函数中定义的变量和函数都属于函数的局部成员,在函数的外部是不可见的。实际上,可以将这个函数作用域用作模块的私有命名空间(有时称为“模块函数”)。
1 | // 声明全局变量Set,使用一个函数的返回值给它赋值 |
注意,这里使用了立即执行的匿名函数,这在JavaScript中是一种惯用法。如果想让代码在一个私有命名空间中运行,只需要给代码加上“(function() { ... })
”。开始的做圆括号确保这是一个函数表达式,而不是函数定义语句,因此可以给该前缀添加一个函数名来让代码变得更加清晰。
一旦将模块代码封装进一个函数,就需要一些方法导出其公用API,以便在模块函数的外部调用它们。在上文实例中,模块函数返回构造函数,这个构造函数随后赋值给一个全局变量。将值返回已经清楚地表名API已经导出在函数作用域之外。如果模块API包含多个单元,则它可以返回命名空间对象。对于sets
模块来说,可以将代码写成这样
1 | var collections; |
另外一种类似的技术是将模块函数当作构造函数,通过new
调用,通过将它们赋值给this
来将其导出
1 | var collections; |
作为一种替代方案,如果已经定义了全局命名空间对象,这个模块函数可以直接设置那个对象的属性,不用返回任何内容
1 | var collections; |