第13章 面向对象编程

第13章 面向对象编程

JavaScript是基于原型继承的机制来实现面向对象编程的。例如,对象直接量继承于Object.prototype,函数对象继承于Function.prototype,而prototype对象本身也继承于Object.prototype。每个函数对象在创建时也有一个prototype属性,它的值是一个拥有constructor属性且constructor属性值为该函数的对象。

在JavaScript中,如果类型相同或相近,则可以使用继承来抽象;如果类型不同,而功能相似,则可以使用接口来描述;如果一组类型都具有某种相似性,那么可以使用原型来概括;如果类型完全不同,但是仍然能使用工厂来关联它们。本章将深入探讨JavaScript面向对象编程的特性和应用技巧。

【学习重点】

▲ 定义JavaScript类

▲ 定义接口

▲ 掌握JavaScript原型模型和继承

▲ 了解封装、多态、构造和析构等编程概念

▲ 能够使用JavaScript设计复杂的面向对象程序

13.1 认识类

类主要借助两种方法来实现:归纳法和演绎法。

☑ 归纳法,就是由特殊到一般。在归纳的过程中,人们从具体的事物中把共同的特征抽取出来,形成一般的概念,这就是归类。例如,壁虎、狮子、鲫鱼、麻雀,因为它们都能够自由活动,所以被归类为动物;而松树、小草、浮萍、苔藓,因为它们都不能够自由活动,所以被归类为植物。

☑ 演绎法,就是由一般到特殊。在演绎的过程中,人们把同类的事物,根据不同的特征分成不同的小类,这就是分类。例如,动物可以细分为猫科动物,猫科动物可以细分为猫,而猫又细分为花猫、白猫等。

每一类都会包括很多个体,这些个体就是对象。类的内部状态是指类集合中对象的共同状态,类的运动规律是指类集合中对象的共同运动规律。例如,人是能够思维的高级动物,它被称为类,它的上面还包含父类(超类,如动物)、祖类(基类,如生命)等,而张三、李四、王五就是具体的人,这些具体的人就是对象。人与人生活在一起就构成了社会(即域),社会中各种交往活动就构成了人的生活规律。

类成员描述了类内部的各种事物,如常量、字段、构造器、方法、属性、事件、操作符、重载、类型等。JavaScript是一种弱类型语言,它没有这么多约定,只定义了基本成员,如属性、方法。成员的名称也被称为标识符。

属性(Property)是数据,而方法(Method)是函数。属性是类知道的事情,而方法是类准备完成的事情。属性和方法都是类的核心。

面向对象开发是基于这样的概念:系统应由对象来创建,对象拥有数据和功能。属性定义数据,而方法定义功能。

显然,在面向对象的开发中,最重要的工作就是创建类。而创建类时,就必须定义它的属性和方法。属性的定义应该是直接明了的,需要定义它的名称和数据类型。方法的定义就是创建一个函数的过程,根据需要,还可以创建能够接收参数且能够返回值的方法。

在面向对象的程序设计中,所有行为都是以事件驱动来实现的,这意味着假如没有任何事件发生,程序仅是多行文本字符串。事件(Event)定义了类的行为准则。

类的本质是抽象,抽象是一个分析的过程。抽象的过程就是定义类知道和要完成的事情的过程。因此,它具有3个基本特性,即继承、封装和多态。

☑ “继承”描述了类型关系,不同类之间经常会存在相似性。两个以上的类也会经常共享相同的属性或方法。利用继承机制可以快速实现代码的“复制和粘贴”。

☑ “封装”描述了类型的独立性。用户不知道类型的内部机制和实现,当然也不想知道,只知道如何使用类型即可。封装暗示着只要类型接口没有发生变化,那么对系统中一个功能部分的改变不会对系统的其他功能部分产生影响。

☑ “多态”描述了类型的宽容性。通过多态,可以在事先不知道对象的类型时就与它进行协作。即使类型不同,而类能够帮助处理这些问题。

13.2 定义类

Object、Function、Array等内置对象都是类,也称为构造函数。JavaScript提供了多种创建类的方法,本节将专门介绍定义类型的多种方法。

13.2.1 案例:工厂模式

【示例1】对象的属性可以在对象创建后动态定义,因此在JavaScript最初引入时,用户可以创建类似下面的对象。

在上面代码中,创建对象car。然后给它设置几个属性:它的颜色是蓝色,有4个门,每加仑油可以跑25英里。最后一个属性实际上是指向函数的指针,意味着该属性是个方法。执行这段代码后,就可以使用对象car。

【示例2】示例1存在一个问题:无法快速创建多个car的实例。解决方案:创造能创建并返回特定类型的对象的工厂函数。

下面函数createCar()可以封装上面列出的创建car对象的操作。

在这里,第一个例子中的所有代码都包含在createCar()函数中。此外,还有一行额外的代码,返回car对象(oTempCar)作为函数值。调用此函数,将创建新对象,并赋予它所有必要的属性,复制出一个car对象。因此,通过这种方法,可以很容易地创建car对象的两个版本(oCar1和oCar2),它们的属性完全一样。

【示例3】还可以修改createCar()函数,给它传递各个属性的默认值,而不是简单地赋予属性默认值。

给createCar()函数加上参数,即可为要创建的car对象的color、doors和mpg属性赋值。这使两个对象具有相同的属性,却有不同的属性值。

【示例4】在示例3中,每次调用函数createCar(),都要创建新函数showColor(),意味着每个对象都有自己的showColor()版本。而事实上,每个对象都共享同一个函数。因此可以在工厂函数外定义对象的方法,然后通过属性指向该方法,从而避免这个问题。

在上面这段重写的代码中,在函数createCar()之前定义了函数showColor()。在createCar()内部,赋予对象一个指向已经存在的showColor()函数的指针。从功能上讲,这样解决了重复创建函数对象的问题;但是从语义上讲,该函数不太像是对象的方法。

13.2.2 案例:构造函数模式

在JavaScript中,Object、Array、Function、RegExp、String等内置对象都是构造函数,使用new运算符可以调用它们,并初始化为一个个对象实例。

在JavaScript中,构造函数具有如下特性。

☑ 使用new运算符进行调用,也可以使用小括号调用,但返回值不同。

☑ 构造函数内部通过this关键字指代实例化对象,或者指向调用对象。

☑ 在构造函数内可以通过点运算符声明本地成员。当然,构造函数结构体内也可以包含私有变量或函数,以及任意执行语句。

【示例1】下面代码定义一个构造函数Box(),该对象是高度抽象的,通过this关键字来代称,当使用new运算符实例化构造函数时,可以通过传递参数来初始化这个对象的属性值。

由于每一个构造函数都定义了对象的一个类,所以给每个构造函数一个名字以说明它所创建的对象类就显得比较重要了,类名应该很直观,且首字母要大写(非强制的),主要是与普通函数进行区别。

【示例2】构造函数没有返回值,这是它与普通函数的一个最大区别。它们只是初始化由this关键字传递进来的对象,并且什么也不返回。但是,构造函数可以返回一个对象值,如果这样做,被返回的对象就成了new表达式的值。在这种情况下,this值所引用的对象就被丢弃了。

不过可以实例化构造函数,并调用该对象的属性。

当使用new运算符调用构造函数时,JavaScript会自动创建一个空白的对象,然后把这个空对象传递给this关键字,作为它的引用值。这样,this就成为新创建对象的引用指针了。然后在构造函数结构体内,通过为this赋值属性。

【示例3】使用构造函数定义一个Book类型,并定义两个参数,以便实例化对象时能够初始化实例对象。

13.2.3 案例:原型模式

在构造函数对象中定义prototype属性之后,则任何实例对象都将拥有prototype属性。

【示例1】在本示例中,先声明一个空构造函数,并利用构造函数的prototype属性为该构造函数定义原型属性title和pages,以及原型方法what()。构造函数的原型成员将会被所有实例对象继承。这样当使用new运算符实例化对象时,所有对象都拥有原型属性。

【示例2】prototype是构造函数的属性,而不是对象属性。例如,下面的用法是错误的,因为对象o是一个实例,它没有原型属性:

但是下面的用法是正确的:

这是因为JavaScript内置对象都是构造函数,因此它们都拥有prototype属性,并利用它为自己定义原型属性或原型方法,从而实现内置对象进行功能扩展。

13.2.4 案例:构造函数原型模式

原型模式存在两个问题。

