【类和模块】JavaScript中的面向对象技术


JavaScript中的面向对象技术

一个例子:集合类

集合(set)是一种数据结构,用以表示非重复值的无序集合。集合的基础方法包括添加值、检测值是否在集合中,这种集合需要一种通用的实现,以保证操作效率。JavaScript的对象是属性名以及与之对应的值的基本集合。因此将对象只用做字符串的集合是大材小用。下例用JavaScript实现了一个更加通用的Set类,它实现了从JavaScript值到唯一字符串的映射,然后将字符串用做属性名。对象和函数都不具备如此简明可靠的唯一字符串表示。因此集合类必须给集合中的每一个对象或函数定义一个唯一的属性标识。

例: Set.js:值的任意集合
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];	                    // 待添加到集合中的值
	    vax 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--;                              // 集合中值的计数减一
		}
		} 
		return this; // 支持链式方法调用
	};

  
  // 如果集合包含这个值,则返回true;否则,返回false
  Set.prototype.contains = function (value) {
		return this.values.hasOwnProperty(Set._v2s(value));
	};  	
	// 返回集合的大小
	Set.prototype.size = function () { 
		return this.n;
	};

	// 遍历集合中的所有元素,在指定的上下文中调用f
	Set.prototype.foreach = function (f, context) { 
		for (var s in this.values)                     // 遍历集合中的所有字符串
			if (this.values.hasOwnProperty(s))         // 忽略继承的属性
			f.call(context, this.values[s]);           // 调用f,传入value
	};
	//这是一个内部函数,用以将任意JavaScript值和唯一的字符串对应起来 >	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);  // Objs and funcs get @
	            }
    {

	// 对任意对象来说,都会返回一个字符串
	// 针对不同的对象,这个函数会返回不同的字符串
	// 对于同一个对象的多次调用,总是返回相同的字符串
	// 为了做到这一点,它给o创建了一个属性,在ES5中,这个属性是不可枚举且是只读的
	function objectId(o) {
  	var prop = "|**objectid**|"; 	// 私有属性,用以存放id 
		if (!o.hasOwnProperty(prop)) 	// 如果对象没有id 
			o[prop] = Set._v2s.next++; 	// 将下一个值赋给它 														return o[prop];					// 返回这个id
	}
};
Set._v2s.next = 100; // 设置初始id的值

一个例子:枚举类型

枚举类型(enumerated type)是一种类型,它是值的有限集合,如果值定义为这个类型则该值是可列出(或“可枚举”)的。在C及其派生语言中,枚举类型是通过关键字enum声明的。Enum是ECMAScript 5中的保留字(还未使用),很有可能在将来JavaScript就会内置支持枚举类型。到那时,下例展示了如何在JavaScript中定义枚举类型的数据。需要注意的是,这里用到了下例中的inherit()函数。

下例包含一个单独函数enumeration()。但它不是构造函数,它并没有定义一个名叫 “enumeration”的类。相反,它是一个工厂方法,每次调用它都会创建并返回一个新的类,比如:

// 使用4个值创建新的Coin类:Coin.Penny, Coin.Nickel等
var Coin enumeration({Penny:1, Nickel:5, Dime:10, Quarter:25});
var c = Coin.Dime;           // 这是新类的实例
c instanceof Coin            // => true: instanceof正常工作
c.constructor == Coin        // => true:构造函数的属性正常工作
Coin.Quarter + 3*Coin.Nickel // => 40: 将值转换为数字
Coin.Dime == 10              // => true: 更多转换为数字的例子
Coin.Dime > Coin.Nickel      // => true: 关系运算符正常工作
String(Coin.Dime) + ":" Coin.Dime // => "Dime:10": 强制转换为字符串

这个例子清楚地展示了JavaScript类的灵活性,JavaScript的类要比C++和Java语言中的静态类要更加灵活。

