封装和信息隐藏
为对象创建私用成员是任何面向对象语言中最基本和有用的特性之一。通过将一个方法或属性声明为私用的,可以让对象的实现细节对其他对象保密以降低对象之间的耦合程序,可以保持数据的完整性并对其修改方式加以约束。在代码有许多人参与设计的情况下,这也可使代码更可靠、更易于测试。简而言之,封装是面向对象的设计基石。
尽管javascript是一种面向对象语言,它并不具备用以将成员声明为公用或私用的任何内置机制。与前述的接口一样,我们将自己想办法实现这种特性。目前有几种办法可以用来创建具有公用、私用和特权方法的对象。它们各有公优缺点。我们也会讨论在哪些场合下javascript程序员能够接受益于经过复杂封装的对象。
信息隐藏原则
如果这种内部实现发生变化,你必须重新学习整个系统并从头开始。用面向对象设计的术语来说,你与原始数据之间形成了强耦合。信息隐藏原则有助于减轻系统中两个参与者之间的依赖性。它指出,两个参与者必须通过明确的通道传送信息。在本节中这些通道就是对象间的接口。
封装与信息隐藏
封装与信息隐藏之间是什么关系?你可以把它们视为同一个概念的两种表述。信息隐藏是目的,而封装是为了达到这个目的的技术。
封装:可以被定义为对对象的内部数据表现形式和实现细节进行隐藏。要想访问封装过的对象中的数据,只有使用自己定义的操作这一种办法。通过封装可以强制实施信息隐藏。许多面向对象语言都使用关键字来说明某些方法和属性被隐藏。如在java中用关键字private来声明一个方法,可以确保只有该对象内部的代码才能执行它。但javascript中没有这样的关键字,我们将使用闭包的概念来创建只允许从对象内部访问的方法和属性。这比使用关键字的办法更复杂,但它也能获得同样的最终效果。
接口扮演的角色
在向其他对象隐藏信息的过程中接口是如何发挥作用的呢?接口提供了一份记载着可供公众访问的方法的契约。它定义了两个对象间可具有的关系,只要接口不变,这个关系的双方都是可替换的。你不一定非得使用像前面介绍的那种严格的的接口,但大多数情况下,你将发现对可以使用方法的加以记载会很有好处。不是有了接口就万事大吉,你应该避免公开未定义于接口中的方法。否则其他对象可能会对这些不属于接口的方法产生依赖,而这是不安全的。因为这些方法随时都可能发生改变或被删除,从而导致系统失灵。
一个理想的软件系统应该为所有定义的类定义接口。这些类只向外界提供它们实现的接口中规定的方法,任何别的方法都留作自用。其所有属性都是私用的,外界只能通过接口中定义的存取操作与之打交道。但实际上系统很少能真正达到这样的境界。优质的代码应尽量向这个目标选靠拢,但又不能过于刻板。把那些并不需要这些特性的简单项目复杂化。
创建对象的基本模式
javascript创建对象的基本模式有3种
- 门户大开型:对象创建方式简单的一种,但它只能提供公用成员。
- 在第一种之上有所改进,它使用下划线来表示方法或属性的私用性。
- 做法使用闭包来创建真正私用的成员,这些成员只能通过一些特权方法访问。
以Book类为例,假设你接到这样一项任务:创建一个用来存储关于一本书的数据类,并为其实现一个以html形式显示这些数据的方法。你只负责创建这个类,别人分创建并使用其实例。它会被这样使用:
//Book(isbn,title,author)
var theHobbit = new Boot(“0-395-07122-4”,”The Hobbit”,”J.R.R.TOLKINE”);
theHobbit.display();
门户大开型对象
实现Book类简单的做法就是按照传统方式创建一个类,用一个函数来做其构造器。我们称其为门户大开型对象,因为它所有属性和方法都是公开的、可访问的。这些公用属性需要使用this关键字来创建:
var Book =function (isbn,title,author){
if(isbn==undefined)throw new Error(‘bookconstructor require an isbn. ’);
this.isbn = isbn;
this.title = title||”No title specified”;
this.author = author ||”No titlespecified”;
}
Book.prototype.display= function(){
….
}
在构造器中,如果检查到没有提供ISBN,将会抛出一个错误。这是因为display方法要求书箱对象有一个准确的ISBN,否则就不能找到相应的图片,也不能生成一个用于购书的链接。title和author参数都是可选的,所以要准备默认值以防它们未被提供。逻辑“或”运行符”||“在此用于提供后备值。如果提供了title或author,那么运算符左边的运算数的求值结果为true,因此这个运算数会被作为运算结果返回。
乍一看这个类似乎符合一切需要。但其最大的问题是你无法检验ISBN数据的完整性,而不完整的ISBN数据有可能导致display方法失灵。这破坏了你与其他程序员之间的契约。如果book对象在创建时没有抛出任何错误,那么display方法应该能正常工作才对,但是没有进行完整性检查,这就不一定了。为了解决这个问题,下面的版本强化了对ISbN检查:
var Book =function (isbn,title,author){
if(isbn==undefined)throw new Error(‘bookconstructor require an isbn. ’);
this.isbn = isbn;
this.title = title||”No title specified”;
this.author = author ||”No titlespecified”;
}
var book={};
Book.prototype = {
checkIsbn:function(isbn){
if(isbn==undefined || typeofisbn!=’string’){
return false;
}
isbn =isbn.replace(/-/g,"");//移除所有减号
if(isbn.length!=10 &&isbn.length!=13){
return false;
}
var sum = 0;
if(isbn.length==10){ //isbn 10length
if(!isbn.match(/^d{9}/)){
return false;
}
for(var i=0;i<9;i++){
sum+=isbn.carAt(i)*(10-i);
}
var checksum=sum%11;
if(checksum===10)checksum=”X”;
if(isbn.charAt(9)!=checksum){
return false;
}
}else{
//length 13
if(!isbn.match(/^d{12}/)){
return false;
}
for(var i=0;i<12;i++){
sum+=isbn.charAt(i)*((i%2===0)?1:3);
}
var checksum=sum%10;
if(isbn.charAt(12)!=checksum){
return false;
}
}
return true;
},
display:function(){
//....
}
}
上述代码中添加了一个checkIsbn方法,以保证ISBN是一个具有正确的位数和校验和的字符串。因为在该类有两个方法,所以Book.prototype被设为一个对象字面量,这样在定义多个方法的时候就不用在每个方法前面都加Book.prototype了。
即使能在构造器中对数的完整性进行检验,你对其他程序员会把什么样的值直接赋给isbn属性还是毫无控制。为了保护内部数据你为每个属性都提供了取值器accessor和赋值 器mutator方法
var Publication = newInterface(“Publication”,[“getIsbn”,”setIsbn”,”getTitle”,”setTitle”,”getAuthor”,”setAuthor”,”display”]);
var Book =function (isbn,title,author){
if(isbn==undefined)throw new Error(‘bookconstructor require an isbn. ’);
this.isbn = isbn;
this.title = title||”No title specified”;
this.author = author ||”No titlespecified”;
}
Book.prototype = {
checkIsbn:function(){
//...前面有提到
},
getIsbn:function(){
return this.isbn;
},
setIsbn:function(isbn){
if(!this.checkIsbn(isbn)) throw newError("Book:Invalid ISbN");
this.isbn = isbn;
},
getTitle:function(){
return this.title;
},
setTitle:function(title){
this.title = title || "Notitle specified";
},
getAuthor:function(){
return this.author;
},
setIsbn:function(){
this.author = author || "Noauthor specified";
},
display:function(){
//...
}
}
虽然我们为设置属性提供了赋值器方法,但那些属性仍然是公开的,可以被直接设置,而在这种方案中却无法阻止这种行为。不管是出于有意还是无意,isbn属性都可能会被设置为一个无效的值。
这种方式虽然存在一些缺陷,但它的优点还是挺多地,它易于使用,javascript编程新手很快就能学会。创建这样的对象不要求你深入理解作用域或调用链的概念。由于所有方法和属性都是公开的,派生子类和进行单元测试也很容易。唯一的弊端在于无法保护内部数据,而且取值器和赋值器方法也引入了严格来说并非必不可少的额外的代码。
用命名规范区别私用成员
这种方法致力于解决上一节中遇到的一个问题,即无法阻止其他程序员无意中绕过所有检验步骤。从本质上说这种模式与门户大开型对象创建模式如出一辙,只不过在一些方法和属性的名称前加了下划线以示其私用性而已。
var Book =function (isbn,title,author){
if(isbn==undefined)throw new Error(‘bookconstructor require an isbn. ’);
this._isbn = isbn;
this._title = title||”No title specified”;
this._author = author ||”No titlespecified”;
}
Book.prototype = {
checkIsbn:function(){
//...前面有提到
},
getIsbn:function(){
return this._isbn;
},
setIsbn:function(isbn){
if(!this.checkIsbn(isbn)) throw newError("Book:Invalid ISbN");
this. _isbn= isbn;
},
getTitle:function(){
return this. _title;
},
setTitle:function(title){
this. _title= title || "No title specified";
},
getAuthor:function(){
return this. _author;
},
setIsbn:function(){
this. _author= author || "No author specified";
},
display:function(){
//...
}
}
所有属性都已重新命名。每个属性的名称前都加了一个下划线,表示它是私用属性。由于下划线在javascript中要以作为标识符的第一字符,所以它仍然是有效的变量名。这种命名规范也可以应用于方法。
下划线的这种用法这一个众所周知的命名规范,它表明一个属性仅供对象内部使用,直接访问它或设置它可能会导致意想不到的后果。这有助于防止程序员对它的无意使用,却不能防止对它的有意使用。后一个目标的实现需要有真正私用 性的方法。这种创建对象的模式具有门户大开型对象创建模式的所有优点,而且比后者少了一个缺点。但是,它只是一种约定,只有在得到遵守时才有效果,而且并没有什么强制性手段可以保证这一点。所以它并不是真正可以用来隐藏对象内部数据的解决方案。它主要适用于非敏感性的内部方法和属性。也即,那些因为未见于公开接口,所以类的大多数使用者都不会关心的方法和属性。
作用域、嵌套函数和闭包
javascript只有函数做用域。也就说,在一个函数内部声明的变量在函数外部无法方法。私用属性就其本质而言就是你希望在对象外部无法访问的变量,所以为实现这种拒访性而求助于作用域这个概念是合乎情理的。定义在一个函数中的变量在该函数的内嵌函数中是可以访问的。
如:
functon foo(){
var a = 10;
function bar(){
a*=2;
}
bar();
return a;
}
在这个示例中,a定义在函数foo中,但函数bar可能访问它,因为bar也定义在foo中。bar在执行过程中将a设置为a乘以2。当bar在foo中被调用时它能够访问a,这可以理解,但是如果bar是在foo外部被调用呢如下:
functon foo(){
var a = 10;
function bar(){
a*=2;
}
return bar;
}
var baz = foo();
baz(); //20
baz(); //40
baz(); //80
var blat = foo();//blat is another reference to bar.
blat(); //return20,
在上述代码中,所返回的对bar函数的引用被赋给变量baz。这个函数现在是在foo外部被调用,但它仍然能够访问a。这是因为javascript中的作用域是词法性。函数运行在定义它们的作用域中,而不是运行在它调用它们的作用域中。只要bar被定义在foo中,它就能访问在foo中定义的所有变量,即使foo的执行已经结束。
这就是闭包的一个例子。在foo返回后,它的作用域被保存下来,但只有它返回的那个函数能够访问这个作用域。在前面的示例中,baz和blat各有这个作用域及a的一个副本,而且只有它们自己能对其进行修改。返回一个内嵌函数是创建闭包最常用的手段。
用闭包实现私用成员
现在回过来看手头的那个问题:你需要创建一个只能在对象内部访问的变量。闭包用于这个目的看来是再合适不过了,因为借助于闭包你可以创建只允许特定函数访问的变量,而且这些变量在这些函数的各次调用之间依然存在。为了创建私用属性,你需要在构造构造器函数的作用域中定义相关变量。这些变量可以被定义于该作用域中的所有函数访问。
var Book =function(newIsbn,newTitle,newAuthor){
//private attributes.
var isbn,title,author;
function checkIsbn(isbn){
}
//私有方法
this.getIsbn = function(){
return isbn;
};
this.setIsbn= function(newIsbn){
if(!checkIsbn(newIsbn)) throw newError("Book:Invalid ISBN");
isbn = newIsbn;
};
this.getTitle= function(){
return title;
}
this.setTitle= function(newTitle){
title = newTitle || "No titlespecified";
}
this.getAuthor= function(){
return author;
};
this.setAuthor= function(newAuthor){
author = newAuthor || "Noauthor specified";
}
};
Book.prototype = {
display:function(){
// ….
}
}
那么这与我们先前讲过的其他创建对象的模式有什么不同呢,在其他使用Book的例子中,我们在创建和引用对象的属性时总要使用this关键字。而在本例中,我们用var声明这些变量。这意味着它们只存在于Book构造器中。checkIsbn函数也是用同样的方式声明的,因此成了一个私用方法。
需要访问这些变量和函数的方法只需要声明在Book中即可。这些方法被称为特权方法,因为它们是公用方法,但却能够访问私用属性和方法。为了在对象外部能访问这些特权函数,它们的前面被加上了关键字this。因为这些方法定义于Book构造器的作用域,所以它们能访问到私用属性。引用这些属性时并没有使用this关键字,因为它们不是公开的。所有取值器和赋值器方法都被改为不加this地直接引用这些属性。
任何不需要直接访问的私用属性的方法都可以像原来那样在Book.prototype中声明。像display方法。只有那些需要直接访问私用成员的方法才应该被设计为特权方法。但特权方法太多又会占用过多的内存,因为每个对象实例都包含所有特权方法的新副本。
这种对象创建模式解决了其他模式中的所有问题,但它也有自己的一些弊端。在门户大型对象创建模式中,所有方法都创建在原型对象中,因此不管生成多少对象实例,这些方法在内存中只存在一份。而采用本节讨论的做法。每生成一个新的对象实例都将为每一个私用方法和特权方法生成一个新的副本。这会比其他做法耗费更多内存,所以只宜用在需要真正的私用成员的场合。这种对象的创建也不利于派生子类,因为所派生出的子类不能访问超类的任何私用属性或方法。相比之下,在大多数语言中,子类都能访问超类的所有私用和方法。故在javascript中用闭包实现私用成员导致的派生问题被称为“继承破坏封装”
更多高级对象创建模式
静态方法和属性
前面所讲的作用域和闭包的概念可用于创建静态成员,包括公用的和私用的。大多数方法和属性所关联的是类的实例,而静态成员所关联的则是类本身。换句话说,静态成员是在类的层次上操作,而不是在实例的层次上操作。每个静态成员都只有一份。静态成员直接通过类对象访问
var Book =(function(){
//private static attribute
var numOfBooks = 0;
//private static method.
function checkIsbn(isbn){
//...
}
//return the constructor.
return function(newIsbn,newTitle,newAuthor){
//private attributes.
var isbn,title,author;
function checkIsbn(isbn){
}
//私有方法
this.getIsbn = function(){
return isbn;
};
this.setIsbn = function(newIsbn){
if(!checkIsbn(newIsbn)) thrownew Error("Book:Invalid ISBN");
isbn = newIsbn;
};
this.getTitle = function(){
return title;
}
this.setTitle = function(newTitle){
title = newTitle || "Notitle specified";
}
this.getAuthor = function(){
return author;
};
this.setAuthor =function(newAuthor){
author = newAuthor || "Noauthor specified";
}
//constructor code.
numOfBooks++; //keep track of howmanay books have been instantiated with the private static attribute.
if(numOfBooks>50) throw new Error("book:only 50 instance of book can be created");
this.setIsbn(newIsbn);
this.setTitle(newTitle);
this.setAuthor(newAuthor);
}
})();
//public static method
Book.convertToTitleCase =function(inputString){
//...
}
//public non-privileged methods.
Book.prototype = {
display:function(){
//...
}
}
这里的私用成员和特权成员仍然被声明在构造器。但那个构造器却从原来的普通函数变成了一个内嵌函数,并且被作为包含它的函数的返回值给变量Book。这就创建了一个闭包,你可以把静态的私用成员声明在里面。位于外层函数声明之后的一对空括号很重要,其作用是代码一载入就立即执行这个函数。这个函数的返回值是另一个函数,它被赋给Book变量,Book因此成了一个构造函数。在实例华Book时,所调用的这个内层函数。外层那个函数只是用于创建一个可以用来存储静态成员的闭包。
在本例中,checkIsbn被设计成为静态方法,原因是为Book的每个实例都生成这个方法的一个新副本毫无道理。此外还有一个静态属性numOfBooks,其作用在于跟踪Book构造器的总调用次数。本例利用这个属性将Book实例的个数限制为不超过50个。
创建公用的静态成员则容易多,只需要直接将其作为构造函数这个对象的属性创建即可。
所有公用静态方法如果作为独立的函数来声明其实也同样简单,但最好还是像这样把相关行为集中在一起。这些方法用于与类这个整体相关的任务,而不是与类的任一特定实例相关的任务。它们并不直接依赖于对象实例的中包含的任何数据。
常量
常量只不过是一些不能修改的变量。在javascript中,可以通过创建只有取值器而没有赋值器的私用变量来模仿常量。因为常量往往是在开发时进行设置,而且不因对象实例的不同而变化,所以将其作为私用静态属性来设计是合乎情理的。假设Class对象有一个名为UPPER_BOUND的常量,那么为获取这个常量而进行的方法调用如下
:Class.getUPPER_BOUND()
实现这个聚会器,需要使用我们还未讲过的特权静态方法:
var Class =(function(){
//Constants (created as private stticattributes).
var UPPER_BOUND=100;
var ctor =function(constructorArguments){
//...
}
//privileged static method
ctor.getUPPER_BOUND = function(){
return UPPER_BOUND;
}
//return the constructor.
return ctor;
})();
如果需要使用许多常量,但你不想为每个常量都创建一个取值器方法,那么可以创建一个通用的取值器方法:
var Class =(function(){
var constants = {
UPPER_BOUND:100,
LOWER_BOUND:-100
};
//constructior
var ctor =function(constructorArgument){
//..
}
//privileged static method
ctor.getConstant = function(name){
return constants[name];
}
//....
//Return the constructor
return ctor;
})();
单体和对象工厂
其他还有一些模式也使用闭包来创建受保护的变量空间。在这方面最突出的两个是单体模式和工厂模式。后面会提到!
单体模式使用一个由外层函数返回的对象字面量来公开特权成员,而私用成员则被保护性地封装在外层函数的作用域中。