☑ 由于构造函数事先声明,而原型属性在类结构声明之后才被定义,因此无法通过构造函数参数向原型属性动态传递值。这样该类实例化的所有对象都是一个模样,没有个性。要改变原型属性值,则所有实例都受到干扰。

☑ 当原型属性的值为引用类型数据时,如果在一个对象实例中修改该属性值,将会影响所有实例。

【示例1】看下面示例。

由于原型属性x的值为一个引用类型数据,因此所有对象实例的属性x的值都是指向该对象的引用指针。因此一旦某个对象的属性值被改动,其他实例对象的属性值也会随着发生变化。

而构造函数原型模式正是为了解决原型模式而诞生的一种混合设计模式,它是把构造函数模式与原型模式混合使用,从而避免了此类问题的发生。具体的方法是这样的。

对于可能会相互影响的原型属性,并且希望动态传递参数的属性,则拆分出来使用构造函数模式进行设计。而对于不需要个性,反而希望共享,且又不会相互影响的方法或属性,则单独使用原型模式来设计。

【示例2】遵循上述设计原则,对于13.2.3节示例1中存在的问题,可以把其中的两个属性设计为构造函数模式,而方法设计为原型模式。

一般在混合使用构造函数与原型模式时,可以不使用构造函数定义对象的所有非函数属性(即对象属性),而使用原型模式定义对象的函数属性(即对象方法)。这样所有方法都只创建一次,而每个对象都能够根据需要自定义属性值。

这种混合型杂交模式成为ECMAScript定义类的推荐标准,这也是使用最广的一种设计模式,它具有前面设计模式的所有优点,而且去除了不良副作用。

13.2.5 案例:动态原型模式

【示例1】根据面向对象设计原则,所有成员应该都被封装在类结构体内,因此可以这样完善13.2.4节示例的设计思路。

但是上面代码存在一个问题,就是当每次实例化时,类Book中包含的原型方法就会被创建一次,反复实例化,就会生成大量原型方法,浪费系统资源。

【示例2】可以使用if语句判断原型方法是否存在,如果存在就不再创建该方法,否则就创建方法。

typeof Book.isLock表达式能够检测该属性值的类型,如果返回为undefined字符串,则不存在该属性值,说明没有创建原型方法,则允许创建原型方法,并设置该属性的值为true,这样就不用重复创建原型方法。这里使用类名Book,而没有使用this关键字,这是因为原型是属于类本身的,而不是对象实例的。

动态原型模式与构造函数原型模式在性能上是等价的,用户可以自由选择,不过构造函数原型模式应用比较广泛。

13.3 接口

接口与类都是编程中比较抽象的概念,都是不同类的封装,并提供统一的外部联系通道,这样其他对象就可以利用接口来调用不同类的成员。

13.3.1 认识接口

接口(Interface)承诺了具体类应该实现的功能。在Java或C#语言中,Interface结构中的函数声明就是这种承诺。

【示例】Base接口承诺了3个基本功能:function1()、function2()、function3(),则Java可以这样书写:

用户需要负责实现接口约定的功能。实现在编程语言中使用implements关键字来表示,功能的实现者就是所谓的类(即Class)。

3个功能函数function1()、function2()、function3()的具体实现代码也要放在类App中,具体实现如下:

接口(interface)和类(class)实际上都是相同的数据结构。接口中可以声明属性、方法、事件和类型,但不能声明变量,且不能设置被声明成员的具体值。也就是说,接口只能定义成员,不能给定义的成员赋值。而接口作为它的继承类或者派生类的契约,继承类或者它的派生类应共同完成接口属性、方法、事件和类型的具体实现。

13.3.2 定义接口

JavaScript不支持类,更不支持接口。JavaScript不会识别用户定义的接口结构,也不会按着这个接口规则约束定义的类,以及实例化后类的方法。用户可以模拟接口设计模式,具体方法如下:

(1)设计一个接口辅助的类结构。这个构造函数相当于过滤器,用来在接口实例化过程中监测初始化参数是否合法。如果符合接口设计标准,则把第二个参数中每个方法名和参数个数以数组元素的形式输入接口内部属性methods。在输入前分别检测每个参数的类型是否符合规定,同时检查参数是否存在残缺,并即时以0补齐参数。

(2)为接口辅助类Interface定义一个方法implements,该方法将检测实现类(即继承该接口实例标准的构造函数)是否符合接口实例的约定。它至少包含两个参数,第一个参数o表示实现类,第二个参数及其后面参数表示该类将要实现的接口标准。也就是说,可以为一个类指定多个接口约定,这样就能够更灵活地分类设计接口实例。

然后遍历第二个及其后面所有参数,在循环结构中,先检测接口是否为接口标准的实例,否则就会抛出异常。再从接口实例的方法存储器中逐一读取方法名,输入类中来验证类的方法是否符合接口实例设置的标准,验证包括方法名、function类型和参数个数。如果发现问题,则会立即抛出异常。

(3)实例化接口标准。Interface接口仅是一个构造函数,也就是一个框架协议,还没有指定类应该遵循的具体标准。框架协议中已经设计好了监测逻辑,一旦实例化接口,并指明类应遵守的约定,那么应用该标准实例的类就必须遵守。为了帮助用户更清楚理解接口标准(接口构造函数、接口类)、接口实例的关系,这里根据Interface接口标准定义了6个具体的接口实例:

(4)在类中定义应该实现的标准,即类应该遵循的接口约定。

(5)在类中设置多个接口实例,但要确保类中定义了接口实例中约定的方法,否则就会报错。上面代码中为Neddy绑定了3个接口实例,由于类中所定义的方法都符合它们的约定,所以可以正常执行。但是如果指定其他3个接口实例中一个或多个,就会抛出异常。这是因为当前类Neddy还没有实现它们的约定,即没有定义方法saying()。

13.3.3 使用建议

接口的目的就是要约束编码,促使代码规范。这对于强类型的语言来说是必需的,也是非常重要的环节。但是对于JavaScript这类弱类型的语言,严格的类型检查往往会束缚JavaScript的灵活性。

不过,使用接口可以降低对象间的耦合度,反而又提高了代码的灵活性。学会使用接口,能够让函数变得灵活而又乖巧,这在大型开发中是非常重要的。

在大型项目中,会有无数的功能块,以及外部API。很多功能块或API可能还没有开发出来,但是在项目部署中又必须考虑这些问题。这时如果定义一个或多个接口,让接口约定功能块和API的实现,然后开发人员就可以根据规划接口来开发并实现具体的功能块。

当然,针对JavaScript来说,使用接口还存在很多好处。例如,接口能够实现类之间的通信,不管类之间的功能如何迥异,只要它们拥有相同的接口,类之间的交流就不成问题。另外,使用接口能够帮助监测代码运行,这使JavaScript调试变得方便很多。

JavaScript语言特性决定了接口无法推广应用。首先,JavaScript是弱类型语言,强制约束性显得不是那么必要。其次,JavaScript不支持接口功能,没有提供内置方法,如果人工设计一个额外的接口程序,这将对程序的性能产生一定的影响。项目越是很大,这种开销可能也就越大。最后,JavaScript中的接口仅是一种期望,而不是强迫,这与Java或C#等强类型语言不同。在JavaScript中,则更多地体现在开发人员的自觉行为,这在一定程序上减少了接口的实用价值。

用户可以遵循下面的条件确定是否定义接口:

☑ 项目的大小。如果是一个框架,使用接口能够提升程序的性能;如果是一些简单的应用,使用接口就有点费力不讨好了。

☑ 量力而行。如果担心接口额外影响程序的性能;则可以考虑在发布产品时,清除接口功能模块,或者设置接口执行条件,防止它被频繁执行,这样也没有必要。

13.4 原型模型

在JavaScript中,普通对象没有原型,仅有构造函数拥有原型,而类型的实例能通过prototype访问原型,实现原型继承机制。

13.4.1 认识prototype

prototype是对象类的原型。属性prototype是在函数作为构造函数时使用的。它引用的是作为整个对象类的原型的对象。由这个构造函数创建的任何对象都会继承属性prototype引用的对象的所有属性。

从语义角度分析,prototype表示“类的原型”,就是构造类拥有的公共成员。

从语法角度分析,prototype实际上是构造函数对象的一个属性,该属性仅供构造函数使用,其他对象都无权调用。prototype属性引用一个叫原型的对象,它是一个特殊的普通对象,存储着构造函数的公共属性和方法。借助prototype属性,可以访问原型对象内部成员,也可以操作它们。当构造函数实例化后,所有被创建的实例对象都可以继承prototype,访问只读的原型对象。因此,在原型对象中声明一个成员,则所有实例对象都可以访问。

