类理论
面向对象编程强调的是数据和操作数据的行为本质上是互相关联的(当然,不同的数据有不同的行为),因此好的设计就是把数据以及和它相关的行为打包(或者说封装)起来。
类的三个基本特征:封装、继承和多态。
由于类是一种设计模式,所以可以用一些方法近似实现类的功能。为了满足对于类设计模式的最普遍需求,JavaScript 提供了一些近似类的语法。
虽然有近似类的语法,但是 JavaScript 的机制似乎一直在阻止你使用类设计模式。在近似类的表象之下,JavaScript 的机制其实和类完全不同。语法糖和(广泛使用的)JavaScript “类” 库试图掩盖这个现实,但是你迟早会面对它:其他语言中的类和 JavaScript 中的“类”并不一样。
类机制
建造
一个类就是一张蓝图。为了获得真正可以交互的对象,我们必须按照类来建造(也可以说实例化 )一个东西,这个东西通常被称为实例,有需要的话,我们可以直接在实例上调用方法并访问其所有公有数据属性。这个对象就是类中描述的所有特性的一份副本。
把类和实例对象之间的关系看作是直接关系而不是间接关系通常更有助于理解。类通过复制操作被实例化为对象形式:
构造函数
类实例是由一个特殊的类方法构造的,这个方法名通常和类名相同,被称为构造函数。这个方法的任务就是初始化实例需要的所有信息(状态)。
// 伪代码
class CoolGuy {
specialTrick = nothing;
CoolGuy (trick) {
specialTrick = trick;
}
showOff () {
output('Here is my trick: ' + specialTrick);
}
}
Joe = new CoolGuy('jumping rope');
Joe.showOff();
类构造函数属于 类,而且通常和类同名。此外,构造函数大多需要用 new
来调,这样语言引擎才知道你想要构造一个新的类实例。
继承
在面向类的语言中,你可以先定义一个类,然后定义一个继承前者的类。后者通常被称为“子类”,前者通常被称为“父类”。
定义好一个子类之后,相对于父类来说它就是一个独立并且完全不同的类。子类会包含父类行为的原始副本,但是也可以重写所有继承的行为甚至定义新行为。
// 伪代码
class Vehicle {
engines = 1;
ignition () {
output('Turning on my engine.');
};
drive () {
output('Steering and moving forward!');
};
}
class Car inherits Vehicle {
wheels = 4;
drive () {
inherited:drive();
output('Rolling on all ' + this.wheels + ' wheels!');
}
}
class SpeedBoat inherits Vehicle {
engines = 2;
ignition () {
output('Turning on my ' + this.engines + ' engines.');
}
pilot () {
inherits:drive();
output('Speeding through the water with all my engines burning!');
}
}
多态
Car
重写了继承自父类的 drive()
方法,但是之后 Car
调用了 inherited:drive()
方法,这表明 Car
可以引用继承来的原始 drive()
方法。快艇的 pilot()
方法同样引用了原始 drive()
方法。这个技术被称为多态 或者虚拟多态 ,更恰当的说法是相对多态。
多态是一个非常广泛的话题,现在所说的“相对”只是多态的一个方面:任何方法都可以引用继承层次中高层的方法(无论高层的方法名和当前方法名是否相同)。之所以说“相对”是因为并不会定义想要访问的绝对继承层次(或者说类),而是使用相对引用“查找上一层”。
在许多语言中可以使用 super
来代替本例中的 inherited:
,它的含义是“超类”(superclass),表示当前类的父类/祖先类。
多态的另一个方面是,在继承链的不同层次中一个方法名可以被多次定义,当调用方法时会自动选择合适的定义。
:::tip
在传统的面向类的语言中 super
还有一个功能,就是从子类的构造函数中通过 super
可以直接调用父类的构造函数。通常来说这没什么问题,因为对于真正的类来说,构造函数是属于类的。然而,在 JS 中恰好相反——实际上“类”是属于构造函数的(类似 Foo.prototype.xxx
这样的类型引用)。由于 JS 中父类和子类的关系只存在于两者构造函数对应的 .prototype
对象中,因此它们的构造函数之间并不存在直接联系,从而无法简单地实现两者的相对引用(在 ES6 的类中可以通过 super
来“解决”这个问题)。
:::
ignition()
方法定义的多态性 取决于是在哪个类的实例中引用它。
在子类(而不是它们创建的实例对象!)中也可以相对引用它继承的父类,这种相对 引用通常被称为 super
。
从概念上来说,子类 Bar
应当可以通过相对多态引用(或者说 super
)来访问父类 Foo
中的行为。需要注意,子类得到的仅仅是继承自父类行为的一份副本。子类对继承到的一个方法进行“重写”,不会影响父类中的方法,这两个方法互不影响,因此才能使用相对多态引用访问父类中的方法(如果重写会影响父类的方法,那重写之后父类中的原始方法就不存在了,自然也无法引用)。
多态并不表示子类和父类有关联,子类得到的只是父类的一份副本。类的继承其实就是复制。
多重继承
有些面向类的语言允许你继承多个“父类”。多重继承意味着所有父类的定义都会被复制到子类中。
相比之下,JavaScript 要简单得多:它本身并不提供“多重继承”功能。许多人认为这是件好事,因为使用多重继承的代价太高。
混入
在继承或者实例化时,JavaScript 的对象机制并不会自动 执行复制行为。简单来说,JavaScript 中只有对象,并不存在可以被实例化的“类”。一个对象并不会被复制到其他对象,它们会被关联起来。
由于在其他语言中类表现出来的都是复制行为,因此 JavaScript 开发者也想出了一个方法来模拟 类的复制行为,这个方法就是混入 。
显式混入
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
中定义的同名属性,实现了“子类”对“父类”属性的重写。
多态
Vehicle.drive.call(this)
,就是显式多态 。在之前的伪代码中对应的语句是 inherited:drive()
,称之为相对多态 。
JavaScript(在 ES6 之前)并没有相对多态的机制。所以,由于 Car
和 Vehicle
中都有 drive()
函数,为了指明调用对象,必须使用绝对(而不是相对)引用。通过名称显式指定 Vehicle
对象并调用它的 drive()
函数。但是如果直接执行 Vehicle.drive()
,函数调用中的 this
会被绑定到 Vehicle
对象而不是 Car
对象。
使用伪多态通常会导致代码变得更加复杂、难以阅读并且 难以维护,因此应当尽量避免使用显式伪多态,因为这样做往往得不偿失。
混合复制
JavaScript 中的函数无法(用标准、可靠的方法)真正地复制,所以只能复制 对共享函数对象的引用 (函数就是对象=)。如果修改了共享的函数对象(比如 ignition()
),比如添加了一个属性,那 Vehicle
和 Car
都会受到影响。
如果使用混入时感觉越来越困难 ,那或许你应该停止使用它了。实际上,如果必须使用一个复杂的库或者函数来实现这些细节,那就标志着你的方法是有问题的或者是不必要的。
寄生继承
显式混入模式的一种变体被称为“寄生继承”,它既是显式的又是隐式的。
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();
隐式混入
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
的上下文中调用了它(通过 this
绑定)。最终的结果是 Something.cool()
中的赋值操作都会应用在 Another
对象上而不是 Something
对象上。