作者:月影
清华大学出版社
第一部分 概论
第一章 从零开始
程序设计之道无远弗届,御晨风而返
——杰弗瑞.詹姆士
在人类漫漫的历史长河里,很难找到第二个由简单逻辑和抽象符号组合而成的,具有如此宏大信息量
和丰富多彩内涵的领域。从某种意义上说,当你翻开这本书的时候,你已经踏入了一个任由你制定规则的
未知世界。尽管你面对的仅仅是程序设计领域的冰山一角,但你将透过它,去领悟“道”的奥秘。在接下
来的一段时间内,你会同我一起,掌握一种简单而优雅的神秘语言,学会如何将你的意志作用于它。这种
语言中所蕴涵着的亘古之力,将为你开启通往神秘世界的大门……
1.1 为什么选择JavaScript?
在一些人眼里,程序设计是一件神秘而浪漫的艺术工作,对他们来说,一旦选定某种编程语言,就会
像一个忠贞的信徒一样坚持用它来完成任何事情,然而我不是浪漫的艺匠,大多数人也都不是,很多时候
我们学习一种新技术的唯一目的,只是为了把手中的事情做得更好。所以,当你面对一项陌生的技术时,
需要问的第一个问题往往是,我为什么选择它,它对我来说,真的如我所想的那么重要吗?
好,让我们带着问题开始。
1.1.1 用户的偏好:B/S 模式
如果你坚持站在专业人员的角度,你就很难理解为什么B/S 模式会那么受欢迎。如果你是一个资深的
程序员,有时候你甚至会对那些B/S 模式的东西有一点点反感。因为在你看来,浏览器、表单、DOM 和
其他一切与B/S 沾边的东西,大多是行为古怪而难以驾驭的。以你的经验,你会发现实现同样的交互,用
B/S 来做通常会比用任何一种客户端程序来做要困难得多。
如果你尝试站在用户的角度,你会发现为什么大多数最终用户对B/S 模式却是如此的青睐。至少你不
必去下载和安装一个额外的程序到你的电脑上,不必为反复执行安装程序而困扰,不必整天被新的升级补
丁打断工作,不必理会注册表、磁盘空间和一切对普通用户来说有点头疼的概念。如果你的工作地点不是
固定的办公室,你日常工作的PC 也不是固定的一台或者两台,那么,B/S 的意义对你而言或许比想象的
还要大。
大多数情况下,客户更偏好使用浏览器,而不是那些看起来比较专业的软件界面,专业人员则恰恰相
反。
总之,用户的需求让B/S 模式有了存在的理由,而迅速发展的互联网技术则加速了B/S 应用的普及。
随着一些优秀的Web 应用产品出现,不但唤起了用户和业内人士对Ajax 技术的关注,也令Web 领域内的
一个曾经被无数人忽视的脚本语言——JavaScript 进入了有远见的开发人员和IT 经理人的视线。于是在你
的手边,也多了现在这本教程。
编写本书的时候,在TIOBE 编程社区最新公布的数据中,JavaScript 在世界程序开发语言中排名第十,
这意味着JavaScript 已经正式成为一种被广泛应用的热门语言。
1.1.2 在什么情况下用JavaScript
一路发展到今天,JavaScript 的应用范围已经大大超出一般人的想象,但是,最初的JavaScript 是作为
嵌入浏览器的脚本语言而存在,而它所提供的那些用以表示Web 浏览器窗口及其内容的对象简单实用,功
能强大,使得Web 应用增色不少,以至于直到今天,在大多数人眼里,JavaScript 表现最出色的领域依然
是用户的浏览器,即我们所说的Web 应用的客户端。客户端浏览器的JavaScript 应用也正是本书讨论的重
点内容。
作为一名专业程序员,当你在面对客户的时候,经常需要判断哪些交互需求是适合于JavaScript 来实
现的。而作为一名程序爱好者或者是网页设计师,你也需要了解哪些能够带给人惊喜的特效是能够由
JavaScript 来实现的。总之一句话,除了掌握JavaScript 本身,我们需要学会的另一项重要技能是,在正确
的时候、正确的地方使用JavaScript。对于JavaScript 初学者来说学会判断正确使用的时机有时候甚至比学
会语言本身更加困难。
作为项目经理,我经常接受来自客户的抱怨。因此我很清楚我们的JavaScript 在带给客户好处的同时
制造了太多的麻烦,相当多的灾难是由被错误使用的JavaScript 引起的。一些代码本不应该出现在那个位
置,而另一些代码则根本就不应当出现。我曾经寻访过问题的根源,发现一个主要的原因由于JavaScript
的过于强大(在后面的小节中我们将会提到,另一个同样重要的原因是“脚本诱惑”),甚至超越了浏览
器的制约范围,于是麻烦就不可避免的产生了,这就像你将一个魔鬼放入一个根本就不可能关住它的盒子
里,那么你也就无法预料魔鬼会做出任何超出预期的举动。
毫无疑问,正确的做法是:不要放出魔鬼。所以,JavaScript 程序员需要学会的第一个技巧就是掌握
在什么情况下使用JavaScript 才是安全的。
在什么情况下用JavaScript?给出一个简单的答案是:在任何不得不用的场合使用,除此以外,不要
在任何场合使用!无懈可击的应用是不用,除非你确实无法找到一个更有效更安全的替代方案。也许这个
答案会让读到这里的读者有些郁闷,但是,我要很严肃地提醒各位,由于JavaScript 比大多数人想象的要
复杂和强大得多,所以它也比大多数人想象得要危险得多。在我的朋友圈子里,许多资深的JavaScript 程
序员(包括我在内)偶尔也不得不为自己一时疏忽而做出的错误决定让整个项目团队在“脚本泥潭”中挣
扎好一阵子。所以这个建议从某种意义上说也是专家们的血泪教训。最后向大家陈述一个令人欣慰的事实,
即使是像前面所说的这样,在Web 应用领域,JavaScript 的应用范围也仍然是相当广泛的。
?? 在本节的最后三个小节里,我们将进一步展开讨论关于 JavaScript 使用的话题。
1.1.3 对JavaScript 的一些误解
JavaScript 是一个相当容易误解和混淆的主题,因此在对它进一步研究之前,有必要澄清一些长期存
在的有关该语言的误解。
1.1.3.1 JavaScript 和Java
这是最容易引起误会的一个地方,这个Java-前缀似乎暗示了JavaScript 和Java 的关系,也就是
JavaScript 是Java 的一个子集。看上去这个名称就故意制造混乱,然后随之而来的是误解。事实上,这两
种语言是完全不相干的。
JavaScript 和Java 的语法很相似,就像Java 和C 的语法相似一样。但它不是Java 的子集就像Java 也
不是C 的子集一样。在应用上,Java 要远比原先设想的好得多(Java 原称Oak)。
JavaScript 的创造者是Brendan Eich,最早的版本在NetScape 2 中实现。在编写本书时,Brendan Eich
在Mozilla 公司任职,他本人也是JavaScript 的主要革新者。而更加有名的Java 语言,则是出自Sun
Microsystems 公司的杰作。
JavaScript 最初叫做LiveScript,这个名字本来并不是那样容易混淆,只是到最后才被改名为JavaScript,
据说起同Java 相似的名字纯粹是一种行销策略。
尽管JavaScript 和Java 完全不相干,但是事实上从某种程度上说它们是很好的搭档。JavaScript 可以控
制浏览器的行为和内容,但是却不能绘图和执行连接(这一点事实上并不是绝对的,通过模拟是可以做到
的)。而Java 虽然不能在总体上控制浏览器,但是却可以绘图、执行连接和多线程。客户端的JavaScript
可以和嵌入网页的Java applet 进行交互,并且能够对它执行控制,从这一意义上来说,JavaScript 真的可
以脚本化Java。
1.1.3.2 披着C 外衣的Lisp
JavaScript 的C 风格的语法,包括大括号和复杂的for 语句,让它看起来好像是一个普通的过程式语
言。这是一个误导,因为JavaScript 和函数式语言如Lisp 和Scheme 有更多的共同之处。它用数组代替了
列表,用对象代替了属性列表。函数是第一型的。而且有闭包。你不需要平衡那些括号就可以用λ 算子。
?? 关于 JavaScript 闭包和函数式的内容,在本书的第23 章中会有更详细的介绍。
1.1.3.3 思维定势
JavaScript 是原被设计在Netscape Navigator 中运行的。它的成功让它成为几乎所有浏览器的标准配置。
这导致了思维定势。认为JavaScript 是依赖于浏览器的脚本语言。其实,这也是一个误解。JavaScript 也适
合很多和Web 无关的应用程序。
早些年在学校的时候,我和我的实验室搭档曾经研究过将JavaScript 作为一种PDA 控制芯片的动态脚
本语言的可行性,而在我们查阅资料的过程中发现一些对基于嵌入式环境的动态脚本语言实现的尝试,我
们有理由相信,JavaScript 在某些特定的嵌入式应用领域中也能够表现得相当出色。
1.1.3.4 业余爱好者
一个很糟糕的认知是:JavaScript 过于简朴,以至于大部分写JavaScript 的人都不是专业程序员。他们
缺乏写好程序的修养。JavaScript 有如此丰富的表达能力,他们可以任意用它来写代码,以任何形式。
事实上,上面这个认知是曾经的现实,不断提升的Web 应用要求和Ajax 彻底改变了这个现实。通过
学习本书,你也会发现,掌握JavaScript 依然需要相当高的专业程序员技巧,而不是一件非常简单的事情。
不过这个曾经的现实却给JavaScript 带来了一个坏名声──它是专门为外行设计的,不适合专业的程序员。
这显然是另一个误解。
推广 JavaScript 最大的困难就在于消除专业程序员对它的偏见,在我的项目团队中许多有经验的J2EE
程序员却对JavaScript 停留在一知半解甚至茫然的境地,他/她们不愿意去学习和掌握JavaScript,认为这门
脚本语言是和浏览器打交道的美工们该干的活儿,不是正经程序员需要掌握的技能。这对于Web 应用开发
来说,无疑是一个相当不利的因素。
1.1.3.5 面向对象
JavaScript 是不是面向对象的?它拥有对象,可以包含数据和处理数据的方法。对象可以包含其它对
象。它没有类(在JavaScript 2.0 真正实现之前),但它却有构造器可以做类能做的事,包括扮演类变量和
方法的容器的角色。它没有基于类的继承,但它有基于原型的继承。两个建立对象系统的方法是通过继承
和通过聚合。JavaScript 两个都有,但它的动态性质让它可以在聚合上超越。
一些批评说JavaScript 不是真正面向对象的因为它不能提供信息的隐藏。也就是,对象不能有私有变
量和私有方法:所有的成员都是公共的。但随后有人证明了JavaScript 对象可以拥有私有变量和私有方法。
另外还有批评说JavaScript 不能提供继承,但随后有人证明了JavaScript 不仅能支持传统的继承还能应用其
它的代码复用模式。
说JavaScript 是一种基于对象的语言,是一种正确而略显保守的判断,而说JavaScript 不面向对象,
在我看来则是错误的认知。事实上有充足的理由证明JavaScript 是一种的面向对象的语言,只是与传统的
class-based OO(基于类的面向对象)相比,JavaScript 有它与众不同的地方,这种独特性我们称它为
prototype-based OO(基于原型的面向对象)。
?? 关于 JavaScript 面向对象的内容,在本书的第21 章中会有更详细的介绍。
1.1.3.6 其他误解
除了以上提到的几点之外,JavaScript 还有许多容易令人迷惑和误解的特性,这些特性使得JavaScript
成为世界上最被误解的编程语言。
?? 如果读者对这方面有兴趣,可以详细阅读下面这篇文章
http://javascript.crockford.com/javascript.html [Douglas Crockford]
1.1.4 警惕!脚本诱惑
前面我们提到过,许多专业程序员拒绝去了解如何正确使用JavaScript,另一些则是缺乏对JavaScript
足够的认知和应用经验。但是在B/S 应用中,相当多的情况下,要求开发人员不得不采用JavaScript。于
是,一个问题产生了,大量的JavaScript 代码拷贝出现在页面的这个或者那个地方,其中的大部分是不必
要的,另一部分可能有缺陷。我们的开人员没有办法(也没有意识到)去判断这些代码是否必要,以及使
用它们会带来哪些问题。
如果你的B/S 应用中的JavaScript 不是由专业的JavaScript 程序员来维护的,那么当你对你的开发团队
进行一次小小的代码走查时,你甚至可能会发现90%的JavaScript 代码被错误地使用,这些错误使用的代
码浪费了用户大量的网络带宽、内存和CPU 资源,提升了对客户端配置的要求,降低了系统的稳定性,
甚至导致许多本来可以避免的安全问题。
由于浏览器的JavaScript 可以方便地被复制粘贴,因此,一个特效或者交互方式往往在真正评估它的
必要性之前便被采用——客户想要它,有人使用过它,程序员复制它,而它就出现在那儿,表面上看起来
很完美,于是,所谓的脚本诱惑就产生了。
事实上,在我们真正使用JavaScript 之前,需要反复问自己一个重要问题是,究竟是因为有人想要它,
还是因为真正有人需要它。在你驾驭JavaScript 马车之前,你必须学会抵制脚本诱惑,把你的脚本用在必
要的地方,永远保持你的Web 界面简洁,风格一致。
在用户眼里,简洁一致的风格与提供强大而不常用的功能和看起来很COOL 而实际上没有什么功用的
界面特效相比起来,前者更能令他们觉得专业。毕竟,大部分用户和你我一样,掌握一个陌生的环境和新
的技能只是为了能够将事情做得更快更好。除非你要提供的是一个类似于Qzone 之类的娱乐程序,你永远
也不要大量地使用不必要的JavaScript。
1.1.5 隐藏在简单表象下的复杂度
专业人员不重视JavaScript 的一个重要原因是,他们觉得JavaScript 是如此的简单,以至于不愿意花精
力去学习(或者认为不用学习就能掌握)。前面提到过的,这实际上是一种误解。事实上,在脚本语言中,
JavaScript 属于相当复杂的一门语言,它的复杂程度未必逊色于Perl 和Python。
另一个业内的偏见是脚本语言都是比较简单的,实际上,一门语言是否脚本语言往往是它的设计目标
决定的,简单与复杂并不是区分脚本语言和非脚本语言的标准。JavaScript 即使放到非脚本语言中来衡量,
也是一门相当复杂的语言。
之所以很多人觉得JavaScript 过于简单,是因为他们大量使用的是一些JavaScript 中看似简单的文法,
解决的是一些看似简单的问题,真正复杂而又适合JavaScript 的领域却很少有人选择JavaScript,真正强大
的用法很少被涉及。JavaScript 复杂的本质被一个个简单应用的表象所隐藏。
我曾经给一些坚持认为JavaScript 过于简单的开发人员写过一段小代码,结果令他们中的大部分内行
人大惊失色,那段代码看起来大致像下面这个样子:
var a = [-1,-1,1,-3,-3,-3,2,2,-2,-2,3,-1,-1];
function f(s, e)
{
var ret = [];
for(var i in s){
ret.push(e(s[i]));
}
return ret;
}
var b = f(a, function(n){return n>0?n:0});
alert(b);
这是本书中出现的第一段JavaScript 代码,也许现在你看来,它有那么一点点令人迷惑,但是不要紧,
在本书后面的章节中,你会慢慢理解这段代码的含义以及它的无穷妙味。而现在你完全可以跳过它的实际
内容,只要需要知道这是一段外表看起来简单的魔法代码就够了。
因为这段代码而尖叫的不仅仅包括我的这些程序员朋友,事实上,更兴奋的是另一些电子领域的朋友,
他们写信给我反馈说,在此之前他们从来没有见到过如此形式简洁而优雅的数字高通滤波器,更令人欣喜
的是,它的阈值甚至是可调节的:
var b = f(a, function(n){return n>=-1?n:0});
如果你想要,它也很容易支持低通滤波:
var b = f(a, function(n){return n<0?n:0});
用一个小小的堆栈或者其他伎俩,你也可以构造出一族差分或者其他更为复杂的数字设备,而它们明
显形式相近并且结构优雅。
总之,不要被简单的表象所迷惑,JavaScript 的复杂度往往很大程度上取决于你的设计思路和你的使
用技巧。JavaScript 的确是一门可以被复杂使用的程序设计语言。
1.1.6 令人迷惑的选择:锦上添花还是雪中送炭
本节最后的这个话题在前面已经被隐讳地提到过多次,实际上,本小节围绕的话题依然是什么时候使
用JavaScript。一种比较极端的观点是在必须的时候采用,也就是前面所说的不得不用的场合,另一种比
较温和一点的观点则坚持在需要的时候使用,这种观点认为当我们可以依靠JavaScript 令事情变得更好的
时候,我们就采用它。
事实上,就我个人而言,比较支持“必须论”,这是因为从我以往的经验来看,JavaScript 是难以驾
驭的,太多的问题由使用JavaScript 不当而产生,其中的一部分相当令人困扰,彻底解决它们的办法就是
尽可能降低JavaScript 的使用频率,也尽可能将它用在真正适合它的地方。当然万事没有绝对,在何时使
用JavaScript 永远是一个难题,然而不管怎么说同“锦上添花”相比,JavaScript 程序员也许应当更多考虑
的是如何“雪中送炭”。
1.1.7 回到问题上来
本节要解决的问题是为什么选择JavaScript,然而在相当多的篇幅里,我们都在试图寻找一些少用和
不用JavaScript 的理由,尽管如此,抛开大部分不适合JavaScript 的位置和时机,浏览器上依然会经常地见
到JavaScript 的身影,对于浏览器来说,JavaScript 实在是一个不可缺少的修饰。
你再也找不到任何一种优雅简朴的脚本语言如此适合于在浏览器中生存。在本书的第2 章,我们将具
体接触嵌入浏览器中的JavaScript。
最后,用一句话小结本节的内容——我们之所以选择JavaScript,是因为:Web 应用需要JavaScript,
我们的浏览器、我们的程序员和我们的用户离不开它。
1.2 JavaScript 的应用范围
我记得在前面依稀提到过,JavaScript 的应用范围相当广泛,除了最常见的客户端浏览器之外,
JavaScript 还被应用在一部分服务器端的环境、桌面程序和其他一些应用环境中。
1.2.1 客户端的JavaScript
目前绝大多数浏览器中都嵌入了某个版本的 JavaScript 解释器。当JavaScript 被嵌入客户端浏览器后,
就形成了客户端的JavaScript。这是迄今为止最常见也最普通的JavaScript 变体。大多数人提到JavaScript
时,通常指的是客户端的JavaScript,本书重点介绍的内容,也是JavaScript 的客户端应用。
?? 在后面的章节中提到的“浏览器中的 JavaScript”通常也是特指客户端的JavaScript。
当一个 Web 浏览器嵌入了JavaScript 解释器时,它就允许可执行的内容以JavaScript 的形式在用户客
户端浏览器中运行。下面的例子展示了一个简单的嵌入网页中的JavaScript 程序。
例1.1 经典程序Hello World! 的JavaScript 实现
第一个例子展示了document.write 是浏览器提供的一个方法,用来向document 文档对象输出内容,
至于什么是文档对象,在本书的第三部分将有详细的介绍。
把这个脚本装载进一个启用JavaScript 的浏览器后,就会产生如图1.1 所示的输出。
图1.1 Hello World
如果你看到的是“您的浏览器不支持JavaScript……”的字样,那么需要检查浏览器的版本和安全设
置,以确定你的浏览器正确支持JavaScript。
小技巧:是一种防御性编码,如果用户的浏览器不支持JavaScript 或者设置了
过高的安全级别,那么就会显示出相应的提示信息,避免了在用户不知情的情况下停止运行或者得到错误
结果。
从例子中可以看到,标记是用来在HTML 中嵌入JavaScript 代码的。
?? 我们将在第 2 章和第22 章中了解更多有关
document.getElementsByTagName 是我们接触到的domcument 文档对象模型的第二个接口,
它的作用通过它的名字很容易理解:它解析文档获取具有指定标记名称的一个列表,在这里
document.getElementsByTagName("h1")[0]得到文档中的第一个<h1> 标记。
与例1.1 比较,看起来略显繁琐的例1.3 是一种更加安全的方式,在这种方式中,指令不会对装载期
的文档内容产生影响,脚本指令被注册到body 的onload 事件中执行,这样确保了在执行前所有的文档元
素都已经正确初始化完毕。
假如出现某种意外导致程序终止,例1.1 可能因此而导致文档数据不能加载完全,而例1.3 则不会有
这样的风险。
例1.3 中一个值得关注的特性是onload 事件的注册: 。这是到目前为止我
们遇到的第一段事件注册代码,它将函数PageLoad()注册到body 的onload 事件上,在后续的章节里,我
们会了解到,元素的onload 事件将在元素被完全加载后由浏览器发起。除了onload 之外,DOM 元素还有
onclick,onkeydown,onchange,onblur 等各种不同类型的事件,这些事件共同构成了完整的客户端浏览器事件
模型。在第13 章中会就事件和事件模型展开详细讨论。
一个比较好的习惯是把除声明之外的所有的脚本指令都放到运行阶段来执行,这样避免了因为初始化
期间的DOM 元素加载失败或者低级的次序问题而导致脚本失效。
例1.4 忽略了次序的失误
执行结果如图 21.4 所示:
图 21.4 带默认值的Point 对象
上面的例子通过prototype 为Point 对象设定了默认值(0,0),因此p1 的值为(0,0),p2 的值为(1,2),通
过delete p2.x, delete p2.y; 可以将p2 的值恢复为(0,0)。下面是一个更有意思的例子:
例21.7 delete 操作将对象属性恢复为默认值
执行结果如图21.5 所示:
图21.5 delete 操作将对象属性恢复为默认值
利用prototype 还可以为对象的属性设置一个只读的getter,从而避免它被改写。下面是一个例子:
例21.8 prototype 巧设getter
function Point(x, y)
{
if(x) this.x = x;
if(y) this.y = y;
}
Point.prototype.x = 0;
Point.prototype.y = 0;
function LineSegment(p1, p2)
{
//私有成员
var m_firstPoint = p1;
var m_lastPoint = p2;
var m_width = {
valueOf : function(){return Math.abs(p1.x - p2.x)},
toString : function(){return Math.abs(p1.x - p2.x)}
}
var m_height = {
valueOf : function(){return Math.abs(p1.y - p2.y)},
toString : function(){return Math.abs(p1.y - p2.y)}
}
//getter
this.getFirstPoint = function()
{
return m_firstPoint;
}
this.getLastPoint = function()
{
return m_lastPoint;
}
//公有属性
this.length = {
valueOf : function(){return Math.sqrt(m_width*m_width + m_height*m_height)},
toString : function(){return Math.sqrt(m_width*m_width + m_height*m_height)}
}
}
//构造p1、p2 两个Point 对象
var p1 = new Point;
var p2 = new Point(2,3);
//用p1、p2 构造line1 一个LineSegment 对象
var line1 = new LineSegment(p1, p2);
//取得line1 的第一个端点(即p1)
var lp = line1.getFirstPoint();
//不小心改写了lp 的值,破坏了lp 的原始值而且不可恢复
//因为此时p1 的x 属性发生了变化
lp.x = 100;
alert(line1.getFirstPoint().x);
alert(line1.length); //就连line1.lenght 都发生了改变
将this.getFirstPoint()改写为下面这个样子:
this.getFirstPoint = function()
{
function GETTER(){}; //定义一个临时类型
//将m_firstPoint 设为这个类型的原型
GETTER.prototype = m_firstPoint;
//构造一个这个类型的对象返回
return new GETTER();
}
则可以避免这个问题,保证了m_firstPoint 属性的只读性。
实际上,将一个对象设置为一个类型的原型,相当于通过实例化这个类型,为对象建立只读副本,在
任何时候对副本进行改变,都不会影响到原始对象,而对原始对象进行改变,则会影响到每一个副本,除
非被改变的属性已经被副本自己的同名属性覆盖。用delete 操作将对象自己的同名属性删除,则可以恢复
原型属性的可见性。下面再举一个例子:
例21.9 delete 操作恢复原型属性的可见性
执行结果如图21.6 所示:
图21.6 delete 操作恢复原型属性的可见性
注意,上面的两个例子还说明了用prototype 可以快速创建对象的一个或多个副本,一般情况下,利用
prototype 来创建大量的复杂对象的副本,要比用其他任何方法来copy 对象快得多。注意到,以一个对象
为原型,来创建大量的新对象,这正是prototype pattern 的本质。下面是一个例子:
例21.10 构建大量副本
var p1 = new Point(1,2);
var points = [];
var PointPrototype = function(){};
PointPrototype.prototype = p1;
for(var i = 0; i < 10000; i++)
{
points[i] = new PointPrototype();
//由于PointPrototype 的构造函数是空函数,因此它的构造要比直接构造//p1 副本快得多。
}
除了以上作用,prototype 更常见的用处是声明对象的方法。因为,在一般情况下,和属性相比,对象
的方法不会轻易改变,正好利用prototype 的静态特性来声明方法,这样避免了在构造函数中每次对方法进
行重新赋值,节省了时间和空间。例如:
例21.11 定义静态方法
function Point(x, y)
{
this.x = x;
this.y = y;
}
Point.prototype.distance = function(){
return Math.sqrt(this.x * this.x + this.y * this.y);
}
上面的例子中,也可以用this.distance = function(){}的形式来定义Point 对象的distance()方法,但是用
prototype 避免了每次调用构造函数时对this.distance 的赋值操作和函数构造,如果程序里构造对象的次数
很多的话,时间和空间的节省是非常明显的。
小技巧:尽量采用prototype 定义对象方法,除非该方法要访问对象的私有成员或者返回某些引用了构
造函数上下文的闭包。
习惯上,我们把采用prototype 定义的属性和方法称为静态属性和静态方法,或者原型属性和原型方法,
把用this 定义的属性和方法称为公有属性和公有方法。
尽管采用prototype 和采用this 定义的属性和方法在对象调用的形式上是一致的,以至于在一段代码中
甚至很难严格区分,但是用“静态”两个字还是很好地诠释了prototype 在数据存储上的特质,即所有的实
例共享唯一的副本。这一点和C++中的static 成员非常相似,但是和C#不同,C#中的static 方法的调用形
式是通过类型的“.”运算符,这相当于JavaScript 中的类属性和类方法。
?? 关于“公有属性”、“私有属性”、“静态属性”和“类属性”的话题,在后续的章节中还会有
更为详细的介绍。
除了上面所说的这些使用技巧之外,prototype 因为它独特的特性,还有其它一些用途,被用作最广泛
和最广为人知的可能是用它来模拟继承,关于这一点,留待下一节中去讨论。
21.2.3 prototype 的实质
上面已经说了 prototype 的作用,现在我们来透过规律揭示prototype 的实质。
我们说,prototype 的行为类似于C++中的静态域,将一个属性添加为prototype 的属性,这个属性将
被该类型创建的所有实例所共享,但是这种共享是只读的。在任何一个实例中只能够用自己的同名属性覆
盖这个属性,而不能够改变它。换句话说,对象在读取某个属性时,总是先检查自身域的属性表,如果有
这个属性,则会返回这个属性,否则就去读取prototype 域,返回protoype 域上的属性。另外,JavaScript
允许protoype 域引用任何类型的对象,因此,如果对protoype 域的读取依然没有找到这个属性,则JavaScript
将递归地查找prototype 域所指向对象的prototype 域,直到这个对象的prototype 域为它本身或者出现循环
为止。
而下面的这个代码揭示了对象属性查找的 prototype 规律:
例21.12 对象属性查找的prototype 规律
执行结果如图21.7 所示:
图21.7 对象属性查找的prototype 规律
21.2.4 prototype 的价值与局限性
从上面的分析我们理解了 prototype,通过它能够以一个对象为原型,安全地创建大量的实例,这就是
prototype 的真正含义,也是它的价值所在。后面我们会看到,利用prototype 的这个特性,可以用来模拟
对象的继承,但是要知道,prototype 用来模拟继承尽管也是它的一个重要价值,但是绝对不是它的核心,
换句话说,JavaScript 之所以支持prototype,绝对不是仅仅用来实现它的对象继承,即使没有了prototype
继承,JavaScript 的prototype 机制依然是非常有用的。
由于prototype 仅仅是以对象为原型给类型构建副本,因此它也具有很大的局限性。首先,它在类型的
prototype 域上并不是表现为一种值拷贝,而是一种引用拷贝,这带来了“副作用”。改变某个原型上引用
类型的属性的属性值(又是一个相当拗口的解释:P),将会彻底影响到这个类型创建的每一个实例。有的时
候这正是我们需要的(比如某一类所有对象的改变默认值),但有的时候这也是我们所不希望的(比如在
类继承的时候),下面给出了一个例子:
例21.13 prototype 的局限性
执行结果如图21.8 所示:
图21.8 prototype 的局限性
总之,prototype 是一种面向对象的机制,它通过原型来管理类型与对象之间的关系,prototype 的特点
是能够以某个类型为原型构造大量的对象。以prototype 机制来模拟的继承是一种原型继承,它是JavaScript
多种继承实现方式中的一种(在下一节我们会详细讨论她)。尽管prototype 和传统的Class 模式不同,但
是我们仍然可以认为prototype-based 是一种纯粹的面向对象机制。
21.3 继承与多态
继承与多态是面向对象最重要的两个特征,JavaScript 能用语言本身的特性来实现它们。
21.3.1 什么是继承
前面已经说过,如果两个类都是同一个实例的类型,那么它们之间存在着某些关系,我们把同一个实
例的类型之间的泛化关系称为“继承”。
这很容易理解,例如,“白马”是一种“马”,“白马”和“马”之间的关系就是继承关系。“一匹
白马”是“白马”的一个实例,“白马”是“一匹马”的类型,而“马”同样是“一匹马”的类型,“马”
是“白马”的泛化,所以“白马”继承自“马”。上述听起来复杂的关系可以简单地用图21.9 来表示:
图21.9 继承关系
一旦确定了两个类的继承关系,就至少意味着三层含义,一是子类的实例可以共享父类的方法,二是
子类可以覆盖父类的方法或者扩展新的方法,三是子类和父类都是的子类实例的“类型”。
在JavaScript 中,并不直接从文法上支持继承,换句话说,JavaScript 没有实现“继承”的语法,从这
个意义上来说,JavaScript 并不是直接的面向对象的语言。
在 JavaScript 中,继承是通过模拟的方法来实现的,在下一小节里,我们将讨论JavaScript 中具体的实
现继承的方法。
21.3.2 实现继承的方法
从上一小节我们知道,要实现继承,其实就是实现上面所说的三层含义,即子类的实例可以共享父类
的方法,子类可以覆盖父类的方法或者扩展新的方法,以及子类和父类都是子类实例的“类型”。
对于JavaScript 来说要实现上面这三层含义,其实既简单又不简单。这个结论听起来很矛盾,但是你
很快就会发现它是有道理的。下面将介绍几种JavaScript 中具体的实现继承的方法,并详细分析它们的利
与弊。
21.3.2.1 构造继承法
JavaScript 中实现继承的第一种方法被称作构造继承法。顾名思义,这种继承方法的形式是在子类中
执行父类的构造函数,例如:
例21.14 构造继承法
执行结果如图21.10 所示:
图21.10 构造继承法
上面的这个例子中,类ArrayList 继承了Collection,而类SortedList 继承了ArrayList。注意到,这种
继承关系是通过在子类中调用父类的构造函数来维护的。如,ArrayList 中调用了this.base.call(this.base,
m_members.length); 而SortedList 中则调用了
this.base.apply(this.base, arguments)。
从继承关系上看,ArrayList 继承了Collection 公有的size()方法,但是却无法继承Collection 静态的
isEmpty()方法。ArrayList 定义了自己的add()方法。SortedList 继承了ArrayList 的add()方法,以及从Collection
继承下来的size()方法,但是却不能继承ArrayList 重写的静态toString()方法。SortedList 定义了自己的sort()
方法。注意到SortedList 的size()方法实际上有一个BUG,用add()方法添加了元素之后,并没有变更size()
的值,这是因为Collection 类中定义的size()返回的是一个外部环境的参数(具体的奥妙在第22 章中会有
详细的解释),它不会受到子类ArrayList 和SortedList 的影响。所以要维持size()的正确性,只能在ArrayList
类中重写size()方法,如下:
this.size = function(){return m_elements.length}
注意到实际上构造继承法并不能满足继承的第三层含义,无论是a instanceof Collection 还是b
instanceof ArrayList,返回值总是false。其实,这种继承方法除了通过调用父类构造函数将属性复制到自身
之外,并没有作其他任何的事情,严格来说,它甚至算不是上继承。尽管如此用它的特性来模拟常规的对
象继承,也已经基本上达到了我们预期的目标。这种方法的优点是简单和直观,而且可以自由地用灵活的
参数执行父类的构造函数,通过执行多个父类构造函数方便地实现多重继承(下一小节里将讨论多重继承
的概念),缺点主要是不能继承静态属性和方法,也不能满足所有父类都是子类实例的类型这个条件,这
样对于实现多态将会造成麻烦(多态的概念21.3.5 小节将有详细的讨论)。
21.3.2.2 原型继承法:
原型继承法是JavaScript 中最流行的一种继承方式。以至于有人说,JavaScript 的面向对象机制实际上
是基于原型的一种机制,或者说,JavaScript 是一种基于原型的语言。
基于原型编程是面向对象编程的一种特定形式。在这种基于原型的编程模型中,不是通过声明静态的
类,而是通过复制已经存在的原型对象来实现行为重用。这个模型一般被称作是class-less, 面向原型,或
者是基于接口编程。
既然如此,基于原型模型其实并没有“类”的概念,这里所说的“类”是一种模拟,或者说是沿用了
传统的面向对象编程的概念。
所以很快我们会发现,这种原型继承法和传统的类继承法并不一致。
或者说,原型继承法虽然有类继承法无法比拟的优点,也有其缺点,一个很大的缺陷就是前面所说的
prototype 的副作用。
要了解什么是“原型继承法”,先回顾一下上一节里prototype 的特性,我们说,prototype 的最大特
点是能够让对象实例共享原型对象的属性,因此如果把某个对象作为一个类型的原型,那么我们说这个类
型的所有实例以这个对象为原型。这个时候,实际上这个对象的类型也可以作为那些以这个对象为原型的
实例的类型(回顾一下原型模式的第二个要求)。有意思的是,JavaScript 正是这样做的,例如:
例21.15 原型继承法
执行结果如图21.11 所示:
图21.11 原型继承法
在这个简单的例子里,Point2D 和Point3D 都以Point 为原型,Point 为Point2D 和Point3D 提供dimension
(维度)属性。从面向对象的角度,相当于Point2D 和Point3D 都继承了Point,有趣的是,p1 和p2 虽然
分别是Point2D 和Point3D 的实例,但是p1 instanceof Point 和p2 instanceof Point 的值都是true。
之前我们说过,类型的原型可以构成一个原型链,这样就能实现多个层次的继承,继承链上的每一个
对象都是实例的类型。下面是一个例子:
例21.16 prototype 的多重继承
function Point()
{
……
}
//Point 继承Object,这个通常可以省略,因为自定义类型的缺省原型为Object
Point.prototype = new Object();
function Point2D()
{
……
}
//Point2D 继承Point
Point2D.prototype = new Point();
function ColorPoint2D()
{
……
}
//ColorPoint2D 又继承Point2D
ColorPoint2D.prototype = new Point2D();
同构造继承法相比,原型继承法的优点是结构更加简单,而且不需要每次构造都调用父类的构造函数
(尽管你仍然可以调用它),并且不需要通过复制属性的方式就能快速实现继承。但是它的缺点也是很明
显的,首先它不方便直接支持多重继承,因为一个类型只能有一个原型;其次它不能很好地支持多参数和
动态参数的父类构造,因为在原型继承的阶段你还不能决定以什么参数来实例化父类对象;第三是你被迫
要在原型声明阶段实例化一个父类对象作为当前类型的原型,有的时候父类对象是不能也不应该随便实例
化的;最后一个缺点是之前提到过的prototype 的“副作用”。
既然prototype 继承有那么多缺点,那么我们是不是不应该使用它?
答案是否定的,因为,同类继承相比,原型继承本来就是一个简化了的版本,因此我们不应该要求它
完全达到标准的类继承的效果,实际上,当你的父类是一个简单、抽象的模型或者一个接口的时候,原型
继承的表现在已知的JavaScript 对象继承中是最好的,甚至可以说,prototype 继承才是JavaScript 文法上
提供的真正意义上的继承机制。所以,我们在使用JavaScript 时,能够采用原型继承的地方,应当尽可能
地采用这种继承方式。
现在回过头来探讨前面关于“基于对象”和“面向对象”的话题。那么JavaScript 究竟是不是一种面
向对象语言呢?我认为是。
面向对象不是只有类模型一种,prototype-based(基于原型)是class-based(基于类)的简化版本,是
一种class-less 的面向对象。对应地,prototype 继承是class 继承的简化版本,相对于class 继承来说它简化
了许多东西,例如省略了多重继承、基类构造函数、忽略了引用属性的继承……但不能因为它不支持这些
特性,就不承认它是一种完整的继承,否则我们就在用class-based 的眼光来看待prototype-based,实际上
这可能是错误的。
其实prototype-based 本来就是class-based 的简化版,因此给继承加一个限制,要求父类必须是一个抽
象类或者接口,那么prototype-based 就没有任何问题了。当然,也许这么做会使OOP 的reuse(重用)能
力减弱(以class-based 的眼光来看),但是这可以通过其他机制来弥补,比如结合其他类型的继承方式,
再比如闭包。
是否为继承添加额外的特性,开发者可以自由选择,但是在不需要这些额外特性的时候,还是有理由
尽量用prototype-based 继承。
总而言之,prototype-based 认为语言本身可能不需要过分多的reuse 能力,它牺牲了一些特型来保持语
言的简洁,这没有错,prototype-based 虽然比class-based 简单,但它依然是真正意义上的object-based。
21.3.2.3 实例继承法
构造继承法和原型继承法各有一个明显的缺点前面并没有具体提到。由于构造继承法没有办法继承类
型的静态方法,因此它无法很好地继承JavaScript 的核心对象(还记得什么是核心对象么?如果忘了,回
顾一下第7 章)。而原型继承法虽然可以继承静态方法,但是依然无法很好地继承核心对象中的不可枚举
方法,下面举出一个例子:
例21.17 构造继承的局限性
function MyDate()
{
this.base = Date;
this.base.apply(this, arguments);
}
var date = new MyDate();
alert(date.toGMTString);
//核心对象的某些方法不能被构造继承,原因是核心对象并不像我们自定义的一般对象那样
//在构造函数里进行赋值或初始化操作
上面的例子中,我们尝试用构造继承的方法来继承Date 类型,但是却发现它不能很好地工作,date.
toGMTString 的值为undefined,这个方法并没有被成功继承。那么,既然用构造继承法不行,用原型继承
法又如何呢?
例21.18 原型继承的局限性
function MyDate()
{
}
MyDate.prototype = new Date();
var date = new MyDate();
alert(date.toGMTString);
原型继承法的表现似乎不错,这一次终于获得了基类的方法,然而,令人吃惊的是,当你尝试调用date
对象的toString 或toGMTString 方法时,Internet Explorer 抛出一个怪异的异常,说,“‘[object]’不是日
期对象”。功败垂成,看来原型继承法还是不能解决核心对象的继承问题。
那么核心对象是不是就不能被继承呢?答案是否定的。下面要介绍的这种继承方法就是最好的继承核
心对象的方法,不论是继承Date 类型、String 类型还是Array 类型或者其他什么核心类型,它都能够很好
地工作。
先回顾一下第7 章中曾经说过的一句话:“构造函数通常没有返回值,它们只是初始化由this 值传递
进来的对象,并且什么也不返回。如果函数有返回值,被返回的对象就成了new 表达式的值”,这句话引
出了一种新的继承方法,我们叫它“实例继承法”。下面这个例子给出了实例继承法继承Date 对象的例子:
例21.19 实例继承法
执行结果如图21.12 所示:
图21.12 实例继承法
我们可以看到,这一次MyDate 类型工作得很好,它确实继承了核心对象Date 的方法。通常情况下要
对JavaScript 原生的核心对象或者DOM 对象进行继承时,我们会采用这种继承方法。不过,它也有几个
明显的缺点,首先,由于它需要在执行构造函数的时候构造基类的对象,而JavaScript 的new 运算与函数
调用不同的是不能用apply()方法传递给它不确定的arguments 集合,这样就会对那些可以接受不同类型和
不同数量参数的类型的继承造成比较大的麻烦。
其次,从上面的例子可以看出,这种继承方式是通过在类型中构造对象并返回的办法来实现继承的,
那样的话new 运算的结果实际上是类型中构造的对象而不是类型本身创建的对象,alert(myDate instanceof
MyDate);的执行结果将会是false,对象的构造函数将会是实际构造的对象的构造函数而不是类型本身的构
造函数,尽管你可以通过赋值的办法修正它,但是你却无法修正instanceof 表达式的结果,这不能不说是
一个很大的遗憾。
第三,这种方法一次只能返回一个对象,它和原型继承法一样不能支持多重继承。
所以,我们的结论是,构造继承法也不是一种真正的继承法,它也是一种模拟。构造继承法是目前所
知的唯一一种可以较好地继承JavaScript 核心对象的继承法。当你要继承JavaScript 的核心对象或者DOM
对象时,可以考虑采用这种方法。
在第22 章我们讨论闭包的时候,将会给出一个比较复杂的ListArray 的例子,它采用的就是构造继承
法,继承Array 对象。
21.3.2.4 拷贝继承法
顾名思义,拷贝继承法就是通过对象属性的拷贝来实现继承,早期的Prototype 和其他一些框架在特定
的情况下就用到了这种继承方法。下面是一个拷贝继承的例子:
例21.20 拷贝继承法
Function.prototype.extends = function(obj)
{
for(var each in obj)
{
this.prototype[each] = obj[each];
//对对象的属性进行一对一的复制,但是它又慢又容易引起问题
//所以这种“继承”方式一般不推荐使用
}
}
var Point2D = function(){
……
}
Point2D.extends(new Point())
{
……
}
从上面的例子中可以看出,拷贝继承法实际上是通过反射机制拷贝基类对象的所有可枚举属性和方法
来模拟“继承”,因为可以拷贝任意数量的对象,因此它可以模拟多继承,又因为反射可以枚举对象的静
态属性和方法,所以它同构造继承法相比的优点是可以继承父类的静态方法。但是由于是反射机制,因此
拷贝继承法不能继承非枚举类方法,例如父类中重载的toString()方法,另外,拷贝继承法也有几个明显的
缺点,首先是通过反射机制来复制对象属性效率上非常低下。其次它也要构造对象,通常也不能很好地支
持灵活的可变参数。第三,如果父类的静态属性中包含引用类型,它和原型继承法一样导致副作用。第四,
当前类型如果有静态属性,这些属性可能会被父类的动态属性所覆盖。最后这种可支持多重继承的方式并
不能清晰地描述出父类与子类的相关性。
21.3.2.5 几种继承法的比较
我们通过下表总结一下上面各种继承方法的优缺点:
表21.1 比较几种继承方法的优劣
比较项构造继承 原型继承 实例继承 拷贝继承
静态属性继承 N Y Y Y
内置对象继承 N 部分 Y Y
多参多重继承 Y N Y N
执行效率 高 高 高 低
多继承 Y N N Y
instanceof false true false false
21.3.2.6 混合继承
混合继承是将两种或者两种以上的继承同时使用,其中最常见的是构造继承和原型继承混合使用,这
样能够解决构造函数多参多重继承的问题。例如:
例21.21 混合继承法
另外,在模拟多继承的时候,原型继承和部分条件下的拷贝继承的同时使用也较常见。
第二十二章闭包与函数式编程
在 JavaScript 里,“闭包”是一个神奇的东西。借着闭包的力量,我们将跨过面向对象的领域,来攀
登一座新的高峰。保罗格雷厄姆曾经说过,我认为目前为止只有两种真正干净利落, 始终如一的编程模式:C
语言模式和Lisp 语言模式.此二者就象两座高地, 在它们中间是尤如沼泽的低地。在这里C 语言代表着过
程式语言的精髓,它目前所知的高层境界是面向对象。而称为Lisp 的语言,则以另一种形式的无与伦比的
美,成为与过程化对等的存在,即,我们将要介绍的函数式编程。
22.1 动态语言与闭包
程序语言中的闭包(closure)概念不是由JavaScript 最先提出的,从smalltalk 开始,闭包就成了编程
语言的一个重要概念。几乎所有的知名动态语言(如Perl、python、ruby 等)都支持闭包,JavaScript 也不
例外。
闭包 (closure)的确是个精确但又很难解释的电脑名词。因此在理解它之前,必须先解释下面一些简
单概念。
22.1.1 动态语言
所谓动态程序设计语言(Dynamic Programming Language),准确地说,是指程序在运行时可以改变其
结构:新的函数可以被引进,已有的函数可以被删除等在结构上的变化。相反,非动态语言在编译(或解
释)时,程序结构已经被确定,在执行过程中不能再发生改变。
JavaScript 是一个典型的动态语言。除此之外如Ruby、Python 等也都属于动态语言,而C、C++等语
言则不属于动态语言。
一些人习惯上将编译型语言认为是非动态语言,而解释型语言认为是动态语言,实际上这是完全错误
的概念,动态语言的概念与语言是编译还是解释没有关系,一些解释型的语言也可以是静态语言,编译型语
言确实不易设计为动态语言,但也仍然可以通过良好的设计和使用技巧达到“动态”的效果。
在这里还需要区分一下另外一对容易和上面概念混淆的概念,即动态类型语言(Dynamically Typed
Language) 和静态类型语言(Static Typed Language)。
所谓动态类型语言是指在执行期间才去发现数据类型的语言,静态类型语言与之相反,如JavaScript、
VBScript 和Perl 都是典型的动态类型语言。很多人常常将动态类型语言和动态语言混为一谈,显然从上面
的描述看来,它们是两个完全不同的概念。虽然,大多数动态语言都是动态类型语言,但动态语言本身并
不要求一定是动态类型的,而动态类型语言也不一定是动态语言。
22.1.2 语法域和执行域
所谓语法域,是指定义某个程序段落时的区域,所谓执行域则是指实际调用某个程序段落时所影响的
区域。
在非动态语言中,语法域和执行域范围基本上是一致的,执行域通常只能访问它自身语法域的范围和
少量向它开放的语法域,而不能访问它外层或者与它关联的执行域。而在动态语言中,执行域的范围通常
大得多。
非动态语言,如C++的函数在调用时(执行域上)只能访问自身语法域上允许访问的环境,如全局变
量和函数、所在对象的属性和方法以及自身的参数和临时变量,这和定义函数时的许可范围一致。动态语
言如JavaScript 的函数不但能够访问语法域上的这些范围,还能够访问它外层环境中的执行域范围,例如:
例22.1 动态语言的执行域
22.1.2 JavaScript 的闭包
在程序语言中,所谓闭包,是指语法域位于某个特定的区域,具有持续参照(读写)位于该区域内自
身范围之外的执行域上的非持久型变量值能力的段落。这些外部执行域的非持久型变量神奇地保留它们在
闭包最初定义(或创建)时的值 (深连结)。
从上面的概念可以看出,闭包通常是在动态语言中才有的概念,它是某些可以访问外部执行域的段落。
JavaScript 中的闭包,是通过定义在函数体内部的function 来实现的。
例22.1 就是典型的闭包应用,RandomAlert()函数的返回值是一个闭包,a(),b()分别访问了闭包两次
被创建时对应的外层RandomAlert()函数的执行域上的局部变量x 的值。
闭包这个概念我们之前已经多次提到过,但是一直没有解释清楚。相信你即使看了本节前面两段的解
释,仍然还是会觉得有一点困惑。闭包和函数的概念到底有什么相同点和不同点,相信这是大多数读到这
里的读者心中最大的疑惑。其实,闭包和函数的关系,应当类似于一种动态和静态、结构和实例的关系,
下面再通过一个例子来简单说明:
例22.2 闭包的本质
我们说例22.2 中,y()和z()的结果不同,因为两次B()创造的闭包被执行时访问的是不同的b 值,它正
好是分别的调用B()时b 被初始化的值。这里最奇怪的地方在于,当y()和z()被调用时,B()函数调用已经
结束了。如果你有C++、Java 或者其他什么编程语言的知识,也许你的潜意识里会认为当B()调用结束时,
局部变量b 的值已经被销毁,但结果却是令人惊讶的,由于被返回的闭包里引用了B()调用域上的b 值,
所以它并没有随着B()调用的结束而被销毁。
类似的还有之前我们见到过的例22.1 和例4.6,在这里我们再次列出例4.6:
function dice(count, side) //count 定义骰子的数量,side 定义每个骰子的面数
{
var ench = Math.floor(Math.random() * 6); //+0~+5 的骰子随机变数修正
//这里返回一个闭包,该闭包的作用是对指定的面数和修正值的骰子进行“投掷”
return function()
{
var score = 0;
for(var i = 0; i < count; i++)
{
score += Math.floor(Math.random() * side) +1;
}
return score + ench;
}
}
var d1 = dice(2,6); //生成一组2d6+n 的骰子,其中的n 为0~5 的随机数
var d2 = dice(1,20); //生成一颗20 面的骰子,带有0~5 的随机点数修正
例4.6 中,d1、d2 引用的闭包都使用了外部环境中的局部变量ench 和side 的值,而这两个局部变量
是在dice()方法才被初始化的,在dice()调用结束后,它们并没有被销毁。当你调用d1()和d2()时,你将会
引用到d1 和d2 在获取闭包时分别创建的side 和ench 值。
我通常认为闭包是一种引用结构,至少在JavaScript 中是这样的。JavaScript 中的闭包(closure),也
可以理解为一种“函数实例引用”(function instatnce referer)。
22.2 闭包的特点与形式
闭包,作为一种特殊的结构,有其自身的特点和各种形式。
22.2.1 闭包的内在:自治的领域
闭包的“闭”是指闭包的内部环境对外部不可见,也就是说闭包具有控制外部域的能力但是又能防止
外部域对闭包的反向控制。换句话说,闭包的领域是对外封闭的。
闭包的这一个特点不用过多解释,因为JavaScript 闭包是通过function 实现的,所以它天然具有基本
的函数特征,在闭包内声明的变量,闭包外的任何环境中都无法访问的,除非闭包向外部环境提供了访问
它们的接口。例如:
例22.3 闭包的封闭性
执行结果如图 22.1 所示:
图 22.1 闭包的封闭性
22.2.2 访问外部环境
我们说,闭包可以访问外部环境,前面我们已经见过闭包读外部环境的例子,事实上闭包不但可以读
外部环境,还可以写外部环境。
严格来说,外部环境既包括闭包外部的语法域也包括闭包外部的执行域。但是闭包对语法域环境的访
问和普通函数一致,因此我们这里主要强调的是闭包对执行域环境的访问。
下面是一个用闭包写外部环境的例子:
例22.4 闭包改变外部环境
我们说c1 和c2 通过调用counter 构造了两个不同的计数器它们的初值分别是0 和10,步长分别是1
和-1,在调用闭包时,我们用步长改变计数器值iden,使得计数器的值按照给定的步长递增。
上面的例子用面向对象的思想也能够实现,但是用闭包从形式上要比用对象简洁一些,后面我们会看
到,实际上我们在上面的例子中用了另外一种和面向对象等同的抽象思想,即函数式(functional)思想。
有趣的是,外部环境的读写和闭包出现在函数体内的顺序没有关系,例如:
function createClosure(){
var x = 10;
return function()
{
return x;
}
}
和
function createClosure(){
function a()
{
return x;
}
var x = 10;
return a;
}
的结果是一样的。
22.2.3 闭包和面向对象
我们说,JavaScript 的对象中的私有属性其实就是环境中的非持久型变量,而在构造函数内用this.foo =
function(){…}形式定义的方法其实也是闭包的一种创建形式,只是它提供的是一种开放了“外部接口”的
闭包:
例22.5 闭包和面向对象
执行结果如图 21.22 所示:
图22.2 闭包和面向对象
22.2.4 其他形式的闭包
JavaScript 的闭包不仅仅只有以上几种简单的形式,它还有其他更加“诡异”的形式,例如:
例22.6 闭包的其他形式
这个例子我们曾经见到过,function(j){setTimeout(function(){alert(j)}, 100);}是一个闭包,它访问test()
的调用环境,而function(){alert(j)}也是一个闭包,它又访问由外部闭包提供的环境。这样的闭包使用法经
常被用在异步的环境中,用来将特定的引用“绑定”给闭包。例如,下面的用法通过闭包环境绑定修正了
事件注册时的“this”指针:
button1.onclick =
(function(owner){return function(){button1_click.apply(owner,arguments)}})(button1);
回顾一下第21 章中关于利用闭包修正this 指针的讨论,这是闭包的一个非常重要的作用。
22.3 闭包使用的注意事项
我们说,闭包的最大特点是可以访问外部环境的执行域,而这些执行域相对于语法域来说是灵活多变
的,这有可能为程序引入额外的复杂度。另外由于执行域被闭包引用,所以返回了闭包的执行域在函数调
用结束后,并没有马上被销毁。如果你在程序在执行过程中产生了大量的闭包,而又忘记或者及时销毁它
们,就有可能导致程序内存的剧增。
在一些特定情况下需要注意限制闭包的使用。
22.3.1 不适合使用闭包的场合
首先,如果你用来返回闭包的函数是一个非常庞大的函数,而你需要的只是访问这个环境中的一小部
分属性,那么你就应该充分衡量一下这么使用的利弊,因为被这个很小的闭包所引用会使得整个调用对象
耗费的存储空间不能被及时销毁。
其次,除非你很确定闭包引用被调用时真正访问到的外部执行环境是什么样子,否则最好不要轻易使
用闭包。尤其是嵌套使用闭包,因为这样做虽然可能使得程序代码量大大减少,但是极大地增加了程序的
逻辑复杂度,因为你如果不能很明确地确定闭包使用时的外部环境是什么样子的,这就意味着当你的程序
出现异常情况的时候,排查和修复将会变成一项非常复杂的工作。
22.4 函数式编程
函数式编程是一种和面向对象编程对等的程序设计思想,在某些偏于数学形式的模型中,函数式编程
拥有比面向对象编程更大的优势。与面向对象相比,函数式编程天生简洁直接,并且有更高的效率。而且
函数式编程和面向对象也并不矛盾,它们的结合有利于我们改善系统的代码和优化结构。
22.4.1 什么是函数式编程
什么是函数式编程?如果你这么直白地询问,会发现它竟是一个不太容易解释的概念。许多在程序设
计领域有着多年经验的老手,也无法很明白地说清楚函数式编程到底在研究些什么。函数式编程对于熟悉
过程式程序设计的程序员来说的确是一个陌生的领域,闭包(closure),延续(continuation),和柯里化
(currying)这些概念看起来是这么的陌生,同我们熟悉的if、else、while 没有任何的相似之处。尽管函数
式编程有着过程式无法比拟的优美的数学原型,但它又是那么的高深莫测,似乎只有拿着博士学位的人才
玩得转它。
这一节有点难,但它并不是掌握JavaScript 所必需的技能,如果你不想用JavaScript 来完成那些用Lisp
来完成活儿,或者不想学函数式编程这种深奥的技巧,你完全可以跳过它们,进入下一章的旅程。
那么回到这个问题,什么是函数式编程?答案很长……
22.4.1.1 函数是第一型
这句话本身该如何理解?什么才是真正的“第一型”?我们看下面的数学概念:
二元方程式 F(x, y) = 0,x, y 是变量, 把它写成 y = f(x), x 是参数,y 是返回值,f 是由x 到y 的
映射关系,被称为函数。如果又有,G(x, y, z) = 0,或者记为 z = g(x, y),g 是x、y 到z 的映射关系,也是
函数。如果g 的参数x, y 又满足前面的关系y = f(x), 那么得到z = g(x, y) = g(x, f(x)),这里有两重含义,
一是f(x)是x 上的函数,又是函数g 的参数,二是g 是一个比f 更高阶的函数。
这样我们就用 z = g(x, f(x)) 来表示方程F(x, y) = 0 和G(x, y, z) = 0 的关联解,它是一个迭代的函数。
我们也可以用另一种形式来表示g,记z = g(x, y, f),这样我们将函数g 一般化为一个高阶函数。同前面相
比,后面这种表示方式的好处是,它是一种更加泛化的模型,例如T(x,y) = 0 和G(x,y,z) = 0 的关联解,我
们也可以用同样的形式来表示(只要令f=t)。在这种支持把问题的解转换成高阶函数迭代的语言体系中,
函数就被称为“第一型”。
JavaScript 中的函数显然是“第一型”。下面就是一个典型的例子:
Array.prototype.each = function(closure)
{
return this.length ? [closure(this.slice(0, 1))].concat(this.slice(1).each(closure)) : [];
}
这真是段神奇的魔法代码,它充分发挥了函数式的魅力,在整个代码中只有函数(function)和符号
(Symbol)。它形式简洁并且威力无穷。
[1,2,3,4].each(function(x){return x * 2})得到[2,4,6,8],而[1,2,3,4].each(function(x){return x-1})得到
[0,1,2,3]。
函数式和面向对象的本质都是“道法自然”。如果说,面向对象是一种真实世界的模拟的话,那么函
数式就是数学世界的模拟,从某种意义上说,它的抽象程度比面向对象更高,因为数学系统本来就具有自
然界所无法比拟的抽象性。
22.4.1.2 闭包与函数式编程:
闭包,在前面的章节中我们已经解释过了,它对于函数式编程非常重要。它最大的特点是不需要通过
传递变量(符号)的方式就可以从内层直接访问外层的环境,这为多重嵌套下的函数式程序带来了极大的
便利性,例如下面这段代码:
JavaScript:(function outerFun(x){return function innerFun(y){return x * y}})(2)(3);//innerFun 访问外层的x
22.4.1.3 科里化(Currying)
什么是 Currying? 它是一个有趣的概念。还是从数学开始:我们说,考虑一个三维空间方程 F(x, y, z)
= 0,如果我们限定z = 0,于是得到 F(x, y, 0) = 0 记为 F’(x, y)。这里F’显然是一个新的方程式,它代表
三维空间曲线F(x, y, z)在z = 0 平面上的两维投影。记y = f(x, z), 令z = 0, 得到 y = f(x, 0),记为 y = f’(x),
我们说函数f’是f 的一个Currying 解。
下面给出了JavaScript 的Currying 的例子:
例22.7 Currying(科里化)
上面的例子中,b=add(2)得到的是一个add()的Currying 函数,它是当x = 2 时,关于参数y 的函数,
注意到上面也用到了闭包的特性。
有趣的是,我们可以给出任意函数一般化Currying 的形式,例如:
function Foo(x, y, z, w)
{
var args = arguments;
//如果函数的形参个数小于实参个数
if(Foo.length < args.length)
//返回一个闭包
return function()
{
//这个闭包用之前已经输入的参数和此次输入的参数构成参数调用Foo 函数自身
return
args.callee.apply(Array.apply([], args)
.concat(Array.apply([], arguments)));
}
else
//否则对函数求值
return x + y – z * w;
}
22.4.1.4 延迟求值和延续
惰性(或延迟)求值是一项有趣的技术,考虑下面的代码片断:
var s1 = somewhatLongOperation1();
var s2 = somewhatLongOperation2();
var s3 = concatenate(s1, s2);
在一个命令式语言中求值顺序是确定的,因为每个函数都有可能会变更或依赖于外部状态,所以就必
须有序的执行这些函数:首先是
somewhatLongOperation1,然后 somewhatLongOperation2,最后 concatenate,在函数式语言里就不尽
然了。
只要确保没有函数修改或依赖于全局变量,somewhatLongOperation1 和 somewhatLongOperation2 可
以被并行执行。但是如果我们不想同时运行这两个函数,还有必要保证有序的执行他们呢?答案是不。我
们只在其他函数依赖于s1 和s2 时才需要执行这两个函数。我们甚至在concatenate 调用之前都不必执行他
们——可以把他们的求值延迟到concatenate 函数内实际用到他们的位置。如果用一个带有条件分支的函数
替换concatenate 并且只用了两个参数中的一个,另一个参数就永远没有必要被求值。在函数式语言中,不
确保一切都(完全)按顺序执行,因为函数式只在必要时才会对其求值。
例如,在JavaScript 中,我们可能这么写:
function concatenate(s1, s2)
{
if(cond1) s1();
s2();
……
}
var s3 = concatenate(somewhatLongOperation1,somewhatLongOperation2);
假如cond1 的条件不满足,那么somewhatLongOperation1 就不需要被执行,这样从一定程度上强化了
程序逻辑的优化潜力。
一个更为有趣的话题是,函数式编程可以定义无穷数据结构,对严格语言来说实现这个要复杂的多。
考虑一个 Fibonacci 数列,显然我们无法在有限的时间内计算出或在有限的内存里保存一个无穷列表。在
严格语言如 Java 中,只能定义一个能返回 Fibonacci 数列中特定成员的 Fibonacci 函数,在函数式语言
中,我们对其进一步抽象并定义一个关于 Fibonacci 数的无穷列表,因为作为一个惰性的语言,只有列表
中实际被用到的部分才会被求值。这使得可以抽象出很多问题并从一个更高的层次重新审视他们。(例如,
我们可以在一个无穷列表上使用表处理函数)。
下面是一个例子:
例22.8 Fibonacci 无穷数列
执行结果如图22.3 所示:
图22.3 无穷数据结构
上面这个函数的好处是,求出fn 之后,要计算fm 只需要计算fn(m-n)就行了,而且它几乎不需要额外
的存储空间。
“延续”(Continuations)是为了解决延迟求值带来的一个不小的副作用。我们说,在精确的函数式
程序结构中,延迟求值的结果让我们很难描述函数somewhatLongOperation1 和somewhatLongOperation2
之间的依赖关系。如果somewhatLongOperation1 必须先于somewhatLongOperation2 被执行那么我们要么无
法控制这种必然性(可能会导致潜在的程序逻辑错误),要么会用额外的约定破坏函数式的完备性,幸运
地是,函数式的形式可以描述这种依赖关系:
var s3 = concatenate(somewhatLongOperation1(somewhatLongOperation2));
这,就是“延续”的含义。
或许,对于Continuations,我们应该找到一个更加合适的中文词汇来翻译,不过其实它的含义并不复
杂。我们说,在函数式模型中,子系统s1 和s2 没有一种固定的次序关系,而是取决于实际的调用,那么
如何来描述系统中的依赖关系呢?答案很简单,当你不能确定s1 的输出是否在s2 之前时,要想把s1 的输
出作为s2 的输入,那么你可以把s1 系统本身作为s2 的输入。
22.4.2 函数式编程、公式化与数学模型
同面向对象的“道法自然”相比,函数式更贴近于数学,它是数学王国的代言人。而数学本身,是对
自然界的一种“强力的抽象”,所以,一般我们认为,函数式编程表现出比面向对象更强的“抽象性”。
我们说数学是一种“先验”科学,它对自然界的抽象是“与生俱来”的,目前已知的任何自然规律,
都近乎完美地服从于数学定律。有意思的是,古往今来,数学定律的发现,往往要先于自然规律的发现。
这样看起来似乎违背原离,不像是数学替自然规律说话,倒有点像是自然规律依附于数学王国了,不过,
这正是数学魅力的所在。
我们说函数式是公式化的语言,它具有明显的数学特征。例如,在前面的例子中,我们已经见到过,
JavaScript 里可以这么定义抛物线方程(族):
例22.9 抛物线方程
仔细研究它,你会发现,这种函数定义方式,同数学语言的描述方式几乎完全一致!
这种数学形式上的一致性,在传统的过程式语言中,几乎是无法想象的。如果不利用JavaScript 的函
数式特性,要定义和调用抛物线方程,只能以下面这种丑陋的方式:
function parabola(a, b, c, x)
{
return a * x * x + b * x + c;
}
var y = parabola(2, 3, 4, 15);
如果用面向对象来表示,则问题又有一点点差别:
function Parabola(a, b, c)
{
this.evaluate = function(x)
{
return a * x * x + b * x + c;
}
}
var p1 = new Parabola(2,3,4); //抛物线y = 2*x^2 + 3*x + 4
alert(p1.evaluate());
面向对象把抛物线当作了“对象”,从自然界的角度来讲,这没有什么问题,然而从数学的角度来讲,
它把问题复杂化了。抛物线本来就是一个方程(函数),不需要再定义成一个对象,然后用蹩脚的evaluate()
来进行求值。