原型本质上也是一个普通对象,继承于Object抽象类,不过它比较特殊,由JavaScript自动创建并依附于每个构造函数,从而实现构造类的原型属性和原型方法能够被所有实例对象继承。原型在JavaScript对象系统中的位置和关系如图13-1所示。

图13-1 原型对象、原型属性及在对象系统中的位置关系

【拓展】在JavaScript中,对象是一个比较模糊的概念,任何事物都可以是对象,对象应该是类(Class)和实例(Instance)的关系演化。类是对象的模型化,而实例则是类的具体化。类又包含很多概念类型,如元类、超类、泛类和类型等,这些概念描述了类的不同抽象程度。

在JavaScript中,不支持类概念,所谓的类就是定义构造函数。

使用instanceof运算符可以验证它们的关系:

instance1和instance2都是对象,但是Class构造函数不是它们唯一的类型,Object也是它们的类型。

因为Object对象比Class类型更加抽象,它们之间应该属于一种继承关系:

     alert(Class instanceof Object);       //返回true,说明Class类是Object对象的实例(或子类)

但是instance1和instance2对象却不是Function构造函数的实例,说明它们之间没有关系:

而Object与Function都是高度抽象的类型,相互之间都是对方的实例,因为它们都是高度抽象的超类。

Object与Function同时也是两个不同类型的构造函数,利用运算符new,可以创建不同类型的实例对象:

下面两行代码能够说明它们实例化对象之后的差异:

总之,实例对象、类、Object(抽象类)和Function(构造类)之间的关系如图13-2所示。

13.4.2 定义原型

JavaScript是一种动态语言,它的对象系统也是动态的,原型对象也不例外。用户可以通过点语法和function.prototype方式定义原型,从而影响所有实例对象。

图13-2 泛类、类型、原型和对象实例之间的关系

【示例1】在代码中为构造函数定义原型。

【示例2】如果给构造函数定义了与原型属性同名的本地属性,则本地属性会覆盖原型属性值。

【示例3】如果使用delete运算符删除本地属性,则原型属性会被访问。在示例2基础上删除本地属性,则会发现可以访问原型属性。

【示例4】利用对象原型与本地属性之间这种特殊关系,可以设计如下有趣的演示效果。

上面示例定义了构造函数p(),并声明3个本地属性。然后实例化构造函数,并把实例对象赋值给构造函数的原型对象。同时定义原型方法del(),该方法将删除实例对象的所有本地属性和方法。最后,分别调用属性x、y和z,则返回的是本地属性值,调用方法del(),删除所有本地属性,则再次调用属性x、y和z,返回的是原型属性值。

13.4.3 案例:原型属性和本地属性

【示例1】在本示例中,演示如何定义一个构造函数,定义本地属性。

构造函数f()中定义了两个本地属性,分别是属性a和方法b()。当构造函数实例化后,实例对象继承了构造函数的本地属性。此时可以在本地修改实例对象的属性a和方法b()。

     e.a=2;
     alert(e.a);
     alert(e.b());

【示例2】本地属性可以在实例对象中被修改,但是不同实例对象之间不会相互响应。

上面示例演示了如果使用本地属性,则实例对象之间就不会相互影响。但是如果希望统一修改实例对象中包含的本地属性值,就需要一个个修改。当构造函数的实例非常多时,手工修改的工作量会非常大。

【示例3】原型属性将会影响所有实例对象,修改任何原型属性值,则该构造函数的所有实例都会看到这种变化,这样就避免了本地属性修改的麻烦。

在上面示例中,原型属性值会影响所有实例对象的属性值,对于原型方法也是如此,这里就不再说明。原型属性或原型方法可以在构造函数结构体内定义。

prototype属性属于构造函数,所以必须使用构造函数通过点语法来调用prototype属性,再通过prototype属性来访问原型对象。

原型属性与本地属性之间的关系如图13-3所示。

图13-3 原型属性与本地属性之间的关系

Object和Function都可以定义原型,此时Object被视为Function的子类,下面的示例能够很好地说明Object和Function原型的异同,它们的属性与原型关系如图13-4所示。

图13-4 Function、Object、Prototype及其属性间的关系

13.4.4 案例:应用原型

下面通过几个实例介绍原型在代码中的应用技巧。

【示例1】利用原型为对象设置默认值。

当原型属性与本地属性同时时,它们之间可以出现交流现象。利用这种现象为对象初始化默认值。

【示例2】利用原型间接实现本地数据备份。

把本地对象的数据完全赋值给原型对象,相当于为该对象定义一个副本,通俗地说就是备份对象。这样当对象属性被修改时,可以通过原型对象来恢复本地对象的初始值。

【示例3】利用原型设置只读属性。

利用原型还可以为对象属性设置“只读”特性,这在一定程序上可以避免对象内部数据被任意修改的尴尬。

下面示例演示了如何根据平面上两点坐标来计算它们之间的距离。构造函数p()用来设置定位点坐标,当传递两个参数值时,会返回以参数为坐标值的点,如果省略参数则默认点为原点(0,0)。而在构造函数l()中通过传递的两点坐标对象,计算它们的距离。

在测试中会发现,如果无意间修改了构造函数l()的方法b()或e()的值,则构造函数l()中的length()方法的计算值也随之发生变化。这种动态效果对于需要动态跟踪两点坐标变化来说,是非常必要的。但是,这里并不需要当初始化实例之后,随意地被改动坐标值。毕竟方法b()和f()与参数a和b是没有多大联系的。

为了避免因为改动方法b()的属性x值会影响两点距离,可以在方法b()和e()中,新建一个临时性的构造类,设置该类的原型为a,然后实例化构造类并返回,这样就阻断了方法b()与私有变量a的直接联系,它们之间仅就是值的传递,而不是对对象a的引用,从而避免因为方法b()的属性值变化,而影响私有对象a的属性值。

还有一种方法,这种方法是在给私有变量w和h赋值时,不是赋值函数,而是函数调用表达式,这样私有变量w和h存储的是值类型数据,而不是对函数结构的引用,从而就不再受后期相关属性值的影响。

【示例4】利用原型进行批量复制。

上面的代码演示了如何复制100次同一个实例对象。这种做法本无可非议,但是如果在后期修改数组中每个实例对象时,就会非常麻烦。现在可以尝试使用原型来进行批量复制操作:

把构造类f的实例存储在临时构造类的原型对象中,然后通过临时构造类temp实例来传递复制的值。这样,要想修改数组的值,只需要修改类f的原型即可,从而避免逐一修改数组中每个元素。

13.4.5 认识原型域和原型域链

原型域与函数域功能和用法相似,但是它们属于不同的域范畴。在函数作用域中,有一个作用域链,用来方便检索局部变量,同时原型域也有一个原型域链,它用来方便检索原型属性。

在JavaScript中,实例对象在读取属性时,总是先检查自身域的属性,如果存在,则会返回本地属性值,否则就会往上检索prototype原型域,如果找到同名属性,则返回prototype原型域中的原型属性。

prototype原型域可以允许原型属性引用任何类型的对象。如果在prototype原型域中没有找到指定的属性,则JavaScript将会根据引用关系,继续向外查找prototype原型域所指向对象的prototype原型域,直到对象的prototype域为它自己,或者出现循环为止。

【示例1】本示例演示了对象属性查找原型的基本方法和规律。

原型域链能够帮助用户更清楚地认识JavaScript面向对象的本质。每个对象实例都有属性成员用于指向它的构造函数的原型(Prototype),可以把这种层层指向父原型的关系称为原型域链(Prototype Chian),如图13-5所示。原型也具有父原型,因为它往往也是一个对象实例,除非人为地去改变它。

图13-5 原型链检索示意图

【示例2】在JavaScript中,一切都是对象,函数是第一型。Function和Object都是函数的实例。构造函数的父原型指向Function的原型,Function.prototype的父原型是Object的原型,Object的父原型也指向Function的原型,Object.prototype是所有父原型的顶层。

13.4.6 原型副作用

prototype是一种模拟面向对象的机制,它通过原型实现对类与实例之间的关系,并进行管理。以prototype机制模拟继承机制是一种原型继承,它也是JavaScript的核心功能之一。但是,prototype真正价值在于它能够以对象结构为载体,创建大量的实例,这些实例能够在构造类下实现共享。也正因为如此,很多人利用prototype的这个特性模拟对象的继承机制。但是,用户应该清楚,prototype模拟继承是其一个重要的应用,但不是它的核心价值。因为JavaScript还可以使用其他途径实现对象继承机制。即使不使用prototype模拟继承机制,JavaScript的prototype原型机制也是面向对象的一个重要的功能。当然,prototype原型机制在应用中也存在很多问题。

