javascript设计模式学习笔记三
封装和信息隐藏
为对象创建私有成员是任何面向对象语言中最基本和有用的特性之一。通过将一个方法或属性声明为私用的,可以让对象的实现细节对其他对象之间的耦合程度,可以保持数据的完整性并对其修改方式加以约束。在代码有许多人参与设计的情况下,这个可以使代码更可靠、更易于调试。简而言之,封装是面向对象的设计基石。
尽管JavaScript是一种面向对象的语言,它并不具备用以将成员声明为公用或私用的任何内置机制。与讲述接口的前一章一样,我们自己想办法实现这种特性。目前有几种可以用来创建具有公用、私用和特权方法的对象,它们各有其优缺点。
3.1 信息隐藏原则
信息隐藏原则有助于减轻系统中两个参与者之间的依赖性。它指出,两个参与者必须通过明确的通道传送信息。这些通道就是对象间的接口。
3.1.1 封装与信息隐藏
封装与信息隐藏之间是什么关系呢?你可以把它们视为同一个概念的两种表述。信息隐藏是目的,而封装则是籍yidadao这个目的的技术。
封装可以被定义为对对象的内部数据表现形式和实现细节进行隐藏。要想访问封装过的对象中的数据,只有使用已定义的操作这一种方法。通过封装可以强制实时信息隐藏。许多面向对象语言都使用关键字来说明某些方法和属性应被隐藏。例如Java中,用关键字与private来声明一个方法,可以确保只有该对象内部的代码才能执行它。但在JavaScript中没有这样的关键字,我们将使用闭包的概念来创建只允许从对象内部访问的方法和属性。这比使用关键字的方法更复杂(更费解),但它也能获得同样的最终效果。
3.1.2 接口扮演的角色
在向其他对象隐藏信息的过程中接口是如何发挥作用的呢?接口提空了一份记载着可供公众访问的方法的契约。它定义了两个对象间可以具有的关系。只要接口不变,这个关系的双方都是可替换的。对可以使用的方法加以记载会很有好处。不是有了接口就万事大吉,你应该避免公开未定义于接口中的方法。否则其他对象可能会对那些并不属于接口的方法产生依赖,而这是不安全的。因为这些方法随时都可能发生改变或被删除,从而导致整个系统失灵。
一个理想的软件系统应该为所有的类定义接口。这些了只向外界提供他们实现的接口中规定的方法,任何别的方法都留作自用。其所有属性都是私用的,外界只能通过接口中定义的存取操作与之打交道。
3.2 创建对象的基本模式
将讨论创建对象的各种不同的方式及其特点。JavaScript中创建对象的基本模式有3种。门户大开型对象创建方式是最简单的一种,但它只能提供公用成员。第二种做法在此方面有所改进,他使用下划线来表示方法或属性的私用性。第三种做法使用闭包来创建真正的私用的成员,这些成员只能通过一些特权的方法访问。
注解:不能简单地说这些定义类的模式中哪种是“正确的”。它们都有各自的利弊,每一种做法都有可能适合你,具体取决于项目的需要。
以Book类为例。假设你接到这样一项任务:创建一个用来关于一本书的数据的类,并为其实现一个以HTML形式显示这些数据的方法。你只负责这个类,别人会创建并使用实例。他会被这样使用:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 // Book(isbn, title, author) 2 3 var theHobbit = new Book('0-395-07122-4', 'The Hobbit', 'J.R.R.Tolkien'); 4 theHobbit.display(); // outputs the data by creating and populating an html element.
实现Book类最简单的做法是按传统方式创建一个类,用一个函数来做其构造器。我们称其为门户大开型对象,因为他的所有属性和方法都是公开的、可访问的。这些公用的属性需要使用this关键字来创建:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 var Book = function(isbn, title, author) { 2 if(isbn == undefined) throw new Error('Book constructor requires an isbn.') 3 this.isbn = isbn; 4 this.title = title || 'No title specified'; 5 this.author = author || 'No author specified'; 6 } 7 8 Book.prototype.display = function() {....};
在构造器中,如果检查到没有提供ISBN,将会抛出错误。这是因为display方法要求书籍对象有一个准确的ISBN,否则就不能找到相应的图书,也不能生成一个用于购书的链接。title和author都是可选的,所以要准备默认的值以防他们未被提供。逻辑“或”运算符“||”在此用于提供后备值。如果提供了title或author,那么运算符左边的运算数的求值结果为true,因此这个运算数会被作为运算结果返回。如果没有提供title或author,那么左边的运算数的求值结果为false,作为运算结果返回的是左边的运算数。
乍一看这个类似乎符合一切需要。但其最大的问题使你无法检验ISBN数据的完整性,而不完整的ISBN数据有可能导致display方法失灵。这破坏了你与其他程序员之间的锲约。如果Book对象在创建时没有抛出任何错误,那么display方法应该能正常工作才对,但是由于没有进行完整性检查,这就不一定了。为了解决这个问题,下面的版本强化了对ISBN的检查:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 var Book = function(isbn, title, author) { 2 if(!this.checkIsbn(isbn)) throw new Error('Book: Invalid ISBN.'); 3 this.isbn = isbn; 4 this.title = title || 'No title specified'; 5 this.author = author || 'No author specified'; 6 } 7 8 Book.prototype = { 9 checkIsbn: function(isbn) { 10 if(isbn == undefined || typeof isbn != 'string') { 11 return false; 12 } 13 14 isbn = isbn.replace(/-/, ''); // 移除 - 15 if(isbn.length != 10 && isbn.length != 13) return false; 16 17 var sum = 0; 18 if(isbn.length === 10) { // 10位数的ISBN 19 if(!isbn.match(/^\d{9}/)) return false; // 为数字 20 for(var i=0; i < 9; i++) { 21 sum += isbn.charAt(i) * (10 - i); 22 } 23 var checksum = sum % 11; 24 if(checksum === 10) checksum = 'X'; 25 if(isbn.charAt(9) != checksum) return false; 26 } else { // 13位数的ISBN 27 if(!isbn.match(/^\d{12}/)) return false; // 为数字 28 for(var i=0; i < 12; i++) { 29 sum += isbn.charAt(i) * ((i % 2 === 0) ? 1 : 3); 30 } 31 var checksum = sum % 10; 32 if(isbn.charAt(12) != checksum) return false; 33 } 34 return true; 35 }, 36 display: function() { 37 // doing some thing 38 } 39 }
上述代码中添加了一个checkIsbn方法,以保证ISBN是一个具有正确的位数和校验和的字符串。现在情况看起来有所改善。在创建对象的时候可以对ISBN的有效性进行检查,这可以确保display方法能正常工作。但是现在又出现一个问题。假设另一个程序员认识到一本书可能会有多个版本,每个版本都有自己的ISBN。它设计了一个用来在这些不同版本之中进行选择的算法,并在实例化书籍对象之后直接用它修改其isbn属性:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 theHobbit.isbn = '978-026110328'; 2 theHobbit.display();
即使能在构造器中对数据的完整性进行检验,你对其他程序员会把什么样的值直接赋给isbn属性还是毫无控制。为了保护内部数据,腻味每个属性都提供了取值器和赋值器方法。取值器方法(通常以getAttributeName这种形式命名)用于获取属性值,而赋值器方法(通常以setAttributeName这种形式命名)则用于设置属性值。通过使用赋值器,你可以在把一个新值真正赋给属性之前进行各种检验。下面是加入了取值器和赋值器之后的新版Book对象:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 var Publication = new interface('Publication', ['getIsbn', 'setIsbn', 'getTitle', 'setTitle', 'getAuthor', 'setAuthor', 'display']); 2 3 var Book = function(isbn, title, author) { 4 this.setIsbn(isbn); 5 this.setTitle(title); 6 this.setAuthor(author); 7 } 8 9 Book.prototype = { 10 checkIsbn: function(isbn) { 11 // 和之前的方法一样 12 }, 13 getIsbn: function() { 14 return this.isbn; 15 }, 16 setIsbn: function(isbn) { 17 if(!this.checkIsbn(isbn)) throw new Error('Book: Invalid ISBN.'); 18 this.isbn = isbn; 19 }, 20 getTitle: function() { 21 return this.title; 22 }, 23 setTitle: function(title) { 24 this.title = title || 'No title specifiled'; 25 }, 26 getAuthor: function() { 27 return this.author; 28 }, 29 setAuthor: function(author) { 30 this.author = author || 'No author specifiled'; 31 }, 32 display: function() { 33 // doing somiething 34 } 35 }
注意,上述代码中还定义了一个接口。从现在开始,其他程序员应该只使用这个接口中定义的方法与对象的内部也使用这些方法。
这是使用门户大开型对象创建方式所能得到的最好的结果。里面包含了一个明确定义的接口、一些对数据具有保护作用的取值器和赋值器方法,以及一些有效性检验方法。尽管这个设计方案有这样一些特性,它还是存在一个漏洞。虽然我们为设置属性提供了赋值器方法,但那些属性仍然是公开的,可以直接被设置,而在这种方案中却无法阻止这种行为。不管是出于有意还是无意,isbn属性都可能会被设置为一个无效值。
尽管这种创建对象的模式存在上述缺陷,它也有许多优点。它易于使用,JavaScript编程新手很快能学会。创建这样的对象不要求你深入理解作用于或调用链的概念。由于所有方法和属性都是公开的,派生子类和进行单元测试也很容易。唯一的弊端在于无法保护内部数据,而且取值器和赋值器方法也引入了严格说来并非必不可少的额外代码(在JavaScript文件大小很重要的场合,这可能是一个应该掂量以下的问题。)
3.2.2 用命名规范区别私用成员
本节讨论通过使用命名规范模仿私用成员的模式。这种方法致力于解决上一节中遇到的一个问题,即无法阻止其他程序猿无意中绕过所有检验步骤。从本质上说这种模式与门户大开型对象创建模式如出一辙,只不过在一些方法和属性上的名称前面加了下划线以示其私用性而已。
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 var Book = function(isbn, title, author) { // implements publication 2 this.setIsbn(isbn); 3 this.setTitle(title); 4 this.setAuthor(author); 5 } 6 7 Book.prototype = { 8 checkIsbn: function() { 9 // doing someing 10 }, 11 getIsbn: function() { 12 return this._isbn; 13 }, 14 setIsbn: function(isbn) { 15 if(!this.checkIsbn(isbn)) throw new Error('Book: Invaild ISBN.'); 16 this._isbn = isbn; 17 }, 18 getTitle: function() { 19 return this._title; 20 }, 21 setTitle: function(title) { 22 this._title = title || 'No title sepcified' 23 }, 24 getAuthor: function() { 25 return this._author; 26 }, 27 setAuthor: function(author) { 28 this._author = _author || 'No author sepcified' 29 }, 30 display: function() { 31 // doing someing 32 } 33 }
在这个例子中,所有属性都已重新命名。每个属性的名称前都加了一个下划线,表示他是私用属性。由于下划线在JavaScript中可以用做标识符的第一个字符,所以他们仍然是有效的变量名。
下划线的这种用法是一个众所周知的命名规范,他表示一个属性(或方法)仅供对象内部使用,直接访问它或设置它可能会导致意想不到的后果。这有助于防止程序员对他的无意使用,却不能防止对他的有意使用。
这种创建对象的模式具有门户大开型对象创建模式的所有优点,而且比后者少了一个缺点。但是,他只是一种约定,只有在得到遵守时才有效,而且并没有什么强制性手段可以保证这一点。所以它并不是真正可以用来隐藏对象内部数据的解决方案。它主要适用于非敏感性的内部方法和属性,也即,那些因为未见与公开的接口,所以类的大多数使用者都不会关心的方法和属性。
3.2.3 作用于、嵌套函数和闭包
在讨论真正的私用性方法和属性的实现技术之前,我们先花点时间解释一下这种技术背后的原理。在JavaScript中,只有函数具有作用域。也就是说,在一个函数内部声明的变量在函数外部无法访问。私用属性就其本质而言就是你希望在对象外部无法访问的变量,所以为实现这种拒访性而求助于作用域这个概念是合乎情理的。定义 一个函数中的变量在该函数的内嵌函数中是可以访问的。下面这个示例说明了JavaScript中作用域的特点。
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 function foo() { 2 var a = 10; 3 4 function bar() { 5 a *=2; 6 } 7 bar(); 8 return a; 9 }
在这个示例中,a定义在函数foo中,但函数bar可以访问它,因为bar也定义在foo中。bar在执行过程中将a设置为a乘以2。当bar在foo中被调用时他能够访问a,这可以理解。但是如果bar是在foo外部被调用呢?
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 function foo() { 2 var a = 10; 3 function bar() { 4 a *= 2; 5 return a; 6 } 7 return bar; 8 } 9 var baz = foo(); // baz is now a reference to function bar 10 baz(); // return 20. 11 baz(); // return 40. 12 baz(); // return 80. 13 14 var blat = foo(); // blat is another reference to bar. 15 blat(); // return 20, because a ner copy a is being used
在上述代码中,所返回的对bar函数的引用被赋给变量baz,这个函数现在是在foo外部被调用,但他依然能够访问a。这是因为JavaScript中的作用域是词法作用域。函数是运行在定义他们的作用域中(本例中是foo内部的作用域),而不是运行在调用它们的作用域中。只要bar被定义的foo中,他就能访问在foo中定义所有的变量,即使foo的执行已经结束。
这就是闭包的一个例子。在foo返回后,他的作用域被保存下来。但只有它返回的那个函数能够访问这个作用域。在前面的例子中,baz和blat各有这个作用域及a的一个副本,而且只有他们自己能对其进行修改。返回一个内嵌函数是闭包最常用的手段。
3.2.4 用闭包实现私用成员
现在回过来看手头的那个问题:你需要创建一个只能在对象内部访问的变量。闭包用于这个目的看来是再合适不过了,因为借助于闭包你可以创建只允许特定函数访问的变量,而且这些变量在这些函数的各次调用之间依然存在。为了创建私用属性,你需要在构造函数的作用域中定义相关变量。这些变量可以被定义于该作用域中的所有函数访问,包括那些特权方法:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 var Book = function (newIsbn, newTitle, newAuthor) { 2 // Private attributes. 3 function checkIsbn(isbn) { 4 //... 5 } 6 7 // Private method. 8 this.getIsbn = function () { 9 return isbn; 10 } 11 this.setIsbn = function(newIsbn) { 12 if(!checkIsbn(newIsbn)) throw new Error('Book: Invalid ISBN.'); 13 isbn = newIsbn; 14 }; 15 16 this.getTitle = function () { 17 return title; 18 } 19 this.setTitle = function(newTitle) { 20 title = newTitle || 'No title specified'; 21 }; 22 23 this.getAuthor = function () { 24 return author; 25 } 26 this.setAuthor = function(newAuthor) { 27 title = newAuthor || 'No author specified'; 28 }; 29 30 // public, non-privileged method 31 Book.prototype = { 32 display: function() { 33 // ... 34 } 35 } 36 }
那么这与我们先前讲过的其他创建对象的模式有什么不同呢?在其他使用Book的例子中,我们在创建和引用对象的属性时总要使用this关键字。而在本例中,我们用var声明这些变量。这意味着它们只存在于Book构造器中。checkIsbn函数也是用同样的方式声明的,因此成了一个私用方法。
需要访问这些变量和函数的方法只需声明在Book中即可。这些方法被称为特权方法(privileged method),因为它们是公用的方法,但却能够访问私用属性和方法。为了在对象外部能访问这些特权函数,它们的前面都被加上了关键字this。因为这些方法定义于Book构造器的作用域中,所以它们能访问到私用属性。引用这些属性时并没有使用关键字this,因为它们不是公开的。所有取值器和赋值器都改为不加this地直接引用这些属性。
任何不需要直接访问私用属性的方法都可以像原来那样在Book。prototype中声明。display就是这类方法中的一个。它不需要直接访问任何私用属性,因为它可以通过调用getIsbn或getTitle来进行间接访问。只有那些需要直接访问私用成员的方法才应该被设计为特权方法。但特权方法太多又会占用过多的内存,因为每个对象实例都包含了所有特权方法的新副本。
用这个方式创建的对象可以具有真正的私用属性。其他程序员不可能直接访问他们创建Book实例的任何内部数据。由于他们不得不通过赋值器方法设置属性的值,所有属性会得到什么样的值尽在你的掌控之下。
这种对象创建模式解决了其他模式中的所有问题,但它也有自己的一些弊端。在门户大开型对象创建模式中,所有方法都创建在原型对象中,因此不管生成多少对象实例。这些方法在内存中只存在一份。而采用本节讨论的做法,每生成一个新的对象实例都将为每一个私用方法和特权方法生成一个新的副本。这会比其他做法消耗更多内存,所以只易用在需要真正的私用成员的场合。这种对象创建模式也不利于派生子类,因为所派生出的子类不能访问超类的任何私用属性或方法。相比之下,大多数语言中,之类都能访问超类的所有私用属性和方法。故在JavaScript中用闭包实现私用成员导致的派生问题被称为继承破坏封装。如果你创建的类以后可能会需要派生出子类,那么最好还是采用所讨论的三种对象创建模式中的前两种。
3.3 更多高级对象创建模式
在前面你已经学到老创建对象的3中基本模式,本节将在讨论一些高级一点的模式。
3.3.1 静态方法和属性
前面所讲的作用域和闭包的概念可用于创建静态成员,包括公用的和私用的。大多数方法和属性所关联的是类的实例,而静态成员所关联的则是类本身。换句话说,静态成员是在类的层次上操作,而不是在实例的层次上 操作。每个静态成员都只有一份。稍后会看到,静态成员是直接通过类对象访问的。下面是添加了静态属性和方法的Book类:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 var Book = (function() { 2 // 私有静态属性 3 var numOfBooks = 0; 4 5 // 私有静态方法 6 function checkIsbn(isbn){ 7 // ... 8 } 9 10 // return the constructor 11 return function(newIsbn, newTitle, newAuthor) { //implements publication 12 // Private attributes 13 var isbn, title, author; 14 15 // Private methods 16 this.getIsbn = function () { 17 9 return isbn; 18 10 } 19 11 this.setIsbn = function(newIsbn) { 20 12 if(!checkIsbn(newIsbn)) throw new Error('Book: Invalid ISBN.'); 21 13 isbn = newIsbn; 22 14 }; 23 15 24 16 this.getTitle = function () { 25 17 return title; 26 18 } 27 19 this.setTitle = function(newTitle) { 28 20 title = newTitle || 'No title specified'; 29 21 }; 30 22 31 23 this.getAuthor = function () { 32 24 return author; 33 25 } 34 26 this.setAuthor = function(newAuthor) { 35 27 author = newAuthor || 'No author specified'; 36 28 }; 37 38 // Constructor code 39 numOfBooks++; // keep track of how many Book have been instantiated with the private static attribute 40 if(numOfBooks > 50) throw new Error('Book: Only 50 instances of Book can be' + numOfBooks +'created') 41 42 this.setIsbn(newIsbn); 43 this.setTitle(newTitle); 44 this.setAuthor(newAuthor); 45 } 46 })(); 47 48 // public static method 49 Book.convertToTitleCase = function(inputSting) { 50 // ... 51 } 52 53 // public, non-privileged methods 54 Book.prototype = { 55 display: function() { 56 //... 57 } 58 }
这与3.2.4节创建的类大体相似,但也有一些重要区别。这里的私有成员和特权成员仍然被声明早构造器中(分别使用var和this关键字)。但那个构造器却从原来的普通函数变成了一个内嵌函数,并且被作为包含他的函数的返回值赋给变量Book。这就创建了一个闭包,你可以把静态的私用成员声明在里面。位于外层函数声明之后的一对空括号很重要,其作用是代码一载入就立即执行这个函数(而不是在调用Book构造函数时)。这个函数的返回值使另一个函数,他被赋予给Book变量,Book因此成了一个构造函数。在实例化Book时,所调用的是这个内层函数。外层那个函数只是用于创建一个可以用来存放静态私用成员的闭包。
在本例中,checkIsbn被设计为静态方法,原因是为Book的每个实例都生成这个方法的一个新副本毫无道理。此外还有一个静态属性numOfBooks,其作用在于跟踪Book构造器的总调用次数。本例利用这个属性将Book实例的个数限制为不超过50个。
这些私用的静态成员可以从构造器内部访问,这意味着所有私用函数和特权函数都能访问它们。与其其他方法相比,它们有一个明显的优点,那就是在内存中只会存放一份。因为其中那些静态方法被声明在构造器之外,所以他们不是特权函数,不能访问任何定义在构造器中的私用属性。定义在构造器中的私用方法能够调用那些私用静态方法,反之则不然。要判断一个私用方法是否应该被设计为静态方法,一条经验法则是看它是否需要访问任何实例数据。如果他不需要,那么将其设计为静态方法会更有效率(从内存占用的意义上来说),因为他只会被创建一份。
创建公用的静态成员则容易得多,只需直接将其作为构造函数这个对象的属性创建即可,前述代码中的方法convertToTitleCase就是一例。这实际上相当于把构造器作为命名空间来使用。
注解:在JavaScript中,除那三种原始类型外,所有其他类型的变量都是对象(即使是三种原始类型,在必要的时候也会被自动包装为对象)。这意味着函数也是对象,因为对象在本质上是一些散列表,所以任何时候都可以为期添加成员。其结果就是函数也可以向其他对象一样具有属性和方法,而且这些属性和方法随时可以添加。
所有公用静态方法如果作为独立的函数来声明其实也同样简单,但最好还是像这样把相关行为集中在一起。这些方法用于与类这个整体相关的任务,而不是与类的任一特定实例相关的任务。它们并不直接依赖对象实例中包含的任何数据。
3.3.2 常量
常量只不过是一些不能被修改的变量。在JavaScript中,可以通过创建只有取值器而没有赋值器的私用变量来模仿常量。因为常量往往是在开发时进行设置,而且不因对象实例的不同而变化,所以将其作为私用静态属性来设计是合乎情理的。假设Class对象有一个名为UPPER_BOUND的常量,那么为获取这个常量而进行的方法调用如下所示:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 var Class = (function() { 2 // Constants (created as private static attributes) 3 var UPPER_BOUND = 100; 4 // construct 5 var ctor = function (constructorArgument) { 6 //... 7 } 8 9 //privileged static method 10 ctor.getUOOER_BOUND = function() { 11 return UPPER_BOUND; 12 } 13 14 return ctor; 15 })()
如果需要使用许多常量,但你不想为每个常量都创建一个取值器方法,那么可以创建一个通用的取值器方法:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 var Class = (function() { 2 // Constants (created as private static attributes) 3 var constants = { 4 UPPER_BOUND: 100, 5 LOWPER_BOUND: -100 6 }; 7 // construct 8 var ctor = function (constructorArgument) { 9 //... 10 } 11 12 //privileged static method 13 ctor.getUOOER_BOUND = function(name) { 14 return constants[name]; 15 } 16 17 return ctor; 18 })()
然后可以这样使用这个取值器以获得一个常量:
Class.getConstant('UPPER_BOUND');
3.3.3 单体和对象工厂
其他还有一些模式也使用闭包来创建受保护的变量空间。在这方面最突出的两个是 单体模式和工厂模式。
单体模式使用一个由外层函数返回的对象字面量来公开特权成员,而私用成员则被保护性地封装在外层函数的作用域中。他使用的技术与我们前面讲的一样:外层函数在定义之后立即执行,其结果被赋给一个变量。在本章前面的例子中外层函数返回的都是一个函数,而单体模式中外层函数返回的则是一个对象字面量。这种创建受保护的命名空间的方法非常简便和直观。
对象工厂也可以使用闭包来创建具有私用成员的对象。其最简形式就是一个类构造器,本章讨论的所有技术都可以用在上面。
3.4 封装之利
要是创建对象时不用操心闭包和特权方法这些东西,事情会简单得多。在一个理想的世界里,所有方法都可以是公开的,而其他程序员会使用接口中规定的那些方法。那么,不厌其烦地隐藏实现细节究竟能为你带来什么好处呢?
封装保护了内部数据的完整性。通过将数据的访问途径限制为取值器和赋值器这两个方法,可以获得对取值和赋值的完全控制。这可以减少其他函数所需的错误检查代码的数量,并确保数据不会处于失效状态。另外,对象的重构因此可以变得更轻松。因为用户不知道对象的内部细节,所以你可以随心所欲地修改对象内部使用的数据结构和算法,对此外人不会知道,它们也不必知道。
通过只公开那些在接口中规定的方法,可以弱化模块间的耦合。这是面向对象设计最重要的原则之一。尽可能地提高对象的独立性可以带来很多好处。他提高了对象的可重用性,使其在必要的时候可以被替换。使用私用变量也有助于避免命名空间冲突。如果一个变量在代码中其他地方都不能被访问,你就不用老是担心它是否与程序中其他地方的对象或函数重名并因此造成问题。封装还使你可以大幅改动对象的内部细节,而不会影响到其他部分的代码。总的来说,代码的修改变得更轻松,因为你对它所带来的影响已经了如指掌。如果对象的内部数据都是公开的话,你不可能完全清楚代码的修改会带来什么结果。
3.5 封装之弊
私用方法很难进行单元测试。因为他们及其内部变量都是私用的,所有在对象外部无法访问到它们。这个问题没有什么很好的应对之策。你要么通过使用公用的方法来提供访问途径(这样一来就葬送了使用私用方法所带来的大多数好处),要么设法在对象内部定义并执行所有单元测试。最好的解决办法是只对公用方法进行单元测试。这应该能覆盖到所有私用的方法,尽管对它们的测试只是间接的。这种问题不是JavaScript所独有的,只对公用方法进行单元测试是一种广为接受的处理方法。
使用封装意味着不得不与复杂的作用域链打交道,而这会使错误调试更加困难。一般说来着不算什么大问题,但有时候你会很难区分来自不同领域的大批同名变量。这个问题不是经过封装的对象所特有的,但实现私用方法和属性所需的闭包会让他变得更加复杂。
过度封装也是一个潜在的问题。如果你对其他程序员对你的类的需求了解得并不透彻,那么防止他们修改的内部细节的热衷可能会过于严厉。预测人们会怎样使用你的代码并不容易。封装可能会损害类的灵活性,致使其无法被用于某些你未曾想到过的目的。
最大的问题在于JavaScript中实现封装的困难。其实现过程需要用到的一些复杂对象模式对与大多数编程新手而言太不直观。JavaScript本来就是一门与多数面向对象语言大相径庭的语言,而封装技术涉及的调用链和定义后立即执行的匿名函数等概念更是加大了学习难度。此外,封装技术的应用还使不熟悉特定模式的人难以理解既有代码。注释和程序文档可以提供一些帮助,但并不能完全理解这个问题。如果你想使用这个模式,那么应该确保与你合作的其他程序员也能理解它们。
3.6 小结
本章讨论了信息隐藏的概念以及如何使用封装这种手段来实现它。因为JavaScript没有对封装提供内置的支持,所以其实现必须依赖于一些其他技术。如果够确信其他程序员只会使用接口中规定的方法,或者并非迫切需要保持内部数据的完整性,那就可以使用门户大开型对象。命名规范可以用来引导其他程序员,使其明白哪些方法是不宜直接访问的内部方法。如果需要真正的私用成员,那就只能使用闭包。通过创建一个受保护的变量空间,可以实现公用、私用和特权成员,以及静态类成员和常量。后面的多数章节都依赖这些基本技术,因此他们值得反复玩味一下。只要理解了JavaScript中作用域的特点,你就能模仿出各种面向对象的技术。