为对象创建私用成员是任何面向对象语言中最基本和有用的特性之一。通过将一个方法或属性声明为私用的,可以让对象的实现细节对其他对象保密以降低对象之间的耦合程度,可以保持数据的完整性并对其修改方式加以约束。在代码有许多人参与设计的情况下,这也可以使代码更可靠、更易于调试。简而言之,封装是面向对象的设计的基石。
尽管JavaScript是一种面向对象的语言,它并不具备用以将成员声明为公用或私用的任何内置机制。目前有几种办法可以用来创建具有公用、私用和特权方法的对象,它们各有优缺点。
可以把封装和信息隐藏视为同一个概念的两种表述。信息隐藏是目的,封装是达到这个目的的技术。
封装可以被定义为对对象的内部数据表现形式和实现细节进行隐藏。要想访问封装过的对象中的数据,只有使用已定义的操作这一种办法。通过封装可以强制实施信息隐藏。许多面向对象语言都使用关键字来说明某些方法和属性应被隐藏。但在JavaScript中没有这样的关键字,我们将使用闭包的概念来创建只允许从对象内部访问的方法和属性。这比使用关键字的办法更复杂。
在向其他对象隐藏信息的过程中接口是如何发挥作用的呢?
接口提供了一份记载着可供公众访问的方法的契约。它定义了两个对象间可以具有的关系。只要接口不变,这个关系的双方都是可替换的。大多数情况下,你将发现对可以使用的方法加以记载会很有好处。不是有了接口就万事大吉,你应该避免公开未定义于接口中的方法。否则其他对象可能会对那些并不属于接口的方法产生依赖,这是不安全的。因为这些方法随时都可能发生改变或被删除,从而导致整个系统失灵。
一个理想的软件系统应该为所有类定义接口。这些类只向外界提供它们实现的接口中规定的方法,任何别的方法都留作自用。其所有属性都是私有的,外界只能通过接口中定义的存取操作与之打交道。但实际的系统很少能真正达到这样的境界。优质的代码应尽量向这个目标靠拢,但又不能过于刻板,把那些并不需要这些特性的简单项目复杂化。
JavaScript创建对象的基本模式有3种
以Book为例,该类满足这样的需求:存储关于一本书的数据,并实现一个以HTML形式显示这些数据的方法。
你只负责创建这个Book类,别人会创建并使用其实例。它会被这样使用:
// Book (isbn, title, author)
var theHobbit = new Book('0-395-07122-4', 'The Hobbit', 'J. R. R. Tolkien');
theHobbit.display();//通过创建HTML element显示数据
用一个函数来做其构造器,它的所有属性和方法都是公开的、可访问的。这些公用属性需要使用this关键字来创建。
var Book = function(isbn, title, author) {
if(isbn == undefined) throw new Error('Book constructor requires an isbn.');
this.isbn = isbn;
this.title = title || 'No title specified';
this.author = author || 'No author specified';
};
Book.prototype.display = function () {
...
}
好像提供了ISBN就可以查到书籍了,可是这里有一个最大的问题,你无法检验ISBN数据的完整性,而不完整的ISBN数据有可能导致display方法失灵。如果Book对象在创建的时候没有什么问题,那么在display时也能正常工作才对,但是由于没有进行完整性检查,就不一定了。下面的版本强化了对ISBN的检查。
var Book = function(isbn, title, author) {
if(!this.checkIsbn(isbn)) throw new Error('Book: Invalid ISBN.');
this.isbn = isbn;
this.title = title || 'No title specified';
this.author = author || 'No author specified';
}
Book.prototype = {
checkIsbn: function(isbn) {
if(isbn == undefined || typeof isbn != 'string'){
return false;
}
isbn = isbn.replace(/-/,'');
if(isbn.length != 10 && isbn.length !=13) {
return false;
}
var sum = 0;
if(isbn.length === 10) {
//10位的ISBN
if(!isbn.match(/^\d{9}/)) {
return false;
}
for(var i = 0; i < 9; i++) {
sum += isbn.charAt(i) * (10-i);
}
var checksum = sum % 11;
if(checksum === 10) checksum = 'X';
if(isbn.charAt(9) != checksum) {
return false;
}
}
else {
//13位的ISBN
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是一个具有正确的位数和校验和的字符串。这样在创建对象的时候可以对ISBN的有效性进行检查,这可以确保display方法能正常工作。
但问题又来了,即使在构造的时候能对ISBN进行检验,如果后续其他程序员把其他值赋给isbn,这时就检验不了了,
theHobbit.isbn = '978-0261103283';
theHobbit.display();
为了保护内部数据,为每一个属性都提供了取值器和赋值器方法。
通过使用赋值器,你可以在把一个新值真正赋给属性之前进行各种检验。
var Publication = new Interface('Publication', ['getIsbn', 'setIsbn', 'getTitle', 'setTitle', 'getAuthor', 'setAuthor', 'display']);
var Book = function(isbn, title, author) {
this.setIsbn(isbn);
this.setTitle(title);
this.setAuthor(author);
}
Book.prototype = {
checkIsbn: function(isbn) {
...
},
getIsbn: function() {
return this.isbn;
},
setIsbn: function(isbn) {
if(!this.checkIsbn()) throw new Error('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;
},
setAuthor: function(author) {
this.author = author || 'No author specified.';
},
display: function() {
...
}
};
这是使用门户大开型对象创建方式所能得到的最好结果。
这里明确定义了接口、一些对数据具有保护作用的取值器和赋值器方法,以及一些有效性检验方法。
这里还是有一个漏洞,虽然我们为设置属性提供了赋值器方法,但那些属性仍然是公开的,可以被直接设置的,而在这种方案中却无法阻止这种行为。
不过这种方法易于使用,创建这样的对象不要求你深入理解作用域或调用链的概念。由于所有方法和属性都是公开的,派生子类和进行单元测试也很容易。唯一的弊端在于无法保护内部数据,而且取值器和赋值器也引入了额外的代码。
从本质上来说,这种模式与门户大开型对象创建模式如出一辙,只不过在一些方法和属性的名称前加了下划线以示其私用性而已。这种方法可以解决上一种方法带来的问题:无法阻止其他程序员无意中绕过的所有检验步骤。
var Book = function(isbn, title, author) {
this.setIsbn(isbn);
this.setTitle(title);
this.setAuthor(author);
}
Book.prototype = {
checkIsbn: function(isbn) {
...
},
getIsbn: function() {
return this._isbn;
},
setIsbn: function(isbn) {
if(!this.checkIsbn()) throw new Error('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;
},
setAuthor: function(author) {
this._author = author || 'No author specified.';
},
display: function() {
...
}
};
这种命名规范也可以应用于方法,例如checkIson方法应该是类私有的方法:
_checkIsbn: function(isbn) {
...
},
下划线的这种用法是一个众所周知的命名规范,它表明一个属性或方法仅供对象内部使用,直接访问它或设置它可能会导致意想不到的后果。这有助于防止程序员对它的无意使用,却不能防止对它的有意使用。
这并不是真正可以用来隐藏对象内部数据的解决方法,它主要适用于非敏感性的内部方法和属性。
在讨论真正的私用性方法和属性的实现技术之前,我们先花点时间解释一下这种技术背后的原理。
在JavaScript中,只有函数具有作用域。也就是说,在一个函数内部声明的变量在函数外部无法访问。私用属性就其本质而言就是希望在对象外部无法访问的变量,所以为实现这种拒访性而求助于作用域这个概念是合乎情理的。
定义在一个函数中的变量在该函数的内嵌函数中是可以访问的:
function foo() {
var a = 10;
function bar() {
a *= 2;
}
bar();
return a;
}
foo();//20
一个简单的闭包例子:
function foo() {
var a = 10;
function bar() {
a *= 2;
return a;
}
return bar;
}
var baz = foo();
baz(); // 20
baz(); // 40
baz(); // 80
可以看到,函数是在foo外部被调用,但它依然能够访问a,这是因为JavaScript中的作用域是词法性的。函数是运行在定义它们的作用域中,而不是运行在调用它们的作用域中。所以,bar被定义在foo的作用域中,就能访问foo中定义的变量,即使foo的执行已经结束。
var Book = function (newIsbn, newTitle, newAuthor) {
// Private attributes.
var isbn, title, author;
// Private method.
function checkIsbn(isbn) {
...
}
// Privileged methods.
this.getIsbn = function () {
return isbn;
};
this.setIsbn = function (newIsbn) {
if(!checkIsbn(newIsbn)) throw new Error('Book: Invalid ISBN.');
isbn = newIsbn;
};
this.getTitle = function () {
return title;
};
this.setTitle = function (newTitle) {
title = newTitle || 'No title specified';
};
this.getAuthor = function () {
return author;
};
this.setAuthor = function (newAuthor) {
author = newAuthor || 'No author specified';
};
// Constructor code.
this.setIsbn(newIsbn);
this.setTitle(newTitle);
this.setAuthor(newAuthor);
};
// Public, non-privileged methods.
Book.prototype = {
display: function() {
...
}
}
代码解读:
前面学习了创建对象的3种基本模式,下面再对一些高级一点的模式做一个简介。
静态成员是在类的层次上操作的,而不是在实例的层次上操作。每个静态成员都只有一份。
下面是添加了静态属性和方法的Book类:
var Book = (function() {
// Private static attributes.
var numOfBooks = 0;
// Private static method.
function checkIsbn(isbn) {
...
}
// Return the constructor.
return function (newIsbn, newTitle, newAuthor) {
// Private attributes.
var isbn, title, author;
// Private method.
function checkIsbn(isbn) {
...
}
// Privileged methods.
this.getIsbn = function () {
return isbn;
};
this.setIsbn = function (newIsbn) {
if(!checkIsbn(newIsbn)) throw new Error('Book: Invalid ISBN.');
isbn = newIsbn;
};
this.getTitle = function () {
return title;
};
this.setTitle = function (newTitle) {
title = newTitle || 'No title specified';
};
this.getAuthor = function () {
return author;
};
this.setAuthor = function (newAuthor) {
author = newAuthor || 'No author specified';
};
// Constructor code.
numOfBooks++;
if(numOfBooks > 50) throw new Error('Book: Only 50 instances 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的每一个实例都创建一个新的副本。
如何判断一个私用方法是否应该被设计为静态方法?
看它是否需要访问任何实例数据。如果它不需要,则设计为静态方法会更有效率,因为它只会被创建一份。
常量是一些不能被修改的变量。在JavaScript中,可以通过创建只有取值器而没有赋值器的私有变量来模仿常量。
var Class = (function() {
// Constants (created as private static attributes.).
var UPPER_BOUND = 100;
// Constructor.
var ctor = function (constructorArgument) {
...
};
// Privileged static method.
ctor.getUPPER_BOUND = function() {
return UPPER_BOUND;
};
...
// Return the constructor
return ctor;
})();
如果需要使用许多常量,但你不想为每个常量都创建一个取值器方法,那么可以创建一个通用的取值器方法,如下:
var Class = (function() {
// Constants (created as private static attributes.).
var constants ={
UPPER_BOUND: 100,
LOWER_BOUND: -100
};
// Constructor.
var ctor = function (constructorArgument) {
...
};
// Privileged static method.
ctor.getConstant = function(name) {
return constants[name];
};
...
// Return the constructor
return ctor;
})();
Class.getConstant('UPPER_BOUND');
单体模式和工厂模式也使用闭包来创建受保护的变量空间,后面部分会详细讨论这两种模式。在此简单介绍一下:
要是在创建对象时不用操心闭包和特权方法,事情就会简单得多。那么,不厌其烦的隐藏实现细节究竟有什么好处?
封装保护了内部数据的完整性。通过将数据的访问途径设置为取值器和赋值器这两个方法,可以获得对取值和赋值的完全控制。这可以减少其他函数所需的错误检查代码的数量,并确保数据不会处于无效状态。
封装提高了对象的可重用性,使其在必要的时候可以被替换。使用私用变量也有助于避免命名空间冲突。
封装还使你可以大幅改动对象的内部细节,而不会影响到其他部分的代码。总的来说,代码的修改变得更轻松,如果对象的内部数据都是公开的话,你不可能完全清楚代码的修改会带来什么结果。
私用方法很难进行单元测试。因为他们及其内部变量都是私用的,在对象外部无法访问到它们。要么通过使用公用方法来提供访问途径(这样一来就葬送了使用私用方法所带来的大多数好处),要么设法在对象内部定义并执行所有测试单元。最好的解决办法是只对公用方法进行单元测试。这是一种广为接受的处理方式。
使用封装意味着不得不与复杂的作用域链打交道,而这会使错误调试变得更加困难。有时候会很难区分来自不同作用域的大批同名变量。这个问题不是经过封装的对象所特有的,但实现私用方法和属性所需的闭包会让它变得更复杂。
过度封装也是一个潜在的问题。
最大的问题在于JavaScript中实现封装的困难。JavaScript本来就是一门与多数面向对象语言大相径庭的语言,而封装技术设计的调用链和定义后立即执行的匿名函数等概念更是加大了学习难度。此外,封装技术的应用还使不熟悉特定模式的人难以理解既有代码。注释和程序文档可以提供一些帮助,但并不能完全解决这个问题。
本章讨论了信息隐藏的概念以及如何用封装这种手段来实现它。因为JavaScript没有对封装提供内置的支持,所以其实现必须依赖于一些其他技术。本书后面的多数章节都依赖于这些基本技术,因此你得好好品味一下本节的内容。只要理解了JavaScript中作用域的特点,你就能模仿出各种面向对象的技术。