原型域不是一种值复制行为,而是一种值引用现象,通过13.4.5节原型链,用户能够明白这个道理。原型的引用关系也容易带来副作用。如果改变某个原型上的引用类型的属性值,将会影响到该原型作用的所有实例对象,这种动态影响会给程序带来安全隐患。

【示例】

上面示例演示了原型对于不同实例的影响。构造函数a中的x属性值为数组,数组是引用类型的数据,而构造函数的原型引用a的实例,于是也就直接引用了数组的指针,从而导致了实例之间的这种相互影响现象。

13.5 继承

在面向对象编程语言中,对象系统的继承机制有3种:基于类、基于原型和基于元类。JavaScript使用原型继承机制设计对象系统。原型继承是JavaScript最重要的语言特性之一,才使该语言拥有丰富、多变且适用于动态编程的对象系统。

13.5.1 认识JavaScript继承机制

继承是面向对象的基础,它是代码重用的一种重要机制。

说到继承,其实它与生活息息相关。例如,对于生命来说,最早源于细菌,随后经过漫长的进化,出现两种生存状态,即植物和动物。水仙是植物演化的一种形态,它具有嗜水特性,而仙人掌具有抗旱特性,不过它们都属于植物类型。鱼、鸟和猿属于动物类型,其中人类又是从猿类演化而来的。这种简单的进化论就构成了一种继承关系。

在这个例子中,细菌是动物和植物的基类(Base Class),所有的生命体都是从它继承而来。水仙和仙人掌属于植物进化的一种形态,因此它们是植物的子类(Sub Class),而植物也是水仙和仙人掌的超类(Super Class)。同样,鱼、鸟和猿属于动物细胞的子类,动物细胞是它们的超类。最后,人继承于猿。如果使用UML(统一建模语言)以图示来表示,则如图13-6所示。

图13-6 类继承示意图

图13-6可视化表示复杂的继承关系,其中每个方框表示一个类,由类名说明。箭头线表示类的继承关系,其中指向方框为超类。

在JavaScript中,要想实现相同的继承关系,就必须采取一系列的措施。

首先,应从基类入手。所有定义的类都可以作为基类。不过出于安全原因,本地类和宿主类不能够作为基类,这是因为这些代码可以被用于恶意攻击。

选定基类之后,就可以创建它的子类,是否使用基类完全根据需要而定。有时还可以创建不能直接使用的基类,它只能够用于给予子类提供通用函数,这时可以把它看作是抽象类。

创建子类将会继承超类的所有属性和方法,包括构造函数及方法的实现。当然它的所有属性和方法都是公用的。因此,子类可以访问这些方法。子类还可以添加超类中没有的属性和方法,也可以覆盖超类中的属性和方法,这些都应根据开发需要而定。因此,要实现继承,其实就是实现下面3层含义。

☑ 子类的实例可以共享超类的属性和方法。

☑ 子类可以覆盖和扩展超类的属性和方法。

☑ 子类和超类都是子类实例的类型。

让一个类继承另一个类可能会导致它们之间产生耦合性,也就是说,一个类依赖于另一个类的内部实现。当然,JavaScript也提供了多种技术途径来避免这种问题的发生,如掺元类等。

与其他功能一样,JavaScript实现继承的方法不止一种,这是因为JavaScript继承机制不是规范的语法,仅是通过模仿各种方法来实现的。这意味着所有的继承细节并非完全由解释程序进行处理。它主要包括类继承、原型继承、实例继承、复制继承和混合继承等,下面分节进行详细讲解。

13.5.2 原型继承

原型继承是一种简化的继承机制,也是JavaScript最流行的一种继承方式。在原型继承中,类和实例概念被淡化了,一切都从对象的角度来考虑。原型继承不再需要使用类来定义对象的结构,直接定义对象,并被其他对象引用,这样就形成了一种继承关系,其中引用对象被称为原型对象(Prototype Object)。JavaScript能够根据原型链来查找对象之间的这种继承关系。

【示例】下面使用原型继承的方法设计类型继承。

在上面示例中,分别定义了3个函数,然后通过原型继承方法把它们串联在一起,这样C能够继承B和A函数的成员,而B能够继承A的成员。prototype的最大特点就是能够允许对象实例共享原型对象的成员。因此如果把某个对象作为一个类型的原型,那么说这个类型的实例以这个对象为原型。这时实际上这个对象的类型也可以作为那些以这个对象为原型的实例的类型。

此时,可以在C的实例中调用B和A的成员。

基于原型的编程是面向对象编程的一种特定形式。在这种编程模型中,不需要声明静态类,而是通过复制已经存在的原型对象来实现继承关系的。因此,基于原型的模型没有类的概念,原型继承中的类仅是一种模拟,或者说是沿用面向对象编程的概念。

原型继承显得非常简单,其优点也是结构简练,不需要每次构造都调用父类的构造函数,且不需要通过复制属性的方式就能快速实现继承。但是它也存在以下几个缺点。

☑ 每个类型只有一个原型,所以它不直接支持多重继承。

☑ 它不能很好地支持多参数或者动态参数的父类。也许在原型继承阶段,用户还不能决定以什么参数来实例化构造函数。

☑ 使用不够灵活。用户需要在原型声明阶段实例化父类对象,并把它作为当前类型的原型,这限制了父类实例化的灵活性,很多时候无法确定父类对象实例化的时机和场所。

☑ prototype属性固有的副作用。

原型继承实际上是类继承的一种简化版,无法完全达到标准的类继承的效果。实际上,当父类是一个简单、抽象的模型或者一个接口时,原型继承应该是最好的选择,它也是JavaScript语法支持的继承机制。

13.5.3 类继承

类继承也被称为构造函数继承,或称对象模拟法。其表现形式是:在子类中执行父类的构造函数。其实现本质是:构造函数也是函数,与普通函数相比,它只不过是一种特殊结构的函数而已。所以可以为一个构造函数(如A)的方法赋值为另一个构造函数(如B),然后调用该方法,使构造函数A在构造函数B内部被执行,这时构造函数B就拥有构造函数A中定义的属性和方法,这就是所谓B类继承A类。

【示例1】下面使用类继承的方法实现继承。

由于构造函数能够使用this关键字为所有属性和方法赋值。在默认情况下,关键字this引用的是构造函数当前创建的对象。不过在这个方法中,this不是指向当前正在使用的实例对象,而是调用构造函数的方法所属的对象,即构造函数B。此时,构造函数A已经不是构造函数结构了,而被视为一个普通执行函数。

【示例2】在示例1中,函数之间的引用和传递正是类继承的基础,还可以设计为一个类继承多个类,本示例中类C继承了类A和类B的所有成员。

在上面的示例中,没有使用delete运算符删除临时方法m(),而是通过覆盖的方式代替,当然不会影响继承关系的实现。

【示例3】示例2的设计模式太随意,缺乏严密性。严谨的设计模式应该考虑到各种可能存在的情况和类继承关系中的相互耦合性。为了更直观地说明,先看一个示例:

在上面代码中,先创建一个构造函数,定义一个类,类名是构造函数的名称A。在结构体内使用this关键字创建本地属性x。方法getx()被放在类的原型对象中,它也就成为公共方法。然后,借助new运算符调用构造函数,返回的结果是新创建的对象实例:

     var a1=new A(1);  //实例化类A为对象a1

最后,对象a1就可以继承类A的本地属性x,也有人称之为实例属性,当然还可以访问类A的原型方法getx():

上面的代码是一个简单的类演示例子。现在,再创建一个类B,让其继承类A,实现的代码如下:

在JavaScript中实现类的继承,需要考虑和设置下面3点。

(1)在构造函数B的结构体内,使用函数call()调用构造函数A,把B的参数x传递给调用函数。让B能够继承A的所有属性和方法,即A.call(this,x);语句行。

(2)在构造函数A和B之间建立原型链,即B.prototype=new A();语句行。在JavaScript中每个构造类都有一个名为prototype的属性,该属性指向一个对象。当在访问对象某个成员时,如果在当前域中没有找到,则会根据prototype属性指向的对象并沿着这个原型链不断向上查找,直到找到为止,否则只检索到顶级域链。因此,为了实现类之间的继承,必须保证它们是原型链上的上下级关系,即设置子类的prototype属性指向超类的一个实例即可。