例:JavaScript中的枚举类型
// 这个函数创建一个新的枚举类型,实参对象表示类的毎个实例的名字和值
// 返回值是一个构造函数,它标识这个新类
// 注意,这个构造函数也会抛出异常:不能使用它来创建该类型的新实例
// 返回的构造函数包含名/值对的映射表
// 包括由值组成的数组,以及一个foreach()迭代器函数
function enumeration(namesToValues) {
	// 这个虚拟的构造函数是返回值
	var enumeration = function () { throw "Can't Instantiate Enumerations"; };

	 // 枚举值继承自这个对象
	var proto = enumeration.prototype = {
		constructor: enumeration,	// 标识类型
		toStxing: function () { return this.name; },  // 返回名字
		valueOf: function () { return this.value; },  // 返回值
		toJSON: function () { return this.name; }     // 转换为JSON
	};

	enumeration.values = [];                            // 用以存放枚举对象的数组

	for(name in namesToValues) {         // 遍历每个值
		var e = inherit(proto);            // 创建一个代表它的对象
		e.name = name;                     // 给它一个名字
  	e.value = namesToValues[name];     // 给它一个值
  	enumeration[name] = e;             // 将它设置为构造函数的属性
  	enumeration.value.push(e);         // 将它存储到值数组中 
	}
	// 一个类方法,用来对类的实例进行迭代 
	enumeration.foreach = function (f, c) {
		for (var i = 0; i < this.values.length; i++) f.call(c, this.values[i]); 
};
	// 返回标识这个新类型的构造函数 
	return enumeration;
}

