## 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 ) 仍然无法变成相对(而且更灵活的)引用,所以使用时千万要小心。通常来说,尽量避免使用这样的结构,以保证代码的整洁和可维护性。
- 前言
- 第一章 JavaScript简介
- 第三章 基本概念
- 3.1-3.3 语法、关键字和变量
- 3.4 数据类型
- 3.5-3.6 操作符、流控制语句(暂略)
- 3.7函数
- 第四章 变量的值、作用域与内存问题
- 第五章 引用类型
- 5.1 Object类型
- 5.2 Array类型
- 5.3 Date类型
- 5.4 基本包装类型
- 5.5 单体内置对象
- 第六章 面向对象的程序设计
- 6.1 理解对象
- 6.2 创建对象
- 6.3 继承
- 第七章 函数
- 7.1 函数概述
- 7.2 闭包
- 7.3 私有变量
- 第八章 BOM
- 8.1 window对象
- 8.2 location对象
- 8.3 navigator、screen与history对象
- 第九章 DOM
- 9.1 节点层次
- 9.2 DOM操作技术
- 9.3 DOM扩展
- 9.4 DOM2和DOM3
- 第十章 事件
- 10.1 事件流
- 10.2 事件处理程序
- 10.3 事件对象
- 10.4 事件类型
- 第十一章 JSON
- 11.1-11.2 语法与序列化选项
- 第十二章 正则表达式
- 12.1 创建正则表达式
- 12.2-12.3 模式匹配与RegExp对象
- 第十三章 Ajax
- 13.1 XMLHttpRequest对象
- 你不知道的JavaScript
- 一、作用域与闭包
- 1.1 作用域
- 1.2 词法作用域
- 1.3 函数作用域与块作用域
- 1.4 提升
- 1.5 作用域闭包
- 二、this与对象原型
- 2.1 关于this
- 2.2 全面解析this
- 2.3 对象
- 2.4 混合对象“类”
- 2.5 原型
- 2.6 行为委托
- 三、类型与语法
- 3.1 类型
- 3.2 值
- 3.3 原生函数