(3)恢复B的原型对象的构造函数,即B.prototype.constructor=B;语句行。当定义构造函数时,其原型对象(prototype属性值)默认是一个Object类型的一个实例,其构造器(constructor属性值)会被默认设置为该构造函数本身。如果改动prototype属性值,使其指向另一个对象,那么新对象就不会拥有原来的constructor属性值,所以必须重新设置constructor属性值。

此时,就可以在子类B的实例对象中调用超类A的属性和方法:

【示例4】再看一个更复杂的多重继承的实例。

上面示例的代码较长,不过思路很简单。

设计类C继承类B,而类B又继承了类A。A、B、C 3个类之间的继承关系是通过在子类中调用父类的构造函数来维护的。例如,C类中添加语句行B.apply(this, arguments);,该行语句能够在B类中调用A类,并把B的参数传递给A,从而使B类拥有A类的所有成员。同理,在B类中添加语句行A.call(this, a.length);,该行语句把B类的参数长度作为值传递给A类,并进行调用,从而实现B类拥有A类的所有成员。

从继承关系上看,B类继承了A类的本地方法getl(),为了确保它还能够继承A类的原型方法,还需要为它们建立原型链,从而实现原型对象的继承关系,方法是添加语句行B.prototype=new A();。同理,在C类中添加语句行C.prototype=new B();,这样就可以把A、B和C通过原型链串联在一起,从而实现子类能够继承超类成员,甚至还可以继承基类的成员。这里的成员主要指类的原型对象包含的成员,当然它们之间也可以通过相互调用,实现对本地成员的继承关系。

用户还应该注意原型继承中的先后顺序,当为B类的原型指定为A类的实例前,不能够再为其定义任何原型属性或方法,否则就会被覆盖。如果要扩展原型方法,只有在原型绑定之后,再定义扩展方法。

13.5.4 案例:封装类继承模式

在面向对象编程中,语言自身都有一套严格的封装机制,但是JavaScript语言没有提供良好的封装机制,只能够依靠手工方式实现部分功能封装。下面尝试把类继承模式封装起来,以便规范代码应用。首先,定义一个封装函数。设计入口为子类和超类对象,函数功能是子类能够继承超类的所有原型成员,不设计出口。

在函数体内,首先定义一个空函数F(),用来实现功能中转。设计它的原型为超类的原型,然后把空函数的实例传递给子类的原型,这样就避免了直接实例化超类可能带来的系统负荷。因为在实际开发中,超类的规模可能会很大,如果实例化,会占用大量内存。

然后,恢复子类原型的构造器为子类自身。同时,检测超类原型构造器是否与Object的原型构造器发生耦合,如果是,则恢复它的构造器为超类自身。

一个简单的功能封装函数就这样实现了。下面定义两个类,尝试把它们绑定为继承关系。

在继承类封装函数中,有这么一句Sub.sup=Sup.prototype;,在上面的代码中没有被利用,那么它有什么作用呢?

为了解答这个问题,先看下面的代码:

上面的代码是在调用封装函数之后,再为B类定义了一个原型方法,该方法名与A类中原型方法add()同名,但是功能不同。如果此时测试程序,会发现子类B定义的原型方法add()将会覆盖超类A的原型方法add()。如下:

     alert(f.add())                       //返回字符串55,而不是数值10

如果在B类的原型方法add()中调用超类的原型方法add(),从而避免代码耦合现象发生:

13.5.5 实例继承

类继承和原型继承在客户端中是无法继承DOM对象的,同时它们也不支持继承系统对象和方法。为了方便理解,先看两个示例。

【示例1】使用类继承法继承Date对象:

上面的示例演示说明,使用类继承是无法实现对静态对象的继承的,这是因为系统对象的结构比较特殊,它不是简单的函数体结构,声明、赋值和初始化等操作都进行了独立的封装,所以也就无法实现在自定义构造函数中的那种操作。

【示例2】使用原型继承法继承Date对象:

上面的示例演示说明了使用原型继承也是无法实现相同的目的。不过,使用实例继承法能够实现对所有JavaScript核心对象的继承。

【示例3】在本示例中,把Date对象的实例化过程和方法调用封装在一个函数中,然后返回实例对象,这样就可以解决核心静态对象无法继承的问题。如下:

构造函数是一种特殊结构的函数,它没有返回值,通过this关键字来初始化实例对象,并且会返回值。当然,在构造函数中可以增加return语句,为其设置一个返回值,这时返回值就是new运算符执行表达式的值。因此,通过在构造函数中完成对类的实例化操作,然后返回实例对象,这就是实例继承的由来。

使用实例继承法能够实现对所有对象的继承,包括自定义类、核心对象和DOM对象等。不过实例继承不是真正的继承机制,仅是一种模拟方法。

☑ 实例继承法无法传递动态参数。由于类的实例化操作是在封闭的函数体内实现的,而不能够通过call()或apply()方法来传递动态参数。如果继承需要传递动态参数,则这种继承就会带来很多不便。

☑ 实例继承只能够返回一个对象,与原型继承一样,不支持多重继承。

☑ 由于通过封装的方法把对象实例化,以及初始化操作都被封装在一个函数体内,最后通过对封装函数执行实例化操作来获取继承的对象。但是这种做法无法真正实现继承对象是封装类的实例,它仍然保持与原对象的实例关系。

13.5.6 复制继承

复制继承是最原始的方法,其设计思路是:利用for/in语句遍历对象成员,然后逐一复制给另一个对象,通过这种蚂蚁搬家的方式从而实现继承关系。

【示例】在本示例中,定义一个F类,其包含4个成员。然后实例化并把它的所有属性和方法都复制给一个空对象o,这样对象o就拥有了F类的所有属性和方法。

对于该复制继承法,还可以进行封装,使其具有较大的灵活性:

上面的封装函数通过原型对象为Function核心对象扩展一个方法,该方法能够把指定的参数对象完全复制给当前对象的构造函数的原型对象。

this关键字指向的是当前实例对象,而不是构造函数本身,所以要为其扩展原型成员,就必须使用constructor属性来指向它的构造器,然后通过prototype属性指向构造函数的原型对象。

然后,新建一个空的构造函数,并为其调用extend()方法把传递进来的F类的实例对象完全复制为原型对象成员。请注意,此时就不能够定义对象直接量,因为extend()方法只能够为构造函数结构复制继承:

从本质上分析,复制继承法也不是真正的继承,它实际上是通过反射机制复制类对象的所有可枚举属性和方法来模拟继承。这种方法能够实现模拟多继承。不过,它的缺点也很明显,如下所示:

☑ 由于是反射机制,复制继承法不能继承非枚举类型的方法。对于系统核心对象的只读方法和属性也是无法继承的。

☑ 通过反射机制来复制对象成员的执行效率会非常差。当对象结构越庞大时,这种低效就越明显。

☑ 当前类型如果包含同名成员,这些成员可能会被父类的动态复制所覆盖。

☑ 在多重继承的情况下,复制继承不能够清晰地描述出父类与子类的相关性。

☑ 只有当类实例化之后,才能够实现遍历成员和复制成员,所以它不能够灵活支持动态参数。

☑ 由于复制继承法仅是简单地引用赋值,如果父类的成员值包含引用类型,那么继承之后,与原型继承法一样容易带来很多副作用。

13.5.7 克隆继承

针对复制继承方法,还可以对其进行适当优化,通过对象克隆方式来实现,这样就可以避免一个个复制对象成员所带来的低效率。具体方法如下:

首先,为Function对象扩展一个方法,该方法能够把参数对象赋值给一个空构造函数的原型对象,然后实例化构造函数,并返回实例对象,这样该对象就拥有构造函数包含的所有成员。

然后,调用该方法来克隆对象。克隆方法返回的是一个空对象,不过它存储了指向给定对象的原型对象指针。这样就可以利用原型链来访问它们,从而在不同对象之间实现继承关系。

13.5.8 混合继承

混合继承是把多种继承方法结合在一起使用,从而发挥各自优势,扬长避短,以实现各种复杂的应用。其中最常见的形式是把类继承与原型继承混合使用,来解决类继承中存在的问题。

类继承与原型继承是两种截然不同的继承模式,它们生成的对象的行为方式也是不同的。原型继承更能节约内存。原型链读取成员的方式使得所有克隆出来的对象都共享一个实例,只有在直接设置了某个克隆出来的对象的属性和方法时,情况才会有所变化。而在类继承方式中,创建的每一个对象在内存中都有自己的一套属性和方法副本。原型继承在这方面的节约效果很突出。这种继承也比类继承显得更为简练,用户不要把原型继承的简洁看作是简陋,它的力量蕴涵在其简洁性之中。