如果用这个枚举类型来实现一个“hello world"小程序的话,就可以使用枚举类型来表示一副扑克牌。下例中使用enumeration()函数实现了这个表示一副扑克牌的类。

	例:使用枚举类型来表示一副扑克牌
	// 定义一个表示“玩牌”的类
	function Card(suit, rank) {
		this.suit = suit; // 每张牌都有花色
		this.rank = rank; // 以及点数
	}

	// 使用枚举类型定义花色和点数
	Card.Suit = enumeration({Clubs: 1, Diamonds: 2, Hearts: 3, Spades: 4});
	Card.Rank = enumeration({Two: 2, Three: 3, Four: 4, Five: 5, Six: 6, 
				    Seven: 7, Eight: 8, Nine: 9, Ten: 10, 
				    Jack: 11, Queen: 12, King: 13, Ace: 14});

	// 定义用以描述牌面的文本
	Card.prototype.toString = function () {
		return this.rank.toString() + " of " + this.suit.toString();
	};
	//比较扑克牌中两张牌的大小
	Card.prototype.compareTo = function (that) {
		if (this.rank < that.rank) return -1;
		if (this.rank > that.rank) return 1; 
		return 0;
  };	

	// 以扑克牌的玩法规则对牌进行排序的函数
	Card.orderByRank = function (a, b) { return a.compareTo(b); };

  // 以桥牌的玩法规则对扑牌进行排序的函数
	Card.orderBySuit = function (a, b) {
		if (a.suit < b.suit) return -1;
		if (a.suit > b.suit) return 1;
		if (a.rank < b.rank) return -1;
		if (a.rank > b.rank) return 1;
		return 0;
	};

	// 定义用以表示一副标准扑克牌的类
  function Deck() {
		var cards = this.cards = [];	        // 一副牌就是由牌组成的数组
		Card.Suit.foreach(function (s) {	// 初始化这个数组
				     Card.Rank.foreach(function (r) {
							  cards.push(new Card(s, r));
				  });
		});

		// 洗牌的方法:重新洗牌并返回洗好的牌
		Deck.prototype.shuffle = function () {
        // 遍历数组中的每个元素,随机找出牌面最小的元素,并与之(当前遍历的元素)交换 
		var deck = this.cards, len = deck.length;
		for (var i = len - 1; i > 0; i--) {
			 var r = Math, floor(Math.random() * (i + 1)),temp;     // 随机数
           temp = deck[i], deck[i] = deck[r], deck[r] = temp;	        // 交换
       }
		 return this;
  };

	// 发牌的方法:返回牌的数组
	Deck.prototype.deal = function (n) {
		if (this.cards.length < n) throw "Out of cards"; 
		return this.cards.splice(this.cards.length - n, n); 
	};

	// 创建一副新扑克牌,洗牌并发牌
	var deck = (new Deck()).shuffle();
	var hand = deck.deal(13).sort(Card.orderBySuit);

标准转换方法

toString()这个方法的作用是返回一个可以表示这个对象的字符串。在希望使用字符串的地方用到对象的话(比如将对象用做属性名或使用“+”运算符来进行字符串连接运算),JavaScript会自动调用这个方法。如果没有实现这个方法,类会默认从Object.prototype中继承toString()方法,这个方法的运算结果是“[object Object]",这个字符串用处不大。toString()方法应当返回一个可读的字符串,这样最终用户才能将这个输出值利用起来,然而有时候并不一定非要如此,不管怎样,可以返回可读字符串的toString()方法也会让程序调试变得更加轻松。

toLocaleString()toString()极为类似:toLocalestring()是以本地敏感性(locale-sensitive)的方式来将对象转换为字符串。默认情况下,对象所继承的toLocaleString()方法只是简单地调用toString()方法。有一些内置类型包含有用的 toLocaleString()方法用以实际上返回本地化相关的字符串。如果需要为对象到字符串 的转换定义toString()方法,那么同样需要定义toLocaleString()方法用以处理本地化的对象到字符串的转换。下面的Set类的定义中会有相关代码。

valueOf()方法可以将对象转换为原始值。比如,当数学运算符(除了 “+”运算符)和关系运算符作用于数字文本表示的对象时,会自动调用valueOf()方法。大多数对象都没有合适的原始值来表示它们,也没有定义这个方法。

第四个方法是toJSON(),这个方法是由JSON.stringify()自动调用的。JSON格式用于序列化良好的数据结构,而且可以处理JavaScript原始值、数组和纯对象。它和类无关,当对一个对象执行序列化操作时,它会忽略对象的原型和构造函数。比如将Range对象或Complex对象作为参数传入JSON.stringify(),将会返回诸如{"form":1, "to":3}或这种字符串。如果将这些字符串传入JSON.parse(),则会得到一个和Range对象和Complex对象具有相同属性的纯对象,但这个对象不会包含从Range和Complex继承来的方法。

这种序列化操作非常适用于诸如Range和Complex这种类,但对于其他一些类则必须自定义toJSON()方法来定制个性化的序列化格式。如果一个对象有toJSON()方法,JSON. stringify()并不会对传入的对象做序列化操作,而会调用toJSON()来执行序列化操作(序列化的值可能是原始值也可能是对象)。比如,Date对象的toJSON()方法可以返回一个表示日期的字符串。如果要模拟一个集合,最接近JSON的表示方法就是数组,因此在下面的例子中将定义toJSON()方法用以将集合对象转换为值数组。

比较方法

JavaScript的相等运算符比较对象时,比较的是引用而不是值。也就是说,给定两个对象引用,如果要看它们是否指向同一个对象,不是检査这两个对象是否具有相同的属性名和相同的属性值,而是直接比较这两个单独的对象是否相等,或者比较它们的顺序(就像“<”和“>”运算符进行的比较一样)。如果定义一个类,并且希望比较类的实例, 应该定义合适的方法来执行比较操作。

Java编程语言有很多用于对象比较的方法,将Java中的这些方法借用到JavaScript中是一个不错的主意。为了能让自定义类的实例具备比较的功能,定义一个名叫equals()实例方法。这个方法只能接收一个实参,如果这个实参和调用此方法的对象相等的话则返回true。当然,这里所说的“相等”的含义是根据类的上下文来决定的。对于简单的类,可以通过简单地比较它们的constructor属性来确保两个对象是相同类型,然后比较两个对象的实例属性以保证它们的值相等。

按照我们需要的方式比较对象是否相等常常是很有用的。对于某些类来说,往往需要比较一个实例“大于”或者“小于”另外一个示例。比如,你可能会基于Range对象的下边界来定义实例的大小关系。枚举类型可以根据名字的字母表顺序来定义实例的大小,也可以根据它包含的数值(假设它包含的都是数字)来定义大小。另一方面,Set对象其实是无法排序的。

如果将对象用于JavaScript的关系比较运算符,比如和“<”和“<=”,JavaScript会首先调用对象的valueOf()方法,如果这个方法返回一个原始值,则直接比较原始值。

方法借用

JavaScript中的方法没有什么特别:无非是一些简单的函数,赋值给了对象的属性,可以通过对象来调用它。一个函数可以赋值给两个属性,然后作为两个方法来调用它。

多个类中的方法可以共用一个单独的函数。比如,Array类通常定义了一些内置方法,如果定义了一个类,它的实例是类数组的对象,则可以从Array.prototype中将函数复制至所定义的类的原型对象中。如果以经典的面向对象语言的视角来看JavaScript的话,把一个类的方法用到其他的类中的做法也称做“多重继承”(multiple inheritance)。然而,JavaScript并不是经典的面向对象语言,我更倾向于将这种方法重用更正式地称为
“方法借用” (borrowing)。

不仅Array的方法可以借用,还可以自定义泛型方法(generic method)。下例定义了泛型方法toString()equals(),可以被Range、Complex和Card这些简单的类使用。如果Range类没有定义equals()方法,可以这样借用泛型方法equals():

Range.prototype.equals = generic.equals;

注意,generic.equals()只会执行浅比较,因此这个方法并不适用于其实例太复杂的 类,它们的实例属性通过其equals()方法指代对象。同样需要注意,这个方法包含一些 特殊情况的程序逻辑,以处理新增至Set对象中的属性。

例:方法借用的泛型实现
var generic = {
        // 返回一个字符串,这个字符串包含构造函数的名字(如果构造函数包含名字)
        // 以及所有非继承来的、非函数属性的名字和值
		toString: function () {
			var s = '[';
			// 如果这个对象包含构造函数,且构造函数包含名字
			// 这个名字会作为返回字符串的一部分
		        // 需要注意的是,函数的名字属性是非标准的,并不是在所有的环境中都可用
                if (this.constructor && this.constructor.name) 						
     		    s += this.constructor.name + ": ";

                // 枚举所有非继承且非函数的属性
                var n = 0;
                for (var name in this) {
                		if (!this.hasOwnProperty(name))continue; // 跳过继承来的属性
                var value = this[name];
                if (typeof value === "function") continue;               // 跳过方法
                if (n++) s += ", ";
	             s += name + '=' + value;
		}
		return s + ']';
},
// 通过比较this和that的构造函数和实例属性来判断它们是否相等
// 这种方法只适合于那些实例属性是原始值的情况,原始值可以通过“===”来比较
// 这里还处理一种特殊情况,就是忽略由Set类添加的特殊属性
equals: function (that) {
	 if (that == null) return false;
	 if (this.constructor !== that.constructor) return false;
    for (var name in this) {
   	if (name === "|**objectid**|") continue;	// 跳过特殊属性
	     if (!this.hasOwnProperty(name)) continue;       // 跳过继承来的属性
        if (this[name] !== that[name]) return false;    // 比较是否相等
    }
    return true; //如果所有属性都匹配,两个对象相等
};

私有状态

在经典的面向对象编程中,经常需要将对象的某个状态封装或隐藏在对象内,只有通过对象的方法才能访问这些状态,对外只暴露一些重要的状态变量可以直接读写。为了实现这个目的,类似Java的编程语言允许声明类的“私有”实例字段,这些私有实例字段只能被类的实例方法访问,且在类的外部是不可见的。

我们可以通过将变量(或参数)闭包在一个构造函数内来模拟实现私有实例字段,调用构造函数会创建一个实例。为了做到这一点,需要在构造函数内部定义一个函数(因此这个函数可以访问构造函数内部的参数和变量),并将这个函数赋值给新创建对象的属性。下例展示了对Range类的另一种封装,新版的类的实例包含from()to()方法用以返回范围的端点,而不是用from和to属性来获取端点。这里的from()to()方法是定义在每个Range对象上的,而不是从原型中继承来的。其他的Range方法还是和之前一样定义在原型中,但获取端点的方式从之前直接从属性读取变成了通过from()to()方法来读取。

例:对Range类的读取端点方法的简单封装
function Range(from, to) {
	//不要将端点保存为对象的属性,相反
	//定义存取器函数来返回端点的值
	//这些值都保存在闭包中
	this.from = function () { return from; };
		this.to = function () { return to; };

	// 原型上的方法无法直接操作端点
	// 它们必须调用存取器方法
	Range.prototype = {
		constructor: Range,
		includes: function (x) { return this.from() <= x && x <= this.to(); }, 
		foreach: function (f) {
			for (var x = Math.ceil(this.from()), max = this.to(); x <= max; x++) f(x);
	},
	toString: function () { return "(" + this.from() + "..." + this.to() + ")"; }
};

这个新的Range类定义了用以读取范围端点的方法,但没有定义设置端点的方法或属性。这让类的实例看起来是不可修改的,如果使用正确的话,一旦创建Range对象,端点数据就不可修改了。除非使用ECMAScript 5 中的某些特性,但from和to属性依然是可写的,并且Range对象实际上并不是真正不可修改的:

var r = new Range(1,5);	                // 一个不可修改的范围
r.from = function() { return 0; };	// 通过方法替换来修改它

但需要注意的是,这种封装技术造成了更多系统开销。使用闭包来封装类的状态的类一定会比不使用封装的状态变量的等价类运行速度更慢,并占用更多内存。

构造函数的重载和工厂方法

有时候,我们希望对象的初始化有多种方式。比如,我们想通过半径和角度(极坐标)来初始化一个Complex对象,而不是通过实部和虚部来初始化,或者通过元素组成的数组来初始化一个Set对象,而不是通过传入构造函数的参数来初始化它。

有一个方法可以实现,通过重载(overload)这个构造函数让它根据传入参数的不同来执行不同的初始化方法。下面这段代码就是重载Set()构造函数的例子:

	function Set() {
		this.values = {};	// 用这个对象的属性来保存这个集合
		this.n = 0;	        // 集合中值的个数

      // 如果传入一个类数组的对象,将这个元素添加至集合中
      // 否则,将所有的参数都添加至集合中
      if (arguments.length == 1 && isArrayLike(arguments[0]))
      	this.add.apply(this, arguments[0]);
		else if (arguments.length > 0)
		  	this.add.apply(this, arguments);
}

这段代码所定义的Set()构造函数可以显式将一组元素作为参数列表传入,也可以传入元素组成的数组。但是这个构造函数有多义性,如果集合的某个成员是一个数组就无法通过这个构造函数来创建这个集合了(为了做到这一点,需要首先创建一个空集合,然后显式调用add()方法)。

在使用极坐标来初始化复数的例子中,实际上并没有看到有函数重载。代表复数两个维度的数字都是浮点数,除非给构造函数传入第三个参数,否则构造函数无法识别到底传入的是极坐标参数还是直角坐标参数。相反,可以写一个工厂方法————一个类的方法用以返回类的一个实例。下面的例子即是使用工厂方法来返回一个使用极坐标初始化的Complex对象:

Complex.polar = function(r, theta) {
		return new Complex(r*Math.cos(theta), r*Math.sin(theta));
};

下面这个工厂方法用来通过数组初始化Set对象:

Set.fromArray = function(a) {
	  s = new Set();	    // 创建一个空集合
    s.add.apply(s, a);	    // 将数组a的成员作为参数传入add()方法
    return s;	            // 返回这个新集合
};

可以给工厂方法定义任意的名字,不同名字的工厂方法用以执行不同的初始化。但由于构造函数是类的公有标识,因此每个类只能有一个构造函数。但这并不是一个“必须遵守”的规则。在JavaScript中是可以定义多个构造函数继承自一个原型对象的,如果这样做的话,由这些构造函数的任意一个所创建的对象都属于同一类型。并不推荐这种技术,但下面的示例代码使用这种技术定义了该类型的一个辅助构造函数:

// Set类的一个辅助构造函数
function SetFromArray(a) {
	// 通过以函数的形式调用Set()来初始化这个新对象
	// 将a的元素作为参数传入
	Set.apply(this, a);
}
//设置原型,以便SetFromArray能创建Set的实例
SetFromArray.prototype = Set.prototype;

var s = new SetFromArray([1,2,3]);
s instanceof Set // => true