每个 JavaScript 对象都是一个属性集合,相互之间没有任何联系。在 JavaScript 中也可以定义对象的类,让每个对象都共享某些属性,这种「共享」的特性是非常有用的。类的成员或实例都包含一些属性,用以存放或者定义它们的状态,其中有些属性定义了它们的行为(通常称为方法)。这些行为通常是由类定义的,而且为所有实例所共享。例如,假设有一个 Complex 的类用来表示复数, 同时还定义了一些复数运算,一个 Complex 实例应当包含实数的实部和虚部(状态),同样 Complex 类还会定义复数的加法和乘法操作(行为)
JavaScript 中类的一个重要特性是「动态可继承」(dynamically extendable),我们可以将类看做是类型,本章会介绍一种编程哲学 —— 「鸭子类型」(duck-typing),它弱化了对象的类型,强化了对象的功能
类和原型
在 JavaScript 中,类的所有实例对象都从同一个原型对象上继承属性。因此,原型对象是类的核心。在之前的 章节 里定义了 inherit() 函数,这个函数返回一个新创建的对象,后者继承自某个原型对象。如果定义一个原型对象,然后通过 inherit() 函数创建一个继承自它的对象,这样就定义了一个 JavaScript 类。通常,类的实例还需要进一步的初始化,通常是通过定义一个函数来创建并初始化这个新对象
// 一个简单的实现表示值的范围的类
function range(from, to) {
var r = inherit(range.methods);
r.from = from;
r.to = to;
return r;
}
range.methods = {
includes: function(x) {
return this.from <= x && x <= this.to;
},
foreach: function (f) {
for (var x = Math.ceil(this.from); x <= this.to; x++) f(x);
}
toString: function() {
return "(" + this.from + "..." + this.to + ")";
}
}
var r = range(1, 3);
r.includes(2) // => true
r.foreach(console.log)
// => 1
// => 2
// => 3
console.log(r); // => (1...3)
这段代码定义了一个工厂方法 range(),用来创建新的范围对象。range.methods 属性用来快捷地存放定义类的原型对象。range() 函数给每个范围对象都定义了 from 和 to 属性,用以定义范围的起始和结束位置,这两个属性是 非共享 的,当然也是不可继承的。
类和构造函数
上节中展示了定义类的一种方法,但是这种方法并不常用,它没定义构造函数。构造函数是用来初始化新创建的对象的。调用构造函数的一个重要特征是,构造函数的 prototype 属性被用做新对象的原型。这意味着 通过同一个构造函数创建的所有对象都继承自一个相同的对象,因此它们都是同一个类的成员
例9-2
function Range(from, to) {
this.from = from;
this.to = to;
}
Range.prototype = {
includes: function(x) {
return this.from <= x && x <= this.to;
},
foreach: function (f) {
for (var x = Math.ceil(this.from); x <= this.to; x++) f(x);
}
toString: function() {
return "(" + this.from + "..." + this.to + ")";
}
};
var r = new Range(1, 3)
r.includes(2) // => true
r.foreach(console.log)
// => 1
// => 2
// => 3
console.log(r); // => "(1...3)"
从某种意义上讲,定义构造函数既是定义类,并且 类名首字母要大写,而普通普通的函数和方法都是首字母小写,Range() 构造函数是通过 new 关键字调用的,而在上一节的工厂函数则不必使用 new。通过 new 调用不必再用 inherit 来创建对象,构造函数会自动创建对象,然后将构造函数作为这个对象的方法来调用一次,最后返回这个对象
构造函数的类的标识
上文提到,原型对象是类的唯一标识:当且仅当两个对象继承自同一个原型对象时,它们才是属于同一个类的实例。而初始化对象的状态的构造函数则不能作为类的标识,两个构造函数的 prototype 属性可能指责同一个原型对象。那么这两个构造函数创建的实例是属于同一个类的
构造函数的名字通常用做类名,比如我们说 Range() 构造函数创建 Range 对象。然而,更根本地讲,当使用 instancdof 运算符来检测对象是否属于某个类时会用到构造函数。假设有一个对象 r,我们想知道它是否是 Range 对象,可以这样判断
r instanceof Range // 如果 r 继承自 Range.prototype,则返回 true
实际上 instanceof 运算符并不会检查 r 是否是由 Range() 构造函数初始化而来,而是 检查 r 是否继承自 Range.prototype
constructor 属性
每个 JavaScript 函数(bind除外)都自动拥有一个 prototype 属性。这个属性的值是一个对象,这个对象包含唯一一个不可枚举属性 constructor。constructor 属性的值是一个函数对象
var F = function() {};
var p = F.prototype;
var c = p.constructor;
c === F // => true
图 9-1 展示了构造函数和原型对象之间的关系,包括原型到构造函数的反向引用以及构造函数创建的实例
图9-1
构 造 函 数 原 型 实 例
+------------------+ +------------------+ +---------------+
| | | | inherits | |
| Range() <---------- contructor <----------+ new Range(2) |
| | | | | |
| | | | +---------------+
| | | includes |
| prototype ------------> |
| | | foreach | +---------------+
| | | | inherits | |
| | | toString <----------+ new Range(3,4)|
| | | | | |
| | | | +---------------+
+------------------+ +------------------+
例9-2中定义的 Range 类使用它自身的一个新对象重写预定义的 Range.prototype 对象。这个新定义的原型对象不含有 constructor 属性。因此 Range 类的实例也不含有 constructor 属性。我匀可以通过补救措施来修正这个问题,显式给原型添加一个构造函数:
Range.prototype = {
constructor: Range,
incluces: function() {/*code*/}
};
// 当然也可以使用给 prototype 指定方法赋值的方式,避免重写整个 prototype
Range.prototype.includes = function() {/*code*/}
JavaScript 中 Java 式的类继承
在 Java 或者其它类似强类型面向对象语言中,类成员可能是这样的:
实例字段 它们是基于实例的属性或变量,用以保存独立对象的状态
实例方法 它们是类的所有实例共享方法,由每个独立的实例调用
类字段 这些属性或变量是属于类的,而不属于类的某个实例
类方法 这些方法是属于类的,而不属于类的某个实例
JavaScript 和 Java 的一个不同之处在于,JavaScript 中的函数都是以值的形式出现的,方法和字段之间并没有太大的区别。如果属性是函数,那么这个属性就定义一个方法,否则,它只是一个普通的属性或「字段」
类和类型
instanceof 运算符
之前我们已经了解过 instanceof 运算符。左操作数是待检测其类的对象,右操作数是定义类的构造函数。如果 o 继承自 c.prototype,则表达式 o instanceof c 值返回 true。这里的继承可以不是直接继承,如果 o 所继承的对象继承自另一个对象,后一个对象继承自 c.prototype,这个表达式的运算结果也是 true
构造函数是类的公共标识,但原型是唯一的标识。尽管 instanceof 运算符的右操作数是构造函数,但计算过程实际上是检测了对象的继承关系,而不是检测创建对象的构造函数
constructor 属性
另一种识别对象是否属于某个类的方法是使用 constructor 属性,因为构造函数是类的公共标识,所以最直接的方法就是使用 constructor 属性,比如:
function typeAndValue(x) {
if (x == null) return "";
switch(x.constructor) {
case: Number:
return "Number: " + x;
case: String:
return "String: " + x;
case: Date:
return "Date: " + x;
case: RegExp:
return "RegExp: " + x;
case: Complex:
return "Complex: " + x;
}
}
使用 constructor 属性检测对象属于某个类的技术不足之处和 instanceof 一样。在 多个执行上下文 场景中它是无法正常工作的(比如在浏览器窗口的多个框架子页面中)。在这种情况下每个框架页面各自拥有独立的构造函数集合。比如一个框架页面中的 Array 构造函数和另一个构架页面的 Array 构造函数不是同一个
鸭子类型
上面描述的检测对象的类的各种技术多少都会有些问题,至少在客户端 JavaScript 中是如此。解决办法就是规避掉这些问题:不要关注「对象是什么」,而是关注「对象能做什么」。这种思考问题的方式在 Python 和 Ruby 中右学普遍,称为「鸭子类型」
像鸭子一样走路、游泳并且嘎嘎叫的鸟就是鸭子
这又让我想起了《蝙蝠侠》里面的那句话:
It’s not who you are underneath, it’s what you do that defines you
JavaScript 中的面向对象技术
一个例子:集合类
集合(set)是一种数据结构,用来表示非重复值的无序集合。集合有添加值、检测值是否存在等方法,下面的例子实现了一个更加通用的 Set 类
function Set() {
this.values = {};
this.n = 0;
this.add.apply(this, arguments)
}
Set.prototype.add = function () {
for (var i = 0; i < arguments.length; i++) {
var val = arguments[i];
var str = Set._v2s(val);
if (!this.values.hasOwnProperty(str)) {
this.values[str] = val;
this.n++;
}
}
return this;
}
Set.prototype.remove = function () {
for (var i = 0; i < arguments.length; i++) {
var str = Set._v2s(arguments[i]);
if (this.values.hasOwnProperty(str)) {
delete this.values[str];
this.n--;
}
}
}
Set.prototype.contains = function (value) {
return this.values.hasOwnProperty(Set._v2s(value))
}
Set.prototype.size = function () {
return this.n;
}
Set.prototype.foreach = function(f, context) {
for (var s in this.values) {
if (this.values.hasOwnProperty(s)) {
f.call(context, this.values[s]);
}
}
}
Set._v2s = function (val) {
switch (val) {
case undefined: return 'u';
case null: return 'n';
case true: return 't';
case false: return 'f';
default:
switch (typeof val) {
case 'number': return '#' + val;
case 'string': return '"' + val;
default: return '@' + objectId(val)
}
}
function objectId(o) {
var prop = "|**objectid**|";
if (!o.hasOwnProperty(prop)) {
o[prop] = Set._v2s.nex++;
}
return o[prop];
}
}
Set._v2s.next = 100;
var s = new Set(1,2,3);
s.values; // => Object {#1: 1, #2: 2, #3: 3}
var s1 = new Set(1,2,3,3,2,1, null, undefined)
s1.values; // => Object {#1: 1, #2: 2, #3: 3, n: null, u: undefined}
s1.remove(null, undefined);
s1.values; // => Object {#1: 1, #2: 2, #3: 3}
构造函数的重载和工厂方法
有时候,我们希望对象的初始化有多种方式,比如 Set 对象,我们想专入一个数组或者类数组,而不是多个参数来初始化它,我们可以加一些判断来实现重载(overload)
// 重载
function Set() {
this.values = {};
this.n = 0;
if (arguments.length == 1 && isArrayLike(arguments[0])) {
this.add.apply(this, arguments[0])
} else {
this.add.apply(this, arguments)
}
}
// 工厂方法: 可以从数组创建一个集合对象
Set.fromArray = function(a) {
var s = new Set();
s.add.apply(s, a)
return s;
}
// Set 类的一个辅助构造函数
function SetFromArray(a) {
Set.apply(this, a);
}
SetFromArray.prototype = Set.prototype;
var s = new SetFromArray([1,2,3]);
s instanceof Set // => true
子类
在面向对象编程中,类 B 可以继承自另外一个类 A。我们将 A 称为父类(superclass),将 B 称为子类(subclass)。B 的实例从 A继承了所有的实例方法。类 B 可以定义自己的实例类方法,有些方法可以重载类 A 中的同名方法,如果 B 的方法重载了 A 中的方法,B 中的重载方法可能会调用 A 中的重载类 A 方法,这种做法称为「方法链」(method chaining)。同样子类的构造函数 B() 有时需要调用父类的构造函数 A(),这种做法称为「构造函数链」(constructor chaining)。子类还可以有子类,当涉及类的层次结构时,往往需要定义抽象类(abstract class)。抽象类中定义的方法没有实现。抽象类中的抽象方法是在抽象类的具体子类中实现的
定义子类
JavaScript 的对象可以从类的原型对象中继承属性。如果 O 是类 B 的实例,B 是 A 的子类,那么 O 也一定从 A 中继承了属性。为此,首先要确保 B 的原型对象继承自 A 的原型对象,通过 inherit() 函数可以实现
B.prototype = inherit(A.prototype);
B.prototype.constructor = B;
构造函数和方法链
我们定义一个 Set 的子类 NonNullSet,它不允许 null 和 undefined 作为集合成员,这就需要在子类的 add() 方法中对 null 和 undefined 值做检测。它需要完全重新实现一个 add() 方法
function NonNullSet() {
Set.apply(this, arguments);
}
NonNullSet.prototype = inherit(Set.prototype);
NonNullSet.prototype.constructor = NonNullSet;
NonNullSet.prototype.add = function() {
for (var i = 0; i < arguments.length; i++) {
if (arguments[i] == null) {
throw new Error("Cant't add null or undefined to a NonNullSet");
}
}
return Set.prototype.add.apply(this, arguments);
};
组合 vs 子类
上节中定义的集合可以根据特定的标准对集合成员做限制,而且使用了子类的技术来实现这种功能
然后还有一种更好的方法来完成这种需求,既面向对象编程中一条广为人知的设计原则:「组合优于继承」。这样,可以利用组合的原理定义一个新的集合实现,它「包装」了另外一个集合对象,在将受限制的成员过滤掉之后会用到这个集合对象