【示例】下面的示例混合使用了多种方法实现继承。

上面的示例把原型继承和类继承混用在一起,从而实现一种比较完善的继承机制。用户也可以把原型继承与复制继承混合使用,也能够实现相同的继承效果。下面简单比较一下几种继承方法的异同,如表13-1所示。

表13-1 继承方法综合比较

13.5.9 多重继承

继承一般包括单向继承和多重继承两种模式。其中单向继承模式比较简单,每个子类有且仅有一个超类,而多重继承是一种比较复杂的继承模式。在这种模式中,一个子类可以拥有任意多个超类,如图13-7所示。其中SupClass表示超类,而SubClass表示子类,类方框中间一栏为属性,下面一栏为方法,属性和方法后面的关键字表示成员的数据类型。

图13-7 类的多重继承示意图

JavaScript原型继承机制不支持多重继承,不过可以通过混合模式来实现多重继承。

【示例】设计3个类型A、B、C,现在希望C能够同时继承A和B的属性和方法。

为了实现多重继承,唯一可以采用的方法是使用复制继承,或者结合类继承来实现该目的。首先,定义一个复制继承扩展函数:

然后,使用类继承方法在C中调用A和B:

再调用复制继承扩展函数来复制A和B的属性和方法:

此时,如果在C对象中,就可以调用A和B的属性和方法。请注意,通过复制继承之后,C不再是一个构造函数,它实际上变成了一个具体的实例对象,因此不需要实例化,而直接调用:

13.5.10 掺元类

13.5.9节讲解了如何让一个类继承多个类,现在再来讨论如何让一个类被多个类继承,如图13-8所示。这种继承关系被形象地称为多亲继承,其中能够被多个类继承的类,被称为掺元类。掺元类是一种比较特殊的类形式,它一般不会被实例化或调用。定义掺元类的目的只是向其他类提供通用方法。

如果希望某个函数被多个类调用,可以通过扩充的方式让这些类共享该函数。具体的设计思路是:先创建包含通用函数的超类,然后利用这个超类扩充子类,这种包含通用方法的类就可以称为掺元类。

【示例1】先设计一个掺元类F,设想定义两个子类A和B,希望子类A和B能够继承掺元类F的通用方法getx()和gety()。代码如下:

图13-8 多亲继承示意图

然后,定义两个子类A和B,利用类继承方法先继承掺元类中的本地属性,以方便继承的方法正确获取值。在实际应用中不用类继承来继承掺元类的本地属性和方法。如下:

如果让A类和B类都继承F类,可以使用原型继承方法来实现,但是原型继承需要实例化类F。可以模仿复制继承方法设计一个专门函数来实现这种继承关系。具体代码如下:

该函数很简单,使用for/in循环遍历掺元类的原型对象中的每一个成员,并将其添加到子类的原型对象中。如果子类中已存在同名成员,则跳过该成员,转而处理下一个,这样能够确保子类原型对象中的成员不会被改写。

有了这个封装函数,就可以直接调用来快速生成多个相同的子类。传递子类参数必须事先声明,且应通过类继承方法,继承F的本地属性和方法:

最后,实例化A和B,就可以调用F定义的通用方法,代码如下:

【示例2】也可以利用这种方法,把多个子类合并到一个类中,实现多重继承。本示例定义了两个类A和B,并分别为它们定义两个原型方法。

面向对象中并不是所有的事物泛型都是使用继承关系来描述的,继承关系只是泛型关系的一种,除此之外,创建关系、原型关系、聚合关系、组合关系等,都是泛型的一种类型,泛型概念很宽泛,通常使用继承、聚合和组合来描述事物的名词特性,而使用原型、元类等其他概念来描述事物的形容词概念。

13.6 封装

封装(Encapsulation)就是把对象内部数据和操作细节进行隐藏。很多面向对象语言都支持封装特性,要想访问被封装对象中的数据,只能使用对象专门提供的接口,接口一般为方法,调用接口方法能够获取对象内部数据。JavaScript不支持封装操作,不过可以使用闭包函数来模拟类型封装。

13.6.1 被动封装

被动封装,就是对对象内部数据进行适当约定,这种约定没有强制性,它主要针对公共对象而言。一般来说,JavaScript类对象所包含的数据都是公开的,没有隐私可言,任何人都可以访问其中的信息。

【示例1】下面的代码使用了条件语句来对参数进行检测,并限制用户的输入。

【示例2】为了安全,代码中适当增加了一些条件限制,避免非法侵入。用户可以增加更完善的监测方法,以保护输入数据的完整性。

上面代码仅列出了各种方法的框架。从安全和扩展的角度分析,凡是类都应该定义接口,这样能够确保数据存取更加安全,同时也方便与其他开发人员和用户进行交流。

内部私有方法监测和接口措施能够在一定程序上保护对象内部数据,但是它们也存在一个致命的漏洞,因为这些属性和方法可以被公开重置,面对公开覆盖属性和方法值,任何人都无法阻止这种行为。不管是有意还是无意操作,属性都可能会被设置为无效值。同时内部检测和接口在一定程度上占用了系统开销,这个问题也是必须认真考虑的问题。

【示例3】一般习惯使用命名规范来区别公共与私有成员。命名规范:在一些方法和属性的名称前后加下划线表示私有特性。

下划线命名法是一种约定俗成的命名规范,它表明一个属性和方法仅供对象内部使用,直接访问可能会导致意想不到的后果。虽然它不是强制性规定,但是能够有助于防止用户误用。

13.6.2 主动封装

函数具有局部作用域,在函数内部声明的变量,在函数外部是无权访问的。所以要真正实现类的封装设计,使用函数作用域是最佳选择。

【示例1】在本示例中,包含在函数f()中的变量n和函数e()都不能够被外界访问,不过函数e()可以访问私有变量n。

如果函数f()返回函数e(),则外界是可以访问它的。

在上面示例中,外界可以访问函数f()内部的私有函数e(),这主要是因为JavaScript作用域是词法性质的,函数总是运行在定义它们的作用域中,而不是运行在调用它们的作用域中。

【示例2】根据函数特性,用户可以把私有数据使用函数作用域和闭包进行封装。具体方法:在函数结构体内部定义变量,这些变量可以被定义该作用域中的所有函数访问。

如果希望外界可以访问某些私有方法,可以采用如下方法来实现:

在函数作用域内,其他公共方法可以访问内部方法,以公共方法为中转,可以巧妙地把内部私有方法公开化。因此这些公共方法也被称为特权方法,即在方法的前面加上关键字this。因为这些方法定义于函数作用域中,所以它们能够访问到私有属性,对于不需要直接访问私有属性和方法的方法建议放在类的原型对象中进行声明。

使用这种方式创建的对象具有真正的封装特性,但它也有如下一些缺点。

☑ 生成的每一个新实例对象都会为每一个私有方法和特权方法生成一个新的副本。这会占用大量的系统资源,所以不适宜大量使用,仅在必要时适当使用。

☑ 这种方法不利于类的继承,因为所有派生的子类都不能访问超类的任何私有属性和方法。

【示例3】使用特权函数访问私有变量。

通过特权方法来访问超类中的私有属性和方法:

     alert(a.checkName());            //访问超类公共方法,间接访问私有属性和方法
13.6.3 静态方法

在面向对象的编程中,类是不能够直接访问的,必须实例化后才可以访问。大多数方法和属性与类的实例产生联系。但是静态属性和方法与类本身直接联系,可以直接从类访问,也就是说静态成员是在类上操作,而不是在实例上操作。JavaScript核心对象中的Math和Global都是静态对象,不需要实例化,就可以直接访问。

【示例1】类的静态成员(属性和方法)包括私有和公共两种类型,不管是公共还是私有,它们在系统中只有一份副本,也就是说它们不会被分成多份传递给不同的对象,而是通过函数指针进行引用。

【示例2】与一般类的创建方法一样,这里的私有成员和特权成员仍然被声明在构造器(即构造函数)中,并借助var和this关键字来实现。但构造器却由原来的普通函数变成了一个内嵌函数,并且作为外层函数的返回值赋值给了变量F,这就创建了一个闭包。在这个闭包中,还可以声明静态私有成员。

这些静态私有成员可以在构造器内部访问,这意味着所有私有函数和特权函数都能访问它们。与其他方法相比,静态方法有一个优点,那就是在内存中仅存放一份。但是那些被声明在构造器之外的公共静态方法,以及下文中将要提到的F类原型属性都不能访问在构造器中定义的任何私有属性,所以它们不是特权成员。

