多应用+插件架构,代码干净,二开方便,首家独创一键云编译技术,文档视频完善,免费商用码云13.8K 广告
## 2.4 混合对象“类” ### 2.4.1 类理论 类/ 继承描述了一种代码的组织结构形式——一种在软件中对真实世界中问题领域的建模方法。 面向对象编程强调的是数据和操作数据的行为本质上是互相关联的(当然,不同的数据有不同的行为),因此好的设计就是把数据以及和它相关的行为打包(或者说封装)起来。这在正式的计算机科学中有时被称为数据结构。 **关于类、继承和实例化还有多态的基础概念认识,略。** **1. “类”设计模式** 你可能从来没把类作为设计模式来看待,讨论得最多的是面向对象设计模式,比如迭代器模式、观察者模式、工厂模式、单例模式,等等。从这个角度来说,我们似乎是在(低级)面向对象类的基础上实现了所有(高级)设计模式,似乎面向对象是优秀代码的基础。 如果你之前接受过正规的编程教育的话,可能听说过**过程化编程**,这种代码只包含过程(函数)调用,没有高层的抽象。 当然,如果你有**函数式编程**(比如Monad)的经验就会知道类也是非常常用的一种设计模式。但是对于其他人来说,这可能是第一次知道类并不是必须的编程基础,而是一种可选的代码抽象。 有些语言(比如Java)并不会给你选择的机会,类并不是可选的——万物皆是类。其他语言(比如C/C++ 或者PHP)会提供过程化和面向类这两种语法,开发者可以选择其中一种风格或者混用两种风格。 **2. JavaScript中的“类”** 在相当长的一段时间里,JavaScript 只有一些近似类的语法元素(比如new 和instanceof),不过在后来的ES6 中新增了一些元素,比如`class `关键字。 **但这并不意味着JavaScript中实际上存在类。** 在软件设计中类是一种可选的模式,虽然JavaScript有近似类的语法,但是JavaScript 的机制似乎一直在阻止你使用类设计模式。在近似类的表象之下,JavaScript 的机制其实和类完全不同。语法糖和( 广泛使用的)JavaScript“类”库试图掩盖这个现实,但是你迟早会面对它:其他语言中的类和JavaScript中的“类”并不一样。 ### 2.4.2 类的机制 在许多面向类的语言中,“标准库”会提供Stack 类,它是一种“栈”数据结构(支持压入、弹出,等等)。Stack 类内部会有一些变量来存储数据,同时会提供一些公有的可访问行为(“方法”),从而让你的代码可以和(隐藏的)数据进行交互(比如添加、删除数据)。 但是在这些语言中,你实际上并不是直接操作Stack(除非创建一个静态类成员引用)。Stack 类仅仅是一个抽象的表示,它描述了所有“栈”需要做的事,但是它本身并不是一个“栈”。你必须先**实例化Stack 类**然后才能对它进行操作。 **1. 建造** “类”和“实例”的概念来源于房屋建造。 一个类就是一张蓝图。为了获得真正可以交互的对象,我们必须按照类来建造(也可以说实例化)一个东西,这个东西通常被称为**实例**,有需要的话,可以直接在实例上调用方法并访问其所有公有数据属性。这个对象就是类中描述的所有特性的一份副本。 把类和实例对象之间的关系看作是**直接关系**而不是间接关系通常更有助于理解。类通过**复制操作**被实例化为对象形式: ![](https://box.kancloud.cn/37ba1d3b1fe77606183b41d8147f4839_736x361.png) 箭头的方向是从左向右、从上向下,它表示概念和物理意义上发生的复制操作。 **2. 构造函数** 类实例是由一个特殊的类方法构造的,这个方法名通常和类名相同,被称为**构造函数**。这个方法的任务就是**初始化实例需要的所有信息(状态)。** 举例来说,思考下面这个关于类的伪代码(编造出来的语法): ~~~ class CoolGuy { specialTrick = nothing CoolGuy( trick ) { specialTrick = trick } showOff() { output( "Here's my trick: ", specialTrick ) } } ~~~ 我们可以调用类构造函数来生成一个CoolGuy 实例: ~~~ Joe = new CoolGuy( "jumping rope" ) Joe.showOff() // 这是我的绝技:跳绳 ~~~ 注意,CoolGuy 类有一个CoolGuy() 构造函数,执行new CoolGuy() 时实际上调用的就是它。构造函数会返回一个对象(也就是类的一个实例),之后我们可以在这个对象上调用showOff() 方法,来输出指定CoolGuy 的特长。 类构造函数属于类,而且通常和类同名。此外,构造函数大多需要用new 来调,这样语言引擎才知道你想要构造一个新的类实例。 ### 2.4.3 类的继承 在面向类的语言中,你可以先定义一个类,然后定义一个继承前者的类。后者通常被称为“子类”,前者通常被称为“父类”。 思考下面关于类继承的伪代码(省略了构造函数): ~~~ class Vehicle { engines = 1 ignition() { output( "Turning on my engine." ); } drive() { ignition(); output( "Steering and moving forward!" ) } } class Car inherits Vehicle { wheels = 4 drive() { inherited:drive() output( "Rolling on all ", wheels, " wheels!" ) } } class SpeedBoat inherits Vehicle { engines = 2 ignition() { output( "Turning on my ", engines, " engines." ) } pilot() { inherited:drive() output( "Speeding through the water with ease!" ) } } ~~~ 我们通过定义Vehicle 类来假设一种发动机,一种点火方式,一种驾驶方法。接下来我们定义了两类具体的交通工具:Car 和SpeedBoat。它们都从Vehicle 继承了通用的特性并根据自身类别修改了某些特性。汽车需要四个轮子,快艇需要两个发动机,因此它必须启动两个发动机的点火装置。 **1. 多态** Car 重写了继承自父类的drive() 方法,但是之后Car 调用了inherited:drive() 方法,这表明Car 可以引用继承来的原始drive() 方法。快艇的pilot() 方法同样引用了原始drive() 方法。 这个技术被称为**多态或者虚拟多态**。在本例中,更恰当的说法是相对多态。 多态是一个非常广泛的话题,“相对”只是多态的一个方面:任何方法都可以引用继承层次中高层的方法(无论高层的方法名和当前方法名是否相同)。之所以说“相对”是因为我们并不会定义想要访问的绝对继承层次(或者说类),而是使用相对引用 “查找上一层”。 在许多语言中可以使用`super` 来代替本例中的inherited:, 它的含义是**“ 超类”**(superclass),表示当前类的父类/ 祖先类。 多态的另一个方面是,在继承链的不同层次中一个方法名可以被多次定义,当调用方法时会自动选择合适的定义。在之前的代码中就有两个这样的例子:drive() 被定义在Vehicle 和Car 中,ignition() 被定义在Vehicle 和SpeedBoat 中。 **2. 多重继承** 有些面向类的语言允许你继承多个“父类”。多重继承意味着所有父类的定义都会被复制到子类中。 从表面上来,对于类来说这似乎是一个非常有用的功能,可以把许多功能组合在一起。 然而,这个机制同时也会带来很多复杂的问题。如果两个父类中都定义了drive() 方法的话,子类引用的是哪个呢?难道每次都需要手动指定具体父类的drive() 方法吗?这样多态继承的很多优点就存在了。 除此之外,还有一种被称为**钻石问题**的变种。在钻石问题中,子类D 继承自两个父类(B和C),这两个父类都继承自A。如果A 中有drive() 方法并且B 和C 都重写了这个方法(多态),那当D 引用drive() 时应当选择哪个版本呢(B:drive() 还是C:drive())? ![](https://box.kancloud.cn/13e4d2d10e00e738626e0300d4b35b21_736x282.png) ### 2.4.4 混入 在继承或者实例化时,JavaScript 的对象机制并不会自动执行复制行为。简单来说,JavaScript 中只有对象,并不存在可以被实例化的“类”。一个对象并不会被复制到其他对象,它们会被**关联**起来。 由于在其他语言中类表现出来的都是复制行为,因此JavaScript 开发者也想出了一个方法来模拟类的复制行为,这个方法就是**混入**。接下来我们会看到两种类型的混入:**显式和隐式**。 **1. 显示混入** 回顾一下之前提到的Vehicle 和Car。由于JavaScript 不会自动实现Vehicle到Car 的复制行为,所以我们需要手动实现复制功能。这个功能在许多库和框架中被称为`extend(..)`,但是为了方便理解我们称之为mixin(..)。 ~~~ // 非常简单的mixin(..) 例子: function mixin( sourceObj, targetObj ) { for (var key in sourceObj) { // 只会在不存在的情况下复制 if (!(key in targetObj)) { targetObj[key] = sourceObj[key]; } } return targetObj; } var Vehicle = { engines: 1, ignition: function() { console.log( "Turning on my engine." ); }, drive: function() { this.ignition(); console.log( "Steering and moving forward!" ); } }; var Car = mixin( Vehicle, { wheels: 4, drive: function() { Vehicle.drive.call( this ); console.log( "Rolling on all " + this.wheels + " wheels!" ); } } ); ~~~ 现在Car 中就有了一份Vehicle 属性和函数的副本了。从技术角度来说,函数实际上没有被复制,复制的是函数引用。所以,Car 中的属性ignition 只是从Vehicle 中复制过来的对于ignition() 函数的引用。相反,属性engines 就是直接从Vehicle 中复制了值1。Car 已经有了drive 属性(函数),所以这个属性引用并没有被mixin 重写,从而保留了Car 中定义的同名属性,实现了“子类”对“父类”属性的重写. #### (1)再说多态 分析一下这条语句:`Vehicle.drive.call( this )`。这就是**显式多态**。在之前的伪代码中对应的语句是`inherited:drive()`,我们称之为**相对多态**。 JavaScript( 在ES6 之前) 并没有相对多态的机制。所以, 由于Car 和Vehicle 中都有drive() 函数,为了指明调用对象,必须使用绝对(而不是相对)引用。我们通过名称显式指定Vehicle 对象并调用它的drive() 函数。 但是如果直接执行`Vehicle.drive()`,函数调用中的this 会被绑定到Vehicle 对象而不是Car 对象,这并不是我们想要的。因此,我们会使用`.call(this)` 来确保drive() 在Car 对象的上下文中执行。 ~~~ 如果函数Car.drive() 的名称标识符并没有和Vehicle.drive() 重叠(或者说“屏蔽”)的话,就不需要实现方法多态, 因为调用mixin(..) 时会把函数Vehicle.drive() 的引用复制到Car 中,因此可以直接访问this.drive()。 正是由于存在标识符重叠,所以必须使用更加复杂的显式伪多态方法。 ~~~ 在支持相对多态的面向类的语言中,Car 和Vehicle 之间的联系只在类定义的开头被创建,从而只需要在这一个地方维护两个类的联系。 但是在JavaScript 中(由于屏蔽)使用显式伪多态会在所有需要使用(伪)多态引用的地方创建一个函数关联,这会极大地增加维护成本。此外,由于显式伪多态可以模拟多重继承,所以它会进一步增加代码的复杂度和维护难度。 #### (2)混合复制 回顾一下之前提到的mixin(..) 函数: ~~~ // 非常简单的mixin(..) 例子: function mixin( sourceObj, targetObj ) { for (var key in sourceObj) { // 只会在不存在的情况下复制 if (!(key in targetObj)) { targetObj[key] = sourceObj[key]; } } return targetObj; } ~~~ 分析一下mixin(..) 的工作原理。它会遍历sourceObj(本例中是Vehicle)的属性,如果在targetObj(本例中是Car)没有这个属性就会进行复制。由于我们是在目标对象初始化之后才进行复制,因此一定要小心不要覆盖目标对象的原有属性。 如果我们是先进行复制然后对Car 进行特殊化的话,就可以跳过存在性检查。不过这种方法并不好用并且效率更低,所以不如第一种方法常用: ~~~ // 另一种混入函数,可能有重写风险 function mixin( sourceObj, targetObj ) { for (var key in sourceObj) { targetObj[key] = sourceObj[key]; } return targetObj; } var Vehicle = { // ... }; // 首先创建一个空对象并把Vehicle 的内容复制进去 var Car = mixin( Vehicle, { } ); // 然后把新内容复制到Car 中 mixin( { wheels: 4, drive: function() { // ... } }, Car ); ~~~ JavaScript 中的函数无法(用标准、可靠的方法)真正地复制,所以你只能复制对共享函数对象的引用(函数就是对象)。如果你修改了共享的函数对象(比如ignition()),比如添加了一个属性,那Vehicle 和Car 都会受到影响。 #### (3)寄生继承 显式混入模式的一种变体被称为“**寄生继承**”,它既是显式的又是隐式的。 下面是它的工作原理: ~~~ // “传统的JavaScript 类”Vehicle function Vehicle() { this.engines = 1; } Vehicle.prototype.ignition = function() { console.log( "Turning on my engine." ); }; Vehicle.prototype.drive = function() { this.ignition(); console.log( "Steering and moving forward!" ); }; // “寄生类” Car function Car() { // 首先,car 是一个Vehicle var car = new Vehicle(); // 接着我们对car 进行定制 car.wheels = 4; // 保存到Vehicle::drive() 的特殊引用 var vehDrive = car.drive; // 重写Vehicle::drive() car.drive = function() { vehDrive.call( this ); console.log( "Rolling on all " + this.wheels + " wheels!" ); return car; } var myCar = new Car(); myCar.drive(); // 发动引擎。 // 手握方向盘! // 全速前进! ~~~ 首先我们复制一份Vehicle 父类(对象)的定义,然后混入子类(对象)的定义(如果需要的话保留到父类的特殊引用),然后用这个复合对象构建实例。 **2. 隐式混入** ~~~ var Something = { cool: function() { this.greeting = "Hello World"; this.count = this.count ? this.count + 1 : 1; } }; Something.cool(); Something.greeting; // "Hello World" Something.count; // 1 var Another = { cool: function() { // 隐式把Something 混入Another Something.cool.call( this ); } }; Another.cool(); Another.greeting; // "Hello World" Another.count; // 1 (count 不是共享状态) ~~~ 通过在构造函数调用或者方法调用中使用`Something.cool.call( this )`,我们实际上“借用”了函数`Something.cool() `并在Another 的上下文中调用了它。最终的结果是Something.cool() 中的赋值操作都会应用在Another 对象上而不是Something 对象上。 虽然这类技术利用了this 的重新绑定功能,但是Something.cool.call( this ) 仍然无法变成相对(而且更灵活的)引用,所以使用时千万要小心。通常来说,尽量避免使用这样的结构,以保证代码的整洁和可维护性。