定义在构造器中的私有方法能够调用其中的静态私有方法,反之则不然。要判断一个私有方法是否应该被设计为静态方法,可以看它是否需要访问任何实例数据。如果它不需要,那么将其设计为静态方法会更有效率,因为它只被创建一份。

定义类的静态公共方法和属性一般在类的外面进行定义,这种外挂定义的方式在前面的示例中也曾经介绍过。这种外挂的静态方法和属性可以直接进行访问,这实际上相当于把构造器作为命名空间来使用。同时,由于它们仍然属于构造器结构的一部分,所以在这些静态方法和属性中可以访问闭包中的私有成员。

【示例3】访问静态成员。

位于外层函数声明之后的小括号很重要,它在代码载入之后立即执行这个函数,而不是在调用构造函数F时。这个函数的返回值是另一个函数,它被赋给F变量,F因此成了一个构造函数。在实例化F时,所调用的是这个内层函数。外层函数仅是用来创建一个可以存储静态私有成员的闭包。

F是返回的内层函数,该值是一个构造函数,它无法访问外层函数的公共方法get1()和set1(),但是能够访问返回构造函数体内的公共方法get2()和set2()。

【示例4】但是下面的用法都是错误的。因为闭包体内的变量、属性和方法(不管是私有还是公共的),对于级别比较低的F类来说是无权访问的(F是返回的匿名构造函数):

     var a=new F()
     alert(a.get1());
     a.set1(2);
     alert(a.get1());

对于闭包体内的所有对象都可以访问闭包体内的私有或公共变量、属性和方法。由于类F是闭包体内返回的构造函数,根据作用域链,它们可以向上访问闭包所有成员:

通过上面示例分析,利用闭包体也可以实现对数据的封装,而且这种封装是非常有效且牢固的。

13.7 多态

多态是类的基本特性,下面介绍在JavaScript中如何实现多态类型设计。

13.7.1 案例:方法重载和覆盖

重载和覆盖是两个不同的概念,重载(Overload)就是同名方法可以有多个实现,它们根据参数类型、参数值或参数个数决定执行行为。

【示例1】JavaScript不支持函数重载,不能定义同样的函数然后通过编译器去根据不同的参数执行不同的函数。但是JavaScript可以通过自身属性去模拟函数重载。

设计一个计算器函数,如果参数为两个数字,就执行加法运算;如果参数为3个数字,就执行乘法运算。

【示例2】示例1看起来不错,但随着需求的增多,if分支就会越来越庞大,而且对应的模式也越来越难看。因此,可以考虑使用另一个策略来实现这个需求。

函数参数重载方法overload,对函数参数进行模式匹配。默认的dispatcher支持*和...以及?,"*"表示一个任意类型的参数,"..."表示多个任意类型的参数,"?"一般用在",?..."表示0个或任意多个参数。

FunctionH.overload包括两个参数,一个是负责处理匹配条件的dispatcher函数(可默认),另一个是一组函数映射表,默认dispatcher函数是根据实际调用的参数类型生成一个字符串。例如,调用的3个参数依次为10、“a”、[1,2],将生成“number,string,array”,具体实现模式匹配时,将根据函数映射表的每一个“key”生成一个正则表达式,用这个正则表达式匹配dispatcher函数的返回值,如果匹配,则调用这个key对应的处理函数,否则依次匹配下一个key。这样刚才那个计算机函数的需求就可以写成。

【示例3】利用示例2的方法和设计思路,可以设计浏览器兼容的重载函数。

【示例4】而覆盖(Overrid)则是子类中定义的方法与超类中方法同名,且参数类型和个数也相同,当子类被实例化之后,从超类中继承的同名方法将被隐藏。当超类同名方法被覆盖之后,覆盖的方法里面可以调用被覆盖的方法(超类的方法),不过用户可以通过临时私有变量先保存超类的同名方法,然后在子类同名方法中调用即可。

在覆盖方法中调用超类同名方法时,需要使用call()或apply()方法来改变执行上下文为this(即当前对象),如果直接调用该方法(即m();),执行上下文就会变成全局对象,在具体语境中可能会干扰其他代码。

13.7.2 案例:类的多态

【示例1】提及类的多态,用户可以联系到加号运算符。

在JavaScript中,加号就是一个多态运算符,它能够根据传入值的类型执行不同的计算。从某种意义上来说,多态是面向对象中重要的一部分,也是实施继承的主要目的。一个实例可以拥有多个类型,它既可以是这种类型,也可以是那种类型,这种多种状态被称为类的多态。

提示:多态表现为两个方面:类型的模糊和类型的识别。JavaScript是一种弱类型语言,通过typeof运算符来判断值的类型,但是它无法确定对象的类型,所有类型的实例对象对于typeof运算符来说都是基本的object,因此JavaScript的类型是比较模糊的。由于没有严格的类型检测,因此可以为任何对象调用任何方法,而无须考虑它是否被设计为拥有该方法。

【示例2】使用JavaScript的原型可以设计类的多态特性。

在上面示例中,类F就包含了一个多态方法get(),它能够根据不同实例执行不同的方法。

13.8 构造和析构

在面向对象编程中,构造和析构是类的两个重要特性。构造函数将在对象创建时被调用,析构函数在对象销毁时被调用。调用的过程和实现方法由编译器完成,且自动完成,不需要手动控制。

构造和析构是创建和销毁的过程,它们是对象生命周期中的起点和终点,也是最重要的环节。当一个对象诞生时,构造函数负责创建并初始化对象环境,而当对象被注销时,析构函数负责关闭环境、释放资源。

13.8.1 案例:构造函数

在JavaScript中,被new运算符调用的函数就是构造函数,构造函数被new运算符调用之后,将返回实例对象,也就是对象初始化,即对象诞生。

【示例1】调用构造函数的过程也是类实例化的过程。

JavaScript为每个对象都定义了constructor属性,该属性值指向构造函数本身。

如果构造函数有返回值,且返回值是引用类型的,那么经过new运算符计算之后,返回的不再是构造函数的实例对象,而是构造函数包含的返回值。

在上面示例中,实例化构造函数之后,返回值是一个空的数组直接量,而不再是实例对象。原来构造函数的返回值覆盖了new运算符创建的实例对象,此时如果调用f的constructor属性,返回值是:

说明它是Array的实例,使用下面的代码可以检测出来:

     alert(f.constructor == Array);      //返回true,说明Array是f的构造函数

提示:根据设计习惯,构造函数是没有返回值的,也不建议在构造函数中定义return语句,但是很多框架却把它看作是一个设计技巧。例如,jQuery框架就是利用这个技巧构建的。

【拓展】在构造对象的过程中,如果使用call()和apply()方法,可以实现动态构造,从而更加灵活面向对象进行开发。

【示例2】在本示例中,构造函数A、B和C相互之间通过call()方法关联在一起,当构造对象c时,将调用构造函数C,而在执行构造函数C中,会先调用构造函数B。调用构造函数B之前,会自动调用构造函数C,从而实现动态构造对象的效果。这种多个构造函数相互关联在一起,也称为多重构造,它显示了构造对象的动态性。

【示例3】根据动态构造的这种特性,用户可以设计类的多态处理。

13.8.2 案例:析构函数

析构是销毁对象的过程。由于JavaScript包含有垃圾自动回收机制,当对象使用完毕后,系统会自动回收对象,不需要手动清理,这个回收程序相当于一个析构函数。

【示例】在本示例中,先定义一个析构函数,该函数中包含一个析构方法,把该方法继承给任意对象,就可以调用它清除对象内部所有成员。

提示:在其他强类型语言中,构造是从基类开始按继承顺序调用,析构的顺序正好相反。这样子类可以在构造函数里使用父类的成员变量。而析构时,如果父类先析构,则会出现异常。JavaScript不支持析构语法,所以对此没有严格要求。

13.9 案例实战

JavaScript是基于对象的语言,它是以对象为基础,以函数为模型,以原型为继承机制的开发模式。在面向对象的语言中,类是面向对象的基础,同时类具有明显的层次概念和继承关系,每个类都有一个超类,它们从超类中继承属性和方法,类还可以进一步地被扩展,扩展类被称为子类,这样就构建了一个多层、复杂的对象继承关系。

13.9.1 惰性实例化

惰性实例化所要解决的问题是:避免了在页面中JavaScript初始化执行时就实例化类,如果在页面中没有使用这个实例化的对象,就会造成一定的内存浪费和性能消耗。如果将一些类的实例化推迟到需要使用它时才开始去实例化,就可以避免资源过早损耗,做到了“按需供应”。

上面就是简单的惰性实例化的示例,其中有一个缺点就是需要使用中间量来调用内部的Configure()函数所返回的对象的方法,当然也可以使用变量来存储myNamespace.getInstance()返回的实例对象。将上面的代码稍微修改一下,就可以用比较得体的方法来使用内部的方法和属性。

在上面代码中修改了自执行函数返回的对象的代码,在获取Configure()函数返回的对象时,将该对象的方法赋给myNamespace2,这样调用方式就发生了一点改变。

13.9.2 安全构造对象

构造函数其实是一个使用new运算符的函数。当使用new调用时,构造函数的内部用到的this对象会指向新创建的实例。

在没有使用new运算符来调用构造函数的情况下,由于该this对象是在运行时绑定的,因此直接调用Person()会将该对象绑定到全局对象window上,这将导致错误属性意外增加到全局作用域上。这是由于this的晚绑定造成的,在这里this被解析成了window对象。

解决这个问题的方案是创建一个作用域安全的构造函数。首先确认this对象是否为正确的类型实例,如果不是,则创建新的实例并返回。

如果使用的构造函数获取继承且不使用原型链,那么这个继承可能就被破坏。

Rectangle构造函数的作用域是不安全的。在新创建一个Rectangle实例后,这个实例通过Polygon.call继承了sides属性,但是由于Polygon构造函数的作用域是安全的,this对象并非是Polygon的实例,因此会创建并返回一个新的Polygon对象。

Rectangle构造函数中的this对象并没有得到增长,同时Polygon.call返回的值没有被用到,所以Rectangle实例中就不会有sides属性。构造函数配合使用原型链可以解决这个问题。

这时构造函数的作用域就很有用了。

13.9.3 设计超类和子类

在JavaScript中,Object对象是通用类,其他所有内置对象和自定义构造对象都是专用类。也就是说,Object对象是超类,而其他内置对象和自定义构造对象都是Object对象的子类。因此,在JavaScript中所有对象都拥有Object对象定义的属性和方法。

JavaScript语言的继承机制是通过原型继承来实现的,而不是通过类继承来实现的。而原型对象本身又是一个实例对象,它是由Object创建。因此,所有原型对象都继承了Object.prototype属性。

【示例1】为Object对象的原型定义一个属性name,则内置对象Date和自定义构造对象Me都自动继承了该原型属性。

内置对象Date和自定义类Me通过原型机制继承了Object.prototype的属性。在解析时,JavaScript解释器首先在实例对象中查询属性name,如果没有发现属性,则会顺着原型链,查询子类包含的Prototype对象,如果在子类的原型对象中没有找到,就会查询超类Object的prototype对象,最终读取Object.prototype对象定义的属性值。

【示例2】在本示例中,构建了3层类型体系。其中超类Object属于顶层,而类Me属于Object超类的子类,同时它也是Sub类的父类,即Sub是Me的子类。

在默认情况下,类的原型对象的构造器应该是类本身,也就是说prototype.constructor属性总是指向类自身。但是,如果把父类的实例传递给子类的prototype属性时,会破坏原型对象与默认构造器的引用关系,从而指向父类实例的构造器,这就容易引发类型关系的混乱。

【示例3】在正常情况下,子类原型对象的构造器应指向子类自身。

而下面写法就破坏了这种关系:

用户可以通过手工修改子类原型对象的构造器引用,纠正这个错误。

13.9.4 设计元类

元类就是类的类型,即创建类型的类。元类与类的关系正如类与对象的关系一样,是一种创建类型的泛化关系。具体说,元类能够接受类作为参数,返回的是类。因此,元类操作的对象是类,而不是具体的数据。

【示例1】本示例就是一个简单的元类,它包含了一个返回的类。

从上面示例可以看到,元类与普通函数没有什么区别,不过它的返回值是类,而不是具体数值。

【示例2】实际上,JavaScript核心对象Function就是一个元类,虽然说它没有返回值,但是可以通过字符串的形式创建返回类。

上面示例演示了在简单函数中包含一个返回类结构。

【示例3】本实例是一个比较复杂的元类,元类参数值包含类类型,返回值也是类类型。

首先,定义一个普通类,作为一个参数值准备传递给元类:

然后,定义一个元类,该函数类包含3个参数,其中第一个参数为类类型,第二、三个参数是值类型数据。

最后,使用new运算符调用元类,第一个参数值为上文定义的类F,第二、三个参数为普通数值,返回的类赋值给变量A,则A就变成了一个类结构。此时不能够通过A来读取元类的本地方法say()。

但是可以通过实例化后的B对象来访问参数类F中的成员,以及返回类内部定义的本地属性。

注意:当一个类有返回值时,如果是值类型数据,则可以访问类的成员,也可以获取返回值。

但是如果类返回的是引用类型或者是函数体时,则类的成员将不可访问,它们将成为闭包结构内的私有数据,不再对外开放。

13.9.5 分支函数

分支函数主要解决:浏览器之间兼容性的重复判断。解决浏览器之间的兼容性的一般方式是使用if语句进行特性检测或能力检测,根据浏览器不同的实现来实现功能上的兼容,这样做的问题是:每执行一次代码,可能都需要进行一次浏览器兼容性方面的检测,这是没有必要的。能否在代码初始化执行时就检测浏览器的兼容性,在之后的代码执行过程中,就无须再进行检测了呢?

【示例】分支技术可以解决这个问题,下面以声明一个XMLHttpRequest实例对象为例进行介绍,有关XMLHttpRequest的介绍可参阅第20章内容。

从上面的例子可以看出,分支的设计原理是:声明几个不同名称的对象,但是为这些对象都声明一个名称相同的方法(关键点)。针对这些来自于不同的对象,但是拥有相同的方法,根据不同的浏览器设计各自的实现,接着开始进行一次浏览器检测,并且由经过浏览器检测的结果来决定返回哪一个对象,这样不论返回的是哪一个对象,最后名称相同的方法都作为对外一致的接口。

这是在JavaScript运行期间进行动态检测,将检测的结果返回赋值给其他的对象,并且提供相同的接口,这样存储的对象就可以使用名称相同的接口了。其实,惰性载入函数和分支在原理上是非常相近的,只是在代码实现方面有差异而已。

13.9.6 惰性载入函数

惰性载入函数主要解决的问题也是兼容性处理。

【示例1】惰性载入函数的设计原理与分支函数类似,下面是简单的示例。

从代码上看,惰性载入函数也是在函数内部改变自身的一种方式,这样在重复执行时就不会再进行兼容性方面的检测了。

惰性载入表示函数执行的分支仅会发生一次:即第一次调用时。在第一次调用的过程中,该函数会被覆盖为另一个按合适方式执行的函数,这样任何对原函数的调用都不用再经过执行的分支了。其优点是:

☑ 要执行的适当代码只有在实际调用函数时才进行。

☑ 尽管第一次调用该函数会因额外的第二个函数调用而稍微慢点,但后续的调用都会很快,因为避免了多重条件。

由于浏览器之间的行为差异,多数JavaScript代码包含了大量的if语句,将执行引导到正确的代码中。具体的执行过程如下:

☑ 惰性载入表示函数执行的分支会发生一次,即函数第一次调用时。

☑ 在第一次调用的过程中该函数会被覆盖为另外一个按合适方式执行的函数。

☑ 这样任何对函数调用都不用再经过执行的分支了。

☑ 在下面惰性载入的createXHR()中,if语句的每个分支都会为createXHR()变量赋值

☑ 有效覆盖了原有的函数,最后一步便是调用新赋函数。下次调用createXHR()时,就会直接调用被分配的函数,这样就不用再次执行if语句。

【示例2】下面代码使用if语句直接实现定义XMLHttpRequest实例对象。

每一次调用createXHR()时都要对浏览器所支持的能力仔细检查,这样每次调用createXHR()时都要经过相同的测试就变得没有必要了。减少if语句使其不必每一次都执行,代码就会运行得快些。解决方案就是惰性载入的技巧。

【示例3】本示例代码是使用惰性载入函数定义XMLHttpRequest实例对象。

if语句的每一个分支都会为createXHR变量赋值,有效覆盖了原有函数。最后一步便是调用新赋的函数。下次调用creatXHR()时就会直接调用被分配的函数,这样就不用再次执行if语句了。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值