JavaScript-编程精解-Eloquent-第四版-一-
JavaScript 编程精解(Eloquent)第四版(一)
译者:飞龙
第一章:引言
这是一本关于指导计算机的书。如今,计算机和螺丝刀一样常见,但它们要复杂得多,让它们按照你的想法运行并不总是容易的。
如果你要给计算机的任务是常见且容易理解的,比如查看你的电子邮件或充当计算器,你可以打开相应的应用程序并开始工作。但对于独特或开放式的任务,往往没有合适的应用程序。
这就是编程可能发挥作用的地方。编程是构建一个程序的行为——一组精确的指令,告诉计算机该做什么。由于计算机是愚蠢的、迂腐的动物,编程在根本上是乏味和令人沮丧的。
幸运的是,如果你能克服这一事实——甚至享受以愚蠢机器能够处理的方式进行思考的严谨——编程是有回报的。它使你能够在几秒钟内完成手动操作需要永远才能完成的事情。它是让你的计算机工具完成之前无法做到的事情的一种方式。此外,它还成为了解决难题和抽象思维的美妙游戏。
大多数编程都是使用编程语言进行的。编程语言是一种人工构造的语言,用于指导计算机。值得注意的是,我们与计算机沟通的最有效方式在很大程度上借鉴了我们彼此之间沟通的方式。像人类语言一样,计算机语言允许将单词和短语以新方式组合,使得表达不断新的概念成为可能。
曾几何时,以语言为基础的接口,比如1980年代和1990年代的BASIC和DOS提示符,是与计算机互动的主要方式。对于常规计算机使用,这些接口在很大程度上已被视觉界面取代,视觉界面更易于学习,但自由度较低。但是如果你知道在哪里寻找,语言依然存在。其中之一,JavaScript,内置于每个现代网页浏览器中,因此几乎在每个设备上都可用。
本书将努力使你对这门语言足够熟悉,以便能够用它做一些有用且有趣的事情。
关于编程
除了解释JavaScript,我还将介绍编程的基本原则。事实证明,编程是困难的。基本规则简单明了,但基于这些规则构建的程序往往变得复杂到引入自己的规则和复杂性。在某种程度上,你是在构建自己的迷宫,而你很容易在其中迷失。
阅读这本书时,你可能会感到非常沮丧。如果你是编程新手,很多新材料需要消化。这些材料会以结合的方式呈现,要求你建立额外的联系。
你必须付出必要的努力。当你努力跟上书中的内容时,不要急于得出关于自己能力的结论。你很好——你只需要坚持下去。休息一下,重新阅读一些材料,确保你阅读并理解示例程序和练习。学习是艰苦的工作,但你所学的一切都是你的,并将使进一步的学习变得更容易。
当行动变得无利可图时,收集信息;当信息变得无利可图时,休息。
—厄休拉·K·勒古恩,《黑暗的左手》
程序有许多方面。它是程序员输入的文本,是驱动计算机执行操作的指令力量,是计算机内存中的数据,同时,它也控制着在该内存上执行的操作。试图将程序与熟悉的物体进行比较的类比往往显得苍白无力。一个表面上看似合适的比较是将程序比作机器——涉及许多独立的部分,要使整个系统运作,我们必须考虑这些部分如何相互连接并对整体操作做出贡献。
计算机是一个物理机器,充当这些无形机器的宿主。计算机本身只能做一些非常简单的事情。它们之所以如此有用,是因为它们以极高的速度完成这些事情。一个程序可以巧妙地组合大量这些简单的动作,从而做出非常复杂的事情。
程序是一种思想的构建。构建是没有成本的,它是无重量的,并且在我们打字的手下容易增长。但是随着程序的增长,其复杂性也在增加。编程的技能是构建不会让程序员感到困惑的程序的技能。最好的程序是在保持易于理解的同时能够做一些有趣的事情。
一些程序员认为,这种复杂性最好通过在他们的程序中使用一小组经过充分理解的技术来管理。他们制定了严格的规则(“最佳实践”),规定程序应该具有的形式,并且他们小心翼翼地待在他们安全的小区域内。
这不仅无聊——而且无效。新问题通常需要新解决方案。编程领域年轻且仍在迅速发展,足够多样化以容纳截然不同的方法。在程序设计中有许多可怕的错误,你应该至少尝试犯一次,这样你才能理解它们。对优秀程序的感知是通过实践发展出来的,而不是通过一份规则清单来学习的。
为什么语言很重要
在计算的初始阶段,并不存在编程语言。程序的样子大致是这样的:
00110001 00000000 00000000
00110001 00000001 00000001
00110011 00000001 00000010
01010001 00001011 00000010
00100010 00000010 00001000
01000011 00000001 00000000
01000001 00000001 00000001
00010000 00000010 00000000
01100010 00000000 00000000
这是一个将数字从1加到10
当然,手动输入这些晦涩的位模式(即零和一)确实让程序员有一种强大巫师的深刻感受。这在工作满足感上一定是值得的。
上一个程序的每一行都包含一条指令。它可以用英语这样写:
-
在内存位置
0存储数字0。 -
在内存位置
1存储数字1。 -
将内存位置
1的值存储到内存位置2。 -
从内存位置
2的值中减去数字11。 -
如果内存位置
2的值是数字0,继续执行指令9。 -
将内存位置
1的值加到内存位置0。 -
将数字
1加到内存位置1的值上。 -
继续执行指令
3。 -
输出内存位置
0的值。
尽管这已经比位的混合更加可读,但仍然相当模糊。使用名称而不是数字作为指令和内存位置会有所帮助。
Set "total" to 0.
Set "count" to 1.
[loop]
Set "compare" to "count".
Subtract 11 from "compare".
If "compare" is 0, continue at [end].
Add "count" to "total".
Add 1 to "count".
Continue at [loop].
[end]
Output "total".
你能看到程序此时是如何工作的吗?前两行给两个内存位置赋予了初始值:total将用于构建计算结果,而count将跟踪我们当前查看的数字。使用compare的行可能是最令人困惑的。程序想要检查count是否等于11,以决定是否可以停止运行。因为我们的假设机器相当原始,所以它只能测试一个数字是否为零,并基于此做出决定。因此,它使用标记为compare的内存位置来计算count - 11的值,并根据该值做出决定。接下来的两行将count的值加到结果中,并在程序决定count还不是11时每次将count增加1。
这是相同程序的JavaScript版本:
let total = 0, count = 1;
while (count <= 10) {
total += count;
count += 1;
}
console.log(total);
// → 55
这个版本给我们带来了一些改进。最重要的是,我们不再需要指定程序要如何来回跳转——while结构解决了这个问题。只要给定的条件成立,它就会继续执行下面的代码块(用大括号括起来)。这个条件是count <= 10,这意味着“计数小于或等于10。”我们不再需要创建一个临时值并将其与零进行比较,这只是一个无趣的细节。编程语言的一部分强大之处在于,它们可以为我们处理这些无趣的细节。
在程序结束时,当while结构完成后,使用console.log操作来输出结果。
最后,如果我们恰好有方便的操作range和sum可用,分别用于创建一个范围内的数字集合和计算一组数字的总和,程序可能看起来像这样:
console.log(sum(range(1, 10)));
// → 55
这个故事的寓意是同一个程序可以用长短不同、可读和不可读的方式表达。程序的第一个版本极为晦涩,而最后一个版本几乎是英文:记录从1到10的数字范围的总和。(我们将在后面的章节中看到如何定义像sum和range这样的操作。)
一种好的编程语言通过让程序员以更高的层次谈论计算机必须执行的操作来帮助他们。它帮助省略细节,提供方便的构建模块(例如while和console.log),允许你定义自己的构建模块(例如sum和range),并使这些模块易于组合。
什么是JavaScript?
JavaScript于1995年推出,作为一种向Netscape Navigator浏览器的网页添加程序的方式。此后,这种语言被所有其他主要图形网页浏览器所采纳。它使现代网页应用成为可能——也就是说,这些应用可以让你直接互动,而无需每次操作都重新加载页面。JavaScript还用于更传统的网站,以提供各种形式的互动性和巧妙性。
重要的是要注意,JavaScript与名为Java的编程语言几乎没有关系。相似的名称是出于营销考虑而非明智的判断。当JavaScript被引入时,Java语言正受到强力推广并日益流行。有人认为借助这一成功的想法不错。现在我们被这个名称困住了。
在Netscape之外被采纳后,编写了一份标准文档,描述JavaScript语言应该如何工作,以便那些声称支持JavaScript的各种软件可以确保它们实际提供的是同一种语言。这被称为ECMAScript标准,以进行标准化的Ecma International组织命名。在实践中,ECMAScript和JavaScript这两个术语可以互换使用——它们是同一种语言的两个名称。
有人会说可怕的关于JavaScript的事情。这些话中很多都是事实。当我第一次被要求用JavaScript写东西时,我很快开始厌恶它。它几乎接受我输入的任何内容,但以一种完全不同于我意图的方式解读。这当然与我当时对自己在做什么毫无头绪有很大关系,但这里确实存在一个问题:JavaScript在其允许的范围上极为宽松。这个设计理念是为了让初学者更容易进行JavaScript编程。实际上,这主要让你在程序中找到问题变得更加困难,因为系统不会直接指出它们。
然而,这种灵活性也有其优点。它为更灵活语言中不可能实现的技术留出了空间,并形成了一种愉快的非正式编程风格。在正确学习这门语言并使用一段时间后,我实际上开始喜欢上JavaScript。
JavaScript经历了多个版本。ECMAScript版本3是在2000年至2010年间JavaScript崛起时得到广泛支持的版本。在此期间,版本4的雄心勃勃的开发正在进行,计划对语言进行一系列激进的改进和扩展。然而,以如此激进的方式改变一个生动且被广泛使用的语言,结果在政治上是困难的,因此版本4的工作在2008年被放弃。一个雄心较小的版本5于2009年发布,仅进行了一些不具争议性的改进。2015年,版本6发布,这是一个重要更新,包括了一些计划在版本4中的理念。从那时起,我们每年都有新的小更新。
JavaScript的演变意味着浏览器必须不断跟上。如果你使用的是较旧的浏览器,它可能不支持所有功能。语言设计者非常小心,不做任何可能破坏现有程序的更改,因此新浏览器仍然可以运行旧程序。在本书中,我使用的是2024版的JavaScript。
网络浏览器并不是唯一使用JavaScript的平台。一些数据库,例如MongoDB和CouchDB,将JavaScript作为其脚本和查询语言。多个桌面和服务器编程平台,最显著的是Node.js项目(见第二十章),提供了在浏览器外编程JavaScript的环境。
代码及其用途
代码是构成程序的文本。本书的大多数章节都包含了相当多的代码。我相信阅读代码和编写代码是学习编程不可或缺的部分。尽量不要只是匆匆浏览示例——要认真阅读并理解它们。起初这可能会很慢且令人困惑,但我保证你很快就会掌握其中的窍门。练习题也是如此。在你真正写出一个有效的解决方案之前,不要假设你理解它们。
我建议你在实际的JavaScript解释器中尝试练习题的解答。这样,你可以立即得到反馈,看看你所做的是否有效,并且我希望你会被诱惑去实验,超越练习。
在本书中运行示例代码最简单的方法——也是进行实验的方法——是在eloquentjavascript.net的在线版本中查找它。你可以单击任何代码示例进行编辑和运行,并查看它产生的输出。要进行练习,请访问eloquentjavascript.net/code,该网站提供每个编码练习的起始代码,并允许你查看解决方案。
在本书的网站之外运行本书定义的程序需要小心。许多示例可以独立运行,并且应该在任何JavaScript环境中工作。但后面的章节中的代码通常是为特定环境(浏览器或Node.js)编写的,只能在那里运行。此外,许多章节定义了更大的程序,其中的代码片段彼此依赖或依赖外部文件。网站上的沙盒提供链接到包含运行特定章节所需的所有脚本和数据文件的ZIP文件。
本书概述
本书大致分为三个部分。前12章讨论JavaScript语言。接下来的七章则讲述网页浏览器以及如何使用JavaScript对其进行编程。最后,两章专门讲解Node.js,另一个编写JavaScript的环境。本书中有五个项目章节描述了更大的示例程序,让你体验实际编程。
本书的语言部分以四个章节开始,介绍JavaScript语言的基本结构。它们讨论控制结构(例如你在本介绍中看到的while语句)、函数(编写自己的构建块)和数据结构。在这些之后,你将能够编写基本程序。接下来,第五章和第六章介绍了使用函数和对象编写更抽象代码并控制复杂性的技巧。
在第一项目章节构建一个简单的送货机器人后,本书的语言部分继续探讨错误处理和修复、正则表达式(处理文本的重要工具)、模块化(抵御复杂性的另一种手段)以及异步编程(处理耗时事件)。第二个项目章节实现了一种编程语言,结束了本书的第一部分。
本书的第二部分,第十三章到第十九章,描述了浏览器JavaScript可访问的工具。你将学习如何在屏幕上显示内容(第十四章和第十七章)、响应用户输入(第十五章)以及通过网络进行通信(第十八章)。在这一部分还有两个项目章节,构建一个平台游戏和一个像素画程序。
第二十章描述了Node.js,第二十一章则使用该工具构建一个小网站。最后,第二十二章讨论了优化JavaScript程序以提高速度时需要考虑的一些事项。
排版约定
在本书中,以等宽字体书写的文本将代表程序的元素。有时这些是自足的片段,有时它们只是引用了附近程序的一部分。程序(你已经见过一些)书写如下:
function factorial(n) {
if (n == 0) {
return 1;
} else {
return factorial(n - 1) * n;
}
}
有时,为了显示程序产生的输出,预期的输出会在其后面写出,前面加上两个斜杠和一个箭头。
console.log(factorial(8));
// → 40320
祝好运!
第一部分:语言
在机器的表面之下,程序在运行。它无需努力,自如地扩展和收缩。电子在极大的和谐中散射并重新组合。显示器上的形态不过是水面上的涟漪。其本质在无形中保持在下方。
—元马大师,《编程之书》
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0010-01.jpg
第二章:值、类型和运算符
在计算机的世界中,只有数据。你可以读取数据、修改数据、创建新数据——但不能提及非数据的东西。所有这些数据都以长序列的位存储,因此从根本上说是相似的。
比特是任何一种二值事物,通常被描述为零和一。在计算机内部,它们的形式可以是高电压或低电压、电信号强或弱,或者在CD表面上光亮或暗淡的点。任何离散信息都可以简化为零和一的序列,因此可以用比特表示。
例如,我们可以用比特表示数字13。这与十进制数字的工作方式相同,但我们只有2个不同的数字,而每个数字的权重从右到左增加一个2的倍数。以下是构成数字13的比特,以及它们下面显示的数字权重:
0 0 0 0 1 1 0 1
128 64 32 16 8 4 2 1
那是二进制数00001101。它的非零数字代表8、4和1,总和为13。
值
想象一下位的海洋——一个庞大的位的海洋。典型的现代计算机在其易失性数据存储(工作内存)中有超过1000亿个比特。非易失性存储(硬盘或同类设备)往往要多几个数量级。
为了能够处理如此数量的比特而不迷失,我们将它们分成表示信息块的块。在Java-Script环境中,这些块被称为值。虽然所有值都是由比特构成的,但它们扮演不同的角色。每个值都有一个确定其角色的类型。有些值是数字,有些值是文本片段,有些值是函数,等等。
要创建
本章的剩余部分介绍Java-Script程序的原子元素——也就是简单值类型和可以作用于这些值的运算符。
数字
number类型的值,毫无疑问,是数字值。在Java-Script程序中,它们的书写方式如下:
13
在程序中使用这个会导致数字13的比特模式在计算机的内存中产生。
JavaScript使用固定数量的位数,64位,来存储单个数字值。用64位可以组合的模式有限,这限制了可以表示的不同数字的数量。用N个十进制数字,你可以表示10^(N)个数字。类似地,给定64个二进制位,你可以表示2^64个不同的数字,大约是18个quintillion(后面有18个零的18)。这非常庞大。
计算机内存曾经小得多,人们通常使用8位或16位的组合来表示数字。意外的溢出小数字很容易发生——即得到一个无法适应给定位数的数字。如今,即便是口袋里的计算机内存也非常充裕,所以你可以自由使用64位,而只有在处理真正庞大的数字时才需担心溢出。
然而,并非所有小于18 quintillion的整数都能适应JavaScript数字。这些位还存储负数,所以有一位用于指示数字的符号。更大的问题是表示非整数。为此,一些位被用于存储小数点的位置。实际上,能存储的最大整数更接近9万亿(15个零)——这仍然是相当巨大的。
分数用小数点表示。
9.81
对于非常大或非常小的数字,你还可以通过添加e(代表指数)及数字的指数来使用科学记数法。
2.998e8
这就是2.998 × 10^8 = 299,800,000。
对于小于前述9万亿的整数(也称为整数)进行的计算,确保始终是精确的。遗憾的是,分数计算通常并非如此。正如π(圆周率)无法用有限的十进制数字精确表示,许多数字在仅有64位可用存储时会失去一些精度。这虽然令人遗憾,但在特定情况下才会引发实际问题。重要的是要意识到这一点,并将分数字符视为近似值,而非精确值。
算术
数字的主要操作是算术。算术运算如加法或乘法需要两个数字值,并生成一个新数字。以下是在JavaScript中的表示方式:
100 + 4 * 11
+和*符号称为运算符。第一个表示加法,第二个表示乘法。在两个值之间放置一个运算符将对这些值应用它,并生成一个新值。
这个例子是指“将4和100相加,然后将结果乘以11”,还是乘法在加法之前进行?正如你可能猜到的,乘法优先进行。与数学一样,你可以通过将加法用括号括起来来改变这一点。
(100 + 4) * 11
对于减法,有-运算符。除法可以使用/运算符。
当操作符没有括号一起出现时,它们的应用顺序由操作符的优先级决定。示例显示乘法优先于加法。/操作符与*操作符具有相同的优先级。同样,+和-也具有相同的优先级。当多个具有相同优先级的操作符并排出现时,如1 - 2 + 1,它们是从左到右应用的:(1 - 2) + 1。
不必过于担心这些优先级规则。如果有疑问,只需添加括号。
还有一个算术操作符,你可能不会立即认出来。%符号用于表示余数操作。X % Y是将X除以Y的余数。例如,314 % 100产生14,144 % 12结果为0。余数操作符的优先级与乘法和除法相同。你也会经常看到这个操作符被称为模。
特殊数字
在JavaScript中,有三个特殊值被视为数字,但它们的行为不同于正常数字。前两个是Infinity和-Infinity,表示正无穷和负无穷。Infinity - 1仍然是Infinity,依此类推。不过,不要过于相信基于无穷的计算。它在数学上并不严谨,并且很快会导致下一个特殊数字:NaN。
NaN代表“不是一个数字”,尽管它是数字类型的一个值。当你尝试计算0 / 0(零除以零)、Infinity - Infinity或其他一些没有意义的数字运算时,你会得到这个结果。
字符串
下一个基本数据类型是字符串。字符串用于表示文本。它们通过用引号括起内容来书写。
`Down on the sea`
"Lie on the ocean"
'Float on the ocean'
你可以使用反引号、双引号或单引号来标记字符串,只要字符串开头和结尾的引号匹配。
你几乎可以在引号之间放入任何内容,让JavaScript将其转换为字符串值。但有些字符比较困难。你可以想象在引号之间放置引号可能会很麻烦,因为它们看起来像是字符串的结束。换行(当你按下ENTER时得到的字符)只能在用反引号(`)引起来的字符串中包含。
为了能够在字符串中包含这些字符,使用以下表示法:在引号文本中,反斜杠(\)表示后面的字符具有特殊含义。这称为转义字符。前面有反斜杠的引号不会结束字符串,而是成为字符串的一部分。当n字符出现在反斜杠后面时,它被解释为换行。同样,反斜杠后面的t表示制表符。考虑以下字符串:
"This is the first line\nAnd this is the second"
这是该字符串中的实际文本:
This is the first line
And this is the second
当然,有时你希望字符串中的反斜杠仅仅是反斜杠,而不是特殊代码。如果两个反斜杠相连,它们将合并在一起,结果字符串值中只会剩下一个。这就是字符串“换行字符的写法是“\n。”的表达方式:
"A newline character is written like \"\\n\"."
字符串同样需要被建模为一系列位,以便能够存在于计算机中。JavaScript 这样做的方式是基于Unicode标准。该标准为几乎每个你可能需要的字符分配一个数字,包括希腊文、阿拉伯文、日文、亚美尼亚文等等。如果我们为每个字符都有一个数字,那么字符串可以用一串数字来描述。这就是 JavaScript 所做的。
不过有一个复杂性:JavaScript 的表示法使用每个字符串元素 16 位,这可以描述多达 2¹⁶ 个不同的字符。然而,Unicode 定义的字符数量超过这个限制——目前大约是其两倍。因此,一些字符,比如许多表情符号,在 JavaScript 字符串中占用两个“字符位置”。我们将在第五章中回到这个问题。
字符串不能被除、乘或减。+操作符可以用于它们,不是用于相加,而是用于连接——将两个字符串粘合在一起。以下行将产生字符串“concatenate”:
"con" + "cat" + "e" + "nate"
字符串值有多个相关的函数(方法),可以用来对它们执行其他操作。我将在第四章中详细说明这些。
用单引号或双引号写的字符串行为非常相似;唯一的区别在于你需要转义的引号类型。反引号引起来的字符串,通常称为模板字面量,可以做一些更多的技巧。除了能够跨越多行外,它们还可以嵌入其他值。
`half of 100 is ${100 / 2}`
当你在模板字面量中写入${}内的内容时,其结果将被计算、转换为字符串,并包含在该位置。这个例子产生的字符串是“100 的一半是 50”。
一元操作符
并非所有操作符都是符号。有些是用单词书写的。一个例子是typeof操作符,它会生成一个字符串值,指明你提供的值的类型。
console.log(typeof 4.5)
// → number
console.log(typeof "x")
// → string
我们将在示例代码中使用console.log来指示我们想要查看某个表达式的计算结果。(更多内容将在下一章讨论。)
本章迄今为止显示的其他操作符都作用于两个值,但typeof只接受一个。使用两个值的操作符称为二元操作符,而只接受一个的称为一元操作符。减号操作符(-)可以同时作为二元操作符和一元操作符使用。
console.log(- (10 - 2))
// → -8
布尔值
通常情况下,有一个值用来区分只有两个可能性是很有用的,比如“是”和“否”或“开”和“关”。为此,JavaScript 有一个布尔类型,它只有两个值:true和false,以这两个词书写。
比较
这是生成布尔值的一种方式:
console.log(3 > 2)
// → true
console.log(3 < 2)
// → false
和
<符号分别是“更大于”和“更小于”的传统符号。它们是二元运算符。应用它们的结果是一个布尔值,指示它们在此情况下是否成立。
字符串可以以相同的方式进行比较。
console.log("Aardvark" < "Zoroaster")
// → true
字符串的排序方式大致上是字母顺序,但并不是你在字典中预期看到的那样:大写字母总是“少于”小写字母,因此"Z" < "a",而且非字母字符(!、- 等)也包含在排序中。在比较字符串时,JavaScript 从左到右逐个比较字符的 Unicode 代码。
其他类似的运算符有>=(大于或等于)、<=(小于或等于)、==(等于)和!=(不等于)。
console.log("Garnet" != "Ruby")
// → true
console.log("Pearl" == "Amethyst")
// → false
在 JavaScript 中,唯一一个不等于自身的值是NaN(“不是一个数字”)。
console.log(NaN == NaN)
// → false
NaN用于表示无意义计算的结果,因此它不等于任何其他无意义计算的结果。
逻辑运算符
还有一些操作可以直接应用于布尔值本身。JavaScript 支持三种逻辑运算符:与、或和非。这些可以用来对布尔值进行“推理”。
&&运算符表示逻辑与。它是一个二元运算符,仅当给它的两个值都为真时,其结果才为真。
console.log(true && false)
// → false
console.log(true && true)
// → true
||运算符表示逻辑或。如果给它的任何值为真,它的结果就是true。
console.log(false || true)
// → true
console.log(false || false)
// → false
非(Not)写作感叹号(!)。它是一个一元运算符,反转给它的值——!true产生false,而!false产生true。
在将这些布尔运算符与算术和其他运算符混合时,并不总是明显需要括号。在实践中,你通常可以知道到目前为止我们看到的运算符中,||的优先级最低,然后是&&,接下来是比较运算符(>、==等),最后是其他。这种顺序的选择旨在使以下典型表达式中尽可能少地需要括号:
1 + 1 == 2 && 10 * 10 > 50
我们要看的最后一个逻辑运算符是非一元、非二元,而是三元,操作三个值。它的写法是用一个问号和一个冒号,如下所示:
console.log(true ? 1 : 2);
// → 1
console.log(false ? 1 : 2);
// → 2
这个被称为条件运算符(有时简称为三元运算符,因为它是该语言中唯一的此类运算符)。该运算符使用问号左侧的值来决定“选择”哪两个值中的一个。如果你写a ? b : c,当a为真时结果为b,否则为c。
空值
两个特殊值,写作null和undefined,用于表示缺少有意义的值。它们本身是值,但不携带任何信息。
语言中许多不产生有意义值的操作会产生undefined,仅仅因为它们必须产生某个值。
undefined和null之间的意义差异是JavaScript设计的一个意外,大多数情况下这并不重要。在你需要关注这些值的情况下,我建议将它们视为大致可互换的。
自动类型转换
在介绍中,我提到JavaScript不遗余力地接受几乎任何你给它的程序,甚至是做奇怪事情的程序。以下表达式很好地展示了这一点:
console.log(8 * null)
// → 0
console.log("5" - 1)
// → 4
console.log("5" + 1)
// → 51
console.log("five" * 2)
// → NaN
console.log(false == 0)
// → true
当运算符应用于“错误”的类型值时,JavaScript会悄悄地将该值转换为所需的类型,使用的一套规则通常不是你想要或期望的。这被称为类型强制转换。第一个表达式中的null变成0,第二个表达式中的"5"变成5(从字符串到数字)。然而在第三个表达式中,+在进行数字相加之前会尝试字符串拼接,因此1被转换为"1"(从数字到字符串)。
当某个值在明显意义上无法映射为数字(例如"five"或undefined)时,被转换为数字后会得到NaN。对NaN的进一步算术操作仍然会产生NaN,因此如果你在意想不到的地方遇到这种情况,请检查是否发生了意外的类型转换。
当使用==运算符比较相同类型的值时,结果是容易预测的:如果两个值相同,你应该得到true,除了NaN的情况。但是,当类型不同,JavaScript会使用一套复杂而混乱的规则来决定如何处理。在大多数情况下,它只是尝试将其中一个值转换为另一个值的类型。然而,当运算符两侧有null或undefined时,只有在两侧都是null或undefined之一时才会返回true。
console.log(null == undefined);
// → true
console.log(null == 0);
// → false
这种行为通常很有用。当你想测试一个值是否具有实际的值而不是null或undefined时,可以用==或!=运算符将其与null进行比较。
如果你想测试某个东西是否确切指向false值呢?像0 == false和"" == false这样的表达式也为真,因为自动类型转换。当你不希望发生任何类型转换时,还有两个额外的运算符:===和!==。第一个测试一个值是否精确等于另一个,第二个测试是否不精确等于。因此,"" === false是false,正如预期的那样。
我建议防御性地使用三字符比较运算符,以防止意外的类型转换让你困扰。但当你确信两侧的类型将相同时,使用较短的运算符没有问题。
逻辑运算符的短路
逻辑运算符&&和||以一种特殊的方式处理不同类型的值。它们会将左侧的值转换为布尔类型以决定如何操作,但根据运算符和转换结果,它们会返回原始的左侧值或右侧值。
例如,||运算符将在左侧的值可以转换为true时返回该值,否则返回右侧的值。当值为布尔值时,这具有预期效果,对其他类型的值也做类似处理。
console.log(null || "user")
// → user
console.log("Agnes" || "user")
// → Agnes
我们可以利用这个功能作为回退到默认值的一种方式。如果你有一个可能为空的值,可以在其后加上||和替代值。如果初始值可以转换为false,你将得到替代值。将字符串和数字转换为布尔值的规则规定,0、NaN和空字符串("")视为false,而其他所有值都视为true。这意味着0 || -1产生-1,"" || “!?”得“!?”。
??运算符与||类似,但仅在左侧的值为null或undefined时返回右侧的值,而不是其他可以转换为false的值。通常,这比||的行为更可取。
console.log(0 || 100);
// → 100
console.log(0 ?? 100);
// → 0
console.log(null ?? 100);
// → 100
&&运算符类似,但方向相反。当左侧的值是转换为false的东西时,它返回该值,否则返回右侧的值。
这两个运算符的另一个重要特性是,它们右侧的部分只有在必要时才会被计算。在true || X的情况下,无论X是什么——即使它是一个做一些可怕事情的程序——结果将为true,X从不被计算。false && X也是如此,它是false并将忽略X。这被称为短路评估。
条件运算符的工作方式类似。第二和第三个值中,只有被选择的那个会被计算。
摘要
在本章中,我们查看了四种类型的 JavaScript 值:数字、字符串、布尔值和未定义值。这些值可以通过输入其名称(true、null)或值(13、"abc")来创建。
你可以使用运算符组合和转换值。我们看到的二元运算符包括算术运算(+、-、*、/和%)、字符串连接(+)、比较(==、!=、===、!==、<、>、<=、>=)以及逻辑运算(&&、||、??),还有几个一元运算符(-用于取反一个数字,!用于逻辑取反,以及typeof用于查找值的类型)和一个三元运算符(?:)用于根据第三个值选择两个值中的一个。
这为你提供了足够的信息来将 JavaScript 用作口袋计算器,但仅此而已。下一章将开始将这些表达式结合成基本程序。
在我薄薄的、半透明的皮肤下,我的心脏闪耀着亮红色,他们不得不给我注射 10cc 的 JavaScript 才能让我恢复。(我对血液中的毒素反应良好。)天啊,那东西会让你的腮帮子嗓子都冒桃子!
—为何,Why’s (Poignant) Guide to Ruby
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0022-01.jpg
第三章:程序结构
在本章中,我们将开始做一些真正可以称之为编程的事情。我们将把对JavaScript语言的掌握扩展到名词和我们迄今为止看到的句子片段的范围,达到能够表达有意义的散文的程度。
表达式与语句
在第一章中,我们创建了值并将运算符应用于这些值以获得新值。像这样创建值是任何JavaScript程序的主要内容。但这些内容必须框架在一个更大的结构中才能有用。这就是我们在本章中要讨论的内容。
产生一个值的代码片段称为表达式。每个字面写出的值(如22或“精神分析”)都是一个表达式。括号中的表达式也是表达式,二元运算符应用于两个表达式或一元运算符应用于一个表达式也是如此。
这展示了基于语言的接口的部分美妙之处。表达式可以包含其他表达式,这与人类语言中子句嵌套的方式类似——一个子句可以包含它自己的子句,依此类推。这使我们能够构建描述任意复杂计算的表达式。
如果一个表达式对应于一个句子片段,那么JavaScript的语句对应于一个完整的句子。一个程序是一系列语句。
最简单的语句是一个表达式,后面跟着一个分号。这是一个程序:
1;
!false;
不过,这仍然是一个无用的程序。一个表达式可以满足于仅仅生成一个值,然后该值可以被外部代码使用。然而,一个语句是独立存在的,因此如果它不影响世界,那就是无用的。它可以在屏幕上显示某些内容,比如通过console.log,或者以某种方式改变机器的状态,从而影响之后的语句。这些变化被称为副作用。前一个示例中的语句仅仅生成值1和true,然后立即将它们丢弃。这对世界没有任何影响。当你运行这个程序时,没有任何可观察的事情发生。
在某些情况下,JavaScript允许你省略语句末尾的分号。在其他情况下,它必须存在,否则下一行将被视为同一语句的一部分。关于何时可以安全省略分号的规则有些复杂且容易出错。因此在本书中,所有需要分号的语句都会包含一个。我建议你也这样做,至少在你学会了更多关于省略分号的细微之处之前。
绑定
程序如何保持内部状态?它是如何记住事物的?我们已经看到如何从旧值生成新值,但这并不会改变旧值,而且新值必须立即使用,否则会再次消散。为了捕捉和保持值,JavaScript提供了一种叫做绑定或变量的东西。
let caught = 5 * 5;
这给了我们第二种语句。特殊词(关键字)let表示此句将定义一个绑定。它后面跟着绑定的名称,如果我们想立即赋值,则跟着一个=运算符和一个表达式。
这个例子创建了一个名为caught的绑定,并用它来获取通过将5与5相乘产生的数字。
在定义一个绑定后,可以将其名称用作表达式。此类表达式的值是绑定当前持有的值。这里有一个例子:
let ten = 10;
console.log(ten * ten);
// → 100
当一个绑定指向一个值时,并不意味着它永远与该值绑定。=运算符可以随时在现有绑定上使用,以将它们从当前值中断开,并指向一个新值。
let mood = "light";
console.log(mood);
// → light
mood = "dark";
console.log(mood);
// → dark
你应该把绑定想象成触手,而不是盒子。它们不包含值;它们抓住值——两个绑定可以引用同一个值。程序只能访问它仍有引用的值。当你需要记住某些东西时,你要么生长一个触手来保持它,要么将你现有的一个触手重新连接到它。
让我们看看另一个例子。为了记住路易吉还欠你的美元金额,你创建了一个绑定。当他还款$35时,你给这个绑定一个新值。
let luigisDebt = 140;
luigisDebt = luigisDebt - 35;
console.log(luigisDebt);
// → 105
当你定义一个绑定而不给它赋值时,触手没有任何东西可抓,因此它就空悬了。如果你询问一个空绑定的值,你会得到值undefined。
单个let语句可以定义多个绑定。定义必须用逗号分隔。
let one = 1, two = 2;
console.log(one + two);
// → 3
单词var和const也可以以类似于let的方式创建绑定。
var name = "Ayda";
const greeting = "Hello ";
console.log(greeting + name);
// → Hello Ayda
其中第一个,var(“变量”的缩写),是在2015年前的JavaScript中声明绑定的方式,当时let还不存在。我将在下一章中详细说明它与let的具体区别。现在,请记住,它主要执行相同的操作,但我们在本书中很少使用它,因为它在某些情况下的行为奇怪。
单词const代表“常量”。它定义了一个常量绑定,该绑定在其存在期间始终指向相同的值。这对于仅将名称赋给某个值的绑定很有用,以便你可以在以后轻松引用它。
绑定名称
绑定名称可以是一个或多个字母的任何序列。数字可以是绑定名称的一部分——例如,catch22是一个有效名称——但名称不能以数字开头。绑定名称可以包含美元符号($)或下划线(_),但不能有其他标点符号或特殊字符。
具有特殊含义的词,如let,是关键字,不能用作绑定名称。还有一些词是“保留以供未来版本使用”的,这些也不能用作绑定名称。关键字和保留字的完整列表相当长。
break case catch class const continue debugger default
delete do else enum export extends false finally for
function if implements import interface in instanceof let
new package private protected public return static super
switch this throw true try typeof var void while with yield
不必担心记住这个列表。当创建绑定产生意外的语法错误时,请检查你是否尝试定义一个保留字。
环境
在特定时间存在的绑定及其值的集合称为环境。当程序启动时,这个环境并不是空的。它总是包含作为语言标准一部分的绑定,并且大多数时候,它也包含提供与周围系统交互方式的绑定。例如,在浏览器中,有一些函数用于与当前加载的网站进行交互,以及读取鼠标和键盘输入。
函数
默认环境中提供的许多值的类型为函数。函数是包裹在值中的一段程序。这种值可以被应用以运行包裹的程序。例如,在浏览器环境中,绑定prompt持有一个函数,该函数显示一个小对话框以请求用户输入。使用方法如下:
prompt("Enter passcode");
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0027-01.jpg
执行一个函数称为调用、调用或应用它。你可以通过在产生函数值的表达式后加上括号来调用一个函数。通常你会直接使用持有函数的绑定的名称。括号之间的值被传递给函数内部的程序。在示例中,prompt函数使用我们提供的字符串作为在对话框中显示的文本。传递给函数的值称为参数。不同的函数可能需要不同数量或不同类型的参数。
prompt函数在现代网络编程中使用得不多,主要是因为你无法控制生成的对话框的外观,但它在玩具程序和实验中可能会很有帮助。
console.log函数
在示例中,我使用console.log输出值。大多数JavaScript系统(包括所有现代网页浏览器和Node.js)提供一个console.log函数,将其参数写入某个文本输出设备。在浏览器中,输出会出现在JavaScript控制台。这部分浏览器界面默认是隐藏的,但大多数浏览器在你按下F12或在Mac上按下COMMAND-OPTION-I时会打开它。如果这样不行,可以在菜单中查找名为开发者工具或类似的选项。
尽管绑定名称不能包含句点字符,但console.log确实有一个。这是因为console.log不是一个简单的绑定,而是一个从console绑定持有的值中检索log属性的表达式。我们将在第四章中确切了解这意味着什么。
返回值
显示对话框或在屏幕上写入文本是一个副作用。许多函数因其产生的副作用而变得有用。函数也可以产生值,在这种情况下,它们不需要有副作用才能有用。例如,函数Math.max接受任意数量的数字参数并返回最大值。
console.log(Math.max(2, 4));
// → 4
当一个函数生成一个值时,称其为返回该值。在JavaScript中,任何生成值的东西都是一个表达式,这意味着函数调用可以在更大的表达式中使用。在以下代码中,调用Math.min(与Math.max相反)作为加法表达式的一部分:
console.log(Math.min(2, 4) + 100);
// → 102
第三章将解释如何编写自己的函数。
控制流
当你的程序包含多个语句时,这些语句的执行就像一个故事,从上到下。例如,以下程序有两个语句。第一个请求用户输入一个数字,第二个在第一个之后执行,显示该数字的平方。
let theNumber = Number(prompt("Pick a number"));
console.log("Your number is the square root of " +
theNumber * theNumber);
Number函数将一个值转换为数字。我们需要这个转换,因为prompt的结果是一个字符串值,而我们想要的是一个数字。还有类似的函数叫做String和Boolean,它们将值转换为相应的类型。
这里是简单的直线控制流的示意图:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0028-01.jpg
条件执行
并不是所有程序都是直线道路。例如,我们可能希望创建一条分支道路,在这种情况下,程序根据当前情况选择适当的分支。这被称为条件执行。
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0028-02.jpg
条件执行是在JavaScript中通过if关键字创建的。在简单的情况下,我们希望某段代码仅在某个条件成立时执行。例如,我们可能希望仅在输入实际是数字时显示输入的平方。
let theNumber = Number(prompt("Pick a number"));
if (!Number.isNaN(theNumber)) {
console.log("Your number is the square root of " +
theNumber * theNumber);
}
通过这个修改,如果你输入鹦鹉,将不会显示任何输出。
if关键字根据布尔表达式的值执行或跳过语句。决定性的表达式写在关键字后面,用括号括起来,后面跟着要执行的语句。
Number.isNaN函数是一个标准的JavaScript函数,仅当给定的参数是NaN时返回true。Number函数在你给它一个不表示有效数字的字符串时,恰好返回NaN。因此,这个条件可以理解为“除非theNumber不是一个数字,否则执行这个操作。”
在这个例子中,if后面的语句用大括号({和})包裹起来。大括号可以用来将任意数量的语句组合成一个单一的语句,称为块。在这种情况下,你也可以省略它们,因为它们只包含一条语句,但为了避免考虑它们是否需要,大多数JavaScript程序员在每个包裹的语句中都使用它们。在本书中,我们大多数情况下将遵循这个约定,偶尔会有一行代码的情况。
if (1 + 1 == 2) console.log("It's true");
// → It's true
你通常不仅会有在条件为真时执行的代码,还有处理其他情况的代码。这个替代路径在图中用第二个箭头表示。你可以使用else关键字,与if一起创建两个独立的替代执行路径。
let theNumber = Number(prompt("Pick a number"));
if (!Number.isNaN(theNumber)) {
console.log("Your number is the square root of " +
theNumber * theNumber);
} else {
console.log("Hey. Why didn't you give me a number?");
}
如果你有超过两个路径可以选择,你可以将多个if/else对组合在一起。这里有一个例子:
let num = Number(prompt("Pick a number"));
if (num < 10) {
console.log("Small");
} else if (num < 100) {
console.log("Medium");
} else {
console.log("Large");
}
程序将首先检查num是否小于10。如果是,它选择那个分支,显示“Small”,并结束。如果不是,它选择else分支,该分支本身包含一个第二个if。如果第二个条件(< 100)成立,这意味着这个数字至少是10,但低于100,显示“Medium”。如果不成立,就选择第二个也是最后的else分支。
该程序的框架大致如下:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0030-01.jpg
while和do循环
考虑一个输出0到12之间所有偶数的程序。可以这样编写:
console.log(0);
console.log(2);
console.log(4);
console.log(6);
console.log(8);
console.log(10);
console.log(12);
这虽然可行,但编写程序的想法是为了减少工作量,而不是增加。如果我们需要所有小于1,000的偶数,这种方法将行不通。我们需要的是一种能多次运行代码的方法。这种控制流的形式称为循环。
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0030-02.jpg
循环控制流允许我们返回到程序中的某个点,并用当前的程序状态重复执行。如果我们将其与一个计数绑定结合起来,我们可以做到类似于:
let number = 0;
while (number <= 12) {
console.log(number);
number = number + 2;
}
// → 0
// → 2
// ... etcetera
以关键字while开头的语句会创建一个循环。while后面跟着一个括号中的表达式,然后是一个语句,和if类似。只要表达式产生一个转换为布尔值时为真值的值,循环就会不断进入该语句。
数字绑定演示了绑定如何跟踪程序的进展。每次循环重复时,number会获取比其先前值多2的值。在每次重复的开始,它会与数字12进行比较,以决定程序的工作是否完成。
作为一个实际上能做有用事情的例子,我们现在可以编写一个程序来计算并显示2¹⁰(2的10次方)的值。我们使用两个绑定:一个用于跟踪结果,另一个用于计数我们将这个结果乘以2的次数。循环测试第二个绑定是否已达到10,如果没有,就更新这两个绑定。
let result = 1;
let counter = 0;
while (counter < 10) {
result = result * 2;
counter = counter + 1;
}
console.log(result);
// → 1024
计数器也可以从1开始,并检查是否<= 10,但出于在第四章中显而易见的原因,习惯从0开始计数是个好主意。
请注意,JavaScript还有一个用于指数运算的运算符(2 ** 10),你可以在实际代码中使用它来计算这一点——但这会破坏示例。
do循环是一种控制结构,类似于while循环。它的不同之处在于:do循环至少会执行其主体一次,只有在第一次执行之后才开始测试是否应该停止。为了反映这一点,测试出现在循环主体之后。
let yourName;
do {
yourName = prompt("Who are you?");
} while (!yourName);
console.log("Hello " + yourName);
该程序将强制你输入一个名称。它会不断询问,直到获得一个非空字符串。使用!运算符将值转换为布尔类型后再取反,除了""之外的所有字符串都会转换为true。这意味着循环将继续,直到你提供一个非空名称。
缩进代码
在示例中,我在某些较大语句的一部分前添加了空格。这些空格不是必需的——计算机没有它们也能正常接受程序。实际上,程序中的换行也是可选的。如果你愿意,可以将程序写成一长行。
在代码块内部的缩进作用是让代码结构对人类读者更明显。在其他块内打开新块的代码中,可能很难看出一个块结束了另一个块开始了。通过适当的缩进,程序的视觉形状与其中的块的形状相对应。我喜欢每个打开的块使用两个空格,但口味各异——有些人使用四个空格,有些人使用制表符。重要的是每个新块添加相同数量的空格。
if (false != true) {
console.log("That makes sense.");
if (1 < 2) {
console.log("No surprise there.");
}
}
大多数代码编辑器程序会自动将新行缩进到适当的位置。
for循环
许多循环遵循while示例中展示的模式。首先创建一个“计数器”绑定,以跟踪循环的进度。然后是一个while循环,通常带有一个测试表达式,以检查计数器是否已达到其结束值。在循环体结束时,计数器会更新以跟踪进度。
由于这种模式非常常见,JavaScript和类似语言提供了一种稍短且更全面的形式,即for循环。
for (let number = 0; number <= 12; number = number + 2) {
console.log(number);
}
// → 0
// → 2
// ... etcetera
该程序与之前的偶数打印示例完全相同(见第30页)。唯一的变化是与循环的状态相关的所有语句都在for之后分组在一起。
for关键字后的括号必须包含两个分号。第一个分号之前的部分初始化循环,通常通过定义一个绑定来实现。第二部分是检查循环是否应该继续的表达式。最后一部分在每次迭代后更新循环的状态。在大多数情况下,这比while结构更简洁明了。
这是使用for循环而非while循环计算2¹⁰的代码:
let result = 1;
for (let counter = 0; counter < 10; counter = counter + 1) {
result = result * 2;
}
console.log(result);
// → 1024
跳出循环
使循环条件产生false不是循环结束的唯一方式。break语句的作用是立即跳出所包围的循环。其用法在以下程序中演示,该程序找出第一个大于或等于20且能被7整除的数字。
for (let current = 20; ; current = current + 1) {
if (current % 7 == 0) {
console.log(current);
break;
}
}
// → 21
使用余数(%)运算符是测试一个数字是否能被另一个数字整除的简单方法。如果能,二者相除的余数就是零。
示例中的for构造没有检查循环结束的部分。这意味着循环永远不会停止,除非执行内部的break语句。
如果你删除那个break语句,或者不小心写了一个始终为真的结束条件,你的程序将陷入*无限循环*。被困在无限循环中的程序永远不会完成运行,这通常是个坏事。
continue关键字与break类似,它会影响循环的进度。当在循环体内遇到continue时,控制权会跳出该体,并继续进行循环的下一次迭代。
简洁地更新绑定。
特别是在循环中,程序通常需要“更新”绑定,以基于该绑定的先前值保持一个值。
counter = counter + 1;
JavaScript为此提供了一个快捷方式。
counter += 1;
对于许多其他运算符,类似的快捷方式也有效,例如result *= 2用于将result加倍,或counter -= 1用于向下计数。
这使我们能够进一步缩短计数示例。
for (let number = 0; number <= 12; number += 2) {
console.log(number);
}
对于counter += 1和counter -= 1,还有更短的等效形式:counter++和counter--。
使用switch根据值调度。
代码看起来像这样并不少见:
if (x == "value1") action1();
else if (x == "value2") action2();
else if (x == "value3") action3();
else defaultAction();
有一种构造叫做switch,旨在以更直接的方式表达这样的“调度”。不幸的是,JavaScript使用的语法(它从C/Java编程语言继承而来)有些笨拙——一系列if语句可能看起来更好。以下是一个示例:
switch (prompt("What is the weather like?")) {
case "rainy":
console.log("Remember to bring an umbrella.");
break;
case "sunny":
console.log("Dress lightly.");
case "cloudy":
console.log("Go outside.");
break;
default:
console.log("Unknown weather type!");
break;
}
你可以在switch打开的块内放置任意数量的case标签。程序将从与给定switch值相对应的标签开始执行,如果没有匹配值,则从default开始。它将继续执行,甚至跨越其他标签,直到达到break语句。在某些情况下,例如示例中的“晴天”情况,可以用来在不同的情况之间共享一些代码(它建议在晴天和阴天都出去)。但要小心——很容易忘记这样的break,这将导致程序执行你不希望执行的代码。
大写。
绑定名称不能包含空格,但使用多个词清晰描述绑定所代表的内容通常是有帮助的。以下是编写包含多个单词的绑定名称的选择:
fuzzylittleturtle
fuzzy_little_turtle
FuzzyLittleTurtle
fuzzyLittleTurtle
第一种风格可能难以阅读。我比较喜欢下划线的样子,尽管这种风格有点难打。标准的JavaScript函数和大多数JavaScript程序员遵循最后一种风格——它们将每个单词的首字母大写,除了第一个。适应这样的细节并不难,而混合命名风格的代码可能会让人阅读不畅,所以我们遵循这一惯例。
在少数情况下,例如数字函数,绑定的首字母也会大写。这是为了将该函数标记为构造函数。在第六章中会明确说明构造函数是什么。现在,重要的是不要被这种明显的不一致所困扰。
注释。
通常,原始代码无法传达程序希望传达给人类读者的所有信息,或者以一种人们可能无法理解的方式传达。有时,你可能只是想在程序中包含一些相关的想法。这就是*注释*的作用。
注释是一段程序的一部分,但计算机完全忽略它。JavaScript有两种写注释的方法。要编写单行注释,可以使用两个斜杠字符(//),然后在后面写上注释文本。
let accountBalance = calculateBalance(account);
// It's a green hollow where a river sings
accountBalance.adjust();
// Madly catching white tatters in the grass.
let report = new Report();
// Where the sun on the proud mountain rings:
addToReport(accountBalance, report);
// It's a little valley, foaming like light in a glass.
//注释只到行尾。位于/*和*/之间的文本段落将完全被忽略,无论是否包含换行。这对于添加关于文件或程序块的信息块是很有用的。
/*
I first found this number scrawled on the back of an old
notebook. Since then, it has often dropped by, showing up in
phone numbers and the serial numbers of products that I've
bought. It obviously likes me, so I've decided to keep it.
*/
const myNumber = 11213;
摘要。
你现在知道程序是由语句构成的,而这些语句有时又包含更多的语句。语句往往包含表达式,而这些表达式可以由更小的表达式构建而成。
将语句一个接一个放置,构成一个从上到下执行的程序。你可以通过使用条件语句(if、else和switch)和循环语句(while、do和for)引入控制流中的干扰。
绑定可以用来将数据片段归档到一个名称下,它们对于跟踪程序状态是有用的。环境是定义的绑定集合。JavaScript系统总是将一些有用的标准绑定放入你的环境中。
函数是封装了一段程序的特殊值。你可以通过编写functionName(argument1, argument2)来调用它们。这样的函数调用是一个表达式,可能会产生一个值。
练习。
如果你不确定如何测试练习的解决方案,请参考引言。
每个练习以问题描述开始。阅读这个描述并尝试解决练习。如果遇到问题,可以考虑阅读书末的提示。你可以在*[eloquentjavascript.net/code#2](https://eloquentjavascript.net/code#2)*找到练习的完整解决方案。如果你想从练习中学到东西,我建议在你解决了练习之后,或者至少在你努力攻克它到头痛的程度后,再查看解决方案。
*循环三角形*
编写一个循环,使其调用console.log七次,以输出以下三角形:
#
##
###
####
#####
######
#######
了解如何通过在字符串后写.length来获取字符串的长度可能会很有用。
let abc = "abc";
console.log(abc.length);
// → 3
*FizzBuzz*
编写一个程序,使用console.log打印从1到100的所有数字,有两个例外。对于能被3整除的数字,打印“Fizz”代替数字,而对于能被5整除(且不能被3整除)的数字,打印“Buzz”代替。
当你实现这个功能后,修改你的程序以打印“FizzBuzz”,用于那些同时能被3和5整除的数字(同时仍然打印“Fizz”或“Buzz”用于仅能被其中一个整除的数字)。
(这实际上是一个面试问题,据说可以筛选出相当一部分程序员候选人。所以如果你解决了这个问题,你的劳动力市场价值就上升了。)
*棋盘*
编写一个程序,创建一个表示8×8网格的字符串,使用换行符分隔行。在网格的每个位置上都有一个空格或一个“#”字符。这些字符应该形成一个棋盘。
将这个字符串传递给console.log应该显示类似这样的内容:
# # # #
# # # #
# # # #
# # # #
# # # #
# # # #
# # # #
# # # #
当你有一个生成这个图案的程序时,定义一个绑定size = 8,并修改程序使其适用于任意大小,输出给定宽度和高度的网格。
*人们认为计算机科学是天才的艺术,但实际上情况正好相反,只是许多人在做相互积累的事情,就像一堵迷你石头墙。*
—唐纳德·克努斯
<https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0038-01.jpg>
第四章:函数
函数是 JavaScript 编程中最核心的工具之一。将一段程序包装在一个值中的概念有很多用途。它为我们提供了一种结构化大型程序的方法,减少重复,将名称与子程序关联,以及将这些子程序相互隔离。
函数最明显的用途是定义新词汇。在散文中,创造新词通常被视为不良风格,但在编程中这是不可或缺的。
典型的成人英语使用者的词汇量大约有 20,000 个单词。很少有编程语言内置 20,000 个命令。而可用的词汇往往被定义得更为精确,因此在灵活性上不如人类语言。因此,我们必须引入新词,以避免过于冗长。
定义函数
函数定义是一种常规绑定,其中绑定的值是一个函数。例如,以下代码将square定义为指向一个生成给定数字平方的函数:
const square = function(x) {
return x * x;
};
console.log(square(12));
// → 144
函数是通过以关键字function开头的表达式创建的。函数有一组参数(在此例中,仅为x)和一个主体,主体包含在调用函数时要执行的语句。以这种方式创建的函数主体必须始终用大括号包裹,即使它仅由一个语句组成。
函数可以有多个参数或根本没有参数。在以下示例中,makeNoise没有列出任何参数名,而roundTo(将n四舍五入到step的最近倍数)列出了两个:
const makeNoise = function() {
console.log("Pling!");
};
makeNoise();
// → Pling!
const roundTo = function(n, step) {
let remainder = n % step;
return n - remainder + (remainder < step / 2 ? 0 : step);
};
console.log(roundTo(23, 10));
// → 20
一些函数,比如roundTo和square,会产生一个值,而有些则不会,比如makeNoise,它唯一的结果是一个副作用。返回语句决定了函数返回的值。当控制流遇到这样的语句时,它会立即跳出当前函数,并将返回的值传递给调用该函数的代码。如果return关键字后没有表达式,函数将返回undefined。没有返回语句的函数,例如makeNoise,也同样返回undefined。
函数的参数表现得像常规绑定,但它们的初始值由函数的调用者提供,而不是函数内部的代码。
绑定与作用域
每个绑定都有一个作用域,即绑定可见的程序部分。对于在任何函数、块或模块外部定义的绑定(见第十章),作用域是整个程序——你可以在任何地方引用这些绑定。这些被称为全局绑定。
为函数参数创建的绑定或在函数内部声明的绑定只能在该函数内引用,因此它们被称为局部绑定。每次调用函数时,这些绑定的新实例都会被创建。这在函数之间提供了一些隔离——每个函数调用都在自己的小世界中运行(其局部环境),并且通常可以在不需要了解全局环境中发生的事情的情况下理解。
使用let和const声明的绑定实际上是局部于其声明所在的块,因此如果在循环内部创建了其中一个,循环前后的代码无法“看到”它
let x = 10; // Global
if (true) {
let y = 20; // Local to block
var z = 30; // Also global
}
每个作用域可以“向外”查看周围的作用域,因此在示例中的块内部可以看到x。例外情况是当多个绑定具有相同名称时——在这种情况下,代码只能看到最内层的绑定。例如,当halve函数内部的代码引用n时,它看到的是它自己的 n,而不是全局的n。
const halve = function(n) {
return n / 2;
};
let n = 10;
console.log(halve(100));
// → 50
console.log(n);
// → 10
嵌套作用域
JavaScript不仅区分全局绑定和局部绑定。块和函数可以在其他块和函数内部创建,从而产生多重局部性。
例如,这个函数——输出制作一批鹰嘴豆泥所需的成分——内部还有另一个函数:
const hummus = function(factor) {
const ingredient = function(amount, unit, name) {
let ingredientAmount = amount * factor;
if (ingredientAmount > 1) {
unit += "s";
}
console.log(`${ingredientAmount} ${unit} ${name}`);
};
ingredient(1, "can", "chickpeas");
ingredient(0.25, "cup", "tahini");
ingredient(0.25, "cup", "lemon juice");
ingredient(1, "clove", "garlic");
ingredient(2, "tablespoon", "olive oil");
ingredient(0.5, "teaspoon", "cumin");
};
成分函数内部的代码可以看到外部函数的factor绑定。但它的局部绑定,例如unit或ingredientAmount,在外部函数中不可见。
块内可见的绑定集合由该块在程序文本中的位置决定。每个局部作用域也可以看到包含它的所有局部作用域,所有作用域都可以看到全局作用域。这种绑定可见性的处理方式称为词法作用域。
函数作为值
函数绑定通常只是程序中某个特定部分的名称。这种绑定定义一次,永不改变。这使得函数和其名称之间容易混淆。
但二者是不同的。函数值可以执行其他值能够做的所有操作——你可以在任意表达式中使用它,而不仅仅是调用它。可以将函数值存储在新的绑定中,作为参数传递给一个函数,等等。类似地,持有函数的绑定仍然只是一个常规绑定,如果不是常量,它可以被分配一个新值,如下所示:
let launchMissiles = function() {
missileSystem.launch("now");
};
if (safeMode) {
launchMissiles = function() {/* do nothing */};
}
在第五章中,我们将讨论通过将函数值传递给其他函数可以做的有趣事情。
声明符号
创建函数绑定有一种稍短的方式。当在语句开始时使用function关键字时,它的工作方式是不同的。
function square(x) {
return x * x;
}
这是一个函数声明。该语句定义了绑定square,并指向给定的函数。它稍微容易写一点,并且在函数后不需要分号。
这种形式的函数定义有一个细微之处。
console.log("The future says:", future());
function future() {
return "You'll never have flying cars";
}
前面的代码可以正常工作,即使函数定义在使用它的代码下面。函数声明并不是常规自上而下控制流的一部分。它们在概念上被移到作用域的顶部,可以被该作用域内的所有代码使用。这在某些情况下很有用,因为它提供了按最清晰的方式排列代码的自由,而不必担心在使用之前定义所有函数。
箭头函数
函数还有第三种表示法,它看起来与其他两种非常不同。它使用一个箭头(=>),由一个等号和一个大于号字符组成(不要与大于或等于运算符混淆,该运算符写作>=)。
const roundTo = (n, step) => {
let remainder = n % step;
return n - remainder + (remainder < step / 2 ? 0 : step);
};
箭头位于参数列表之后,并后接函数的主体。它表达了类似于“这个输入(参数)产生这个结果(主体)”的意思。
当只有一个参数名称时,可以省略参数列表周围的括号。如果主体是单个表达式而不是用大括号括起来的代码块,那么该表达式将从函数中返回。这意味着这两种对square的定义做的是同样的事情:
const square1 = (x) => { return x * x; };
const square2 = x => x * x;
当箭头函数没有任何参数时,它的参数列表只是一个空的括号。
const horn = () => {
console.log("Toot");
};
语言中同时存在箭头函数和函数表达式没有深层原因。除了一个我们将在第六章中讨论的小细节,它们做的是同样的事情。箭头函数是在 2015 年添加的,主要是为了以较少的冗长方式编写小的函数表达式。我们将在第五章中经常使用它们。
调用栈
控制在函数中的流动方式有些复杂。让我们仔细看看。以下是一个简单的程序,进行了一些函数调用:
function greet(who) {
console.log("Hello " + who);
}
greet("Harry");
console.log("Bye");
运行这个程序大致是这样的:对greet的调用使控制跳到该函数的开始(第 2 行)。该函数调用console.log,console.log接管控制,完成它的工作,然后将控制返回到第 2 行。在那里,它到达greet函数的末尾,因此返回到调用它的地方——第 4 行。之后的行再次调用console.log。在返回后,程序到达结束。
我们可以将控制流示意性地展示如下:
not in function
in greet
in console.log
in greet
not in function
in console.log
not in function
因为函数在返回时必须跳回调用它的地方,所以计算机必须记住调用发生的上下文。在一种情况下,console.log在完成时必须返回到greet函数。在另一种情况下,它返回到程序的末尾。
计算机存储这个上下文的地方是调用栈。每次调用函数时,当前上下文会被存储在这个栈的顶部。当函数返回时,它从栈中移除顶部的上下文,并使用该上下文继续执行。
存储这个栈需要计算机内存中的空间。当栈增长得太大时,计算机会出现“栈空间不足”或“递归过多”等错误消息。以下代码通过向计算机提出一个非常棘手的问题来说明这一点,这会导致两个函数之间发生无限的往返。或者说,如果计算机有无限的栈,这将是无限的。实际上,我们会耗尽空间,或者“使栈溢出”。
function chicken() {
return egg();
}
function egg() {
return chicken();
}
console.log(chicken() + " came first.");
// → ??
可选参数
以下代码是允许的,并且没有任何问题地执行:
function square(x) { return x * x; }
console.log(square(4, true, "hedgehog"));
// → 16
我们仅用一个参数定义了平方。然而,当我们用三个参数调用它时,语言并不会抱怨。它会忽略额外的参数,计算第一个参数的平方。
JavaScript对于你可以传递给函数的参数数量极为宽松。如果你传递太多,额外的参数会被忽略。如果你传递的参数太少,缺失的参数会被赋值为undefined。
这样做的缺点是,可能——甚至很可能——你会不小心向函数传递错误数量的参数。而且没有人会告诉你。好处是,你可以利用这种行为,使得一个函数可以接受不同数量的参数。例如,这个minus函数尝试模仿-运算符,可以处理一个或两个参数:
function minus(a, b) {
if (b === undefined) return -a;
else return a - b;
}
console.log(minus(10));
// → -10
console.log(minus(10, 5));
// → 5
如果在参数后写一个=运算符,后面跟一个表达式,当未提供参数时,该表达式的值将替代参数。例如,这个版本的roundTo使得它的第二个参数变为可选。如果你不提供它或传递值undefined,它将默认为1。
function roundTo(n, step = 1) {
let remainder = n % step;
return n - remainder + (remainder < step / 2 ? 0 : step);
};
console.log(roundTo(4.5));
// → 5
console.log(roundTo(4.5, 2));
// → 4
下一章将介绍一种方法,通过这种方法,函数体可以访问它所接收到的所有参数列表。这是非常有帮助的,因为它允许函数接受任意数量的参数。例如,console.log就是这样做的,输出它所接收到的所有值。
console.log("C", "O", 2);
// → C O 2
闭包
将函数视为值的能力,加上每次调用函数时局部绑定会被重新创建的事实,提出了一个有趣的问题:当创建它们的函数调用不再活跃时,局部绑定会发生什么?
以下代码展示了这个例子。它定义了一个函数wrapValue,创建了一个局部绑定。然后返回一个可以访问并返回这个局部绑定的函数。
function wrapValue(n) {
let local = n;
return () => local;
}
let wrap1 = wrapValue(1);
let wrap2 = wrapValue(2);
console.log(wrap1());
// → 1
console.log(wrap2());
// → 2
这是允许的,并且按你所希望的那样工作——两个绑定实例仍然可以被访问。这种情况很好地展示了局部绑定是为每次调用重新创建的,不同的调用不会互相影响各自的局部绑定。
这一特性——能够引用包围作用域中特定实例的局部绑定——被称为闭包。引用周围局部作用域中绑定的函数被称为闭包。这种行为不仅让你不必担心绑定的生命周期,而且使得以某种创造性的方式使用函数值成为可能。
通过稍微修改,我们可以将之前的示例转变为一种创建可以乘以任意数值的函数的方法。
function multiplier(factor) {
return number => number * factor;
}
let twice = multiplier(2);
console.log(twice(5));
// → 10
在 wrapValue 示例中的显式局部绑定其实并不是必需的,因为参数本身就是一个局部绑定。
以这种方式思考程序需要一些练习。一个好的心理模型是将函数值视为包含其主体中的代码以及创建它时的环境。当被调用时,函数主体看到的是它创建时的环境,而不是它被调用时的环境。
在这个示例中,multiplier被调用并创建了一个环境,其中其因子参数绑定为2。它返回的函数值被存储在twice中,记住了这个环境,以便在调用时将其参数乘以2。
递归
函数
function power(base, exponent) {
if (exponent == 0) {
return 1;
} else {
return base * power(base, exponent - 1);
}
}
console.log(power(2, 3));
// → 8
这与数学家对指数运算的定义非常接近,并且可以说比我们在第二章中使用的循环更清晰地描述了这一概念。该函数多次调用自身,指数越来越小,以实现重复乘法。
然而,这种实现存在一个问题:在典型的 JavaScript 实现中,它的速度大约是使用 for 循环版本的三倍慢。运行一个简单的循环通常比多次调用函数更便宜。
关于速度与优雅之间的困境是一个有趣的话题。你可以将其视为人性化和机器友好之间的一种连续体。几乎任何程序都可以通过变得更大、更复杂来加快速度。程序员需要找到一个合适的平衡。
对于幂函数而言,一个不优雅的(循环)版本仍然相当简单且易于阅读。用递归函数替代它并没有太大意义。然而,通常情况下,程序处理的概念如此复杂,以至于为了使程序更简洁而牺牲一些效率是有益的。
过于关注效率可能会分散注意力。这又是一个使程序设计复杂化的因素,当你正在做一些已经很困难的事情时,额外需要担心的事可能会让人感到无从下手。
因此,你通常应该从编写一些正确且易于理解的代码开始。如果你担心它太慢——而实际上它通常并不慢,因为大多数代码并不会频繁执行,无法占用显著的时间——你可以在之后进行测量,并在必要时进行改进。
递归并不总是仅仅是循环的一个低效替代方案。有些问题确实用递归比用循环更容易解决。通常这些是需要探索或处理多个“分支”的问题,每个分支可能又会进一步分叉。
考虑这个难题:通过从数字 1 开始,反复添加 5 或乘以 3,可以产生一个无限集合的数字。你会如何写一个函数,给定一个数字,尝试找到这样一系列的加法和乘法来生成那个数字?例如,数字 13 可以通过先乘以 3 然后加 5 两次来得到,而数字 15 则根本无法得到。
这里有一个递归解决方案:
function findSolution(target) {
function find(current, history) {
if (current == target) {
return history;
} else if (current > target) {
return null;
} else {
return find(current + 5, `(${history} + 5)`) ??
find(current * 3, `(${history} * 3)`);
}
}
return find(1, "1");
}
console.log(findSolution(24));
// → (((1 * 3) + 5) * 3)
请注意,这个程序并不一定找到操作的最短序列。它在找到任何序列时就会满足。
如果你立刻看不懂这段代码也没关系。我们一步一步来,因为这将是一个很好的递归思维练习。
内部函数 find 实际上执行递归。它接受两个参数:当前数字和一个记录我们如何到达这个数字的字符串。如果它找到了解决方案,它会返回一个显示如何达到目标的字符串。如果从这个数字出发找不到解决方案,它会返回null。
为此,该函数执行三种操作之一。如果当前数字是目标数字,那么当前历史就是达到该目标的方式,因此会返回。如果当前数字大于目标,继续探索这个分支就没有意义,因为加法和乘法只会使数字变大,因此返回null。最后,如果我们仍然低于目标数字,函数会通过调用自身两次尝试从当前数字开始的两个可能路径,一次用于加法,一次用于乘法。如果第一次调用返回的结果不是null,那么返回它。否则,返回第二次调用的结果,无论它是否生成字符串或null。
为了更好地理解这个函数是如何产生我们所期望的效果的,让我们查看在搜索数字 13 的解决方案时所进行的所有对 find 的调用。
find(1, "1")
find(6, "(1 + 5)")
find(11, "((1 + 5) + 5)")
find(16, "(((1 + 5) + 5) + 5)")
too big
find(33, "(((1 + 5) + 5) * 3)")
too big
find(18, "((1 + 5) * 3)")
too big
find(3, "(1 * 3)")
find(8, "((1 * 3) + 5)")
find(13, "(((1 * 3) + 5) + 5)")
found!
缩进表示调用栈的深度。第一次调用 find 时,函数开始通过调用自身来探索以(1 + 5)开头的解决方案。该调用将进一步递归,以探索每个导致小于或等于目标数字的持续解决方案。由于没有找到一个正好命中的目标,因此返回null回到第一次调用。在那里,??运算符导致探索(1 * 3)的调用发生。这个搜索更幸运——它的第一个递归调用,通过又一个递归调用,找到了目标数字。最内层的调用返回一个字符串,而中间调用中的每个??运算符将该字符串传递下去,最终返回解决方案。
增长函数
函数引入程序的方式有两种或多种自然的方法。
第一个情况发生在你发现自己多次编写类似代码时。你会更希望不这样做,因为代码越多,隐藏错误的空间就越大,尝试理解程序的人需要阅读的材料也就越多。因此,你提取出重复的功能,为其找到一个好名字,并将其放入一个函数中。
第二种方法是你发现需要一些你尚未编写的功能,这听起来应该有自己的函数。你首先给函数命名,然后编写它的主体。在实际定义函数之前,你甚至可能会开始编写使用该函数的代码。
找到一个好名字为函数命名的难易程度,很好地指示了你试图封装的概念有多清晰。让我们通过一个例子来说明。
我们想写一个程序,打印两个数字:农场上牛和鸡的数量,后面跟上“Cows”和“Chickens”这两个词,并在这两个数字前面填充零,使其始终为三位数。
007 Cows
011 Chickens
这要求一个有两个参数的函数——牛的数量和鸡的数量。让我们开始编码吧。
function printFarmInventory(cows, chickens) {
let cowString = String(cows);
while (cowString.length < 3) {
cowString = "0" + cowString;
}
console.log(`${cowString} Cows`);
let chickenString = String(chickens);
while (chickenString.length < 3) {
chickenString = "0" + chickenString;
}
console.log(`${chickenString} Chickens`);
}
printFarmInventory(7, 11);
在字符串表达式后面写.length会给我们该字符串的长度。因此,while循环不断在数字字符串前面添加零,直到它们至少有三个字符长。
任务完成!但就在我们准备将代码(以及一份高额账单)发送给农民时,她打电话告诉我们她也开始养猪了,能不能请我们扩展软件,以便也能打印猪的数量?
我们当然可以。但是就在我们准备再复制粘贴那四行代码时,我们停下来重新考虑。这一定有更好的方法。下面是第一次尝试:
function printZeroPaddedWithLabel(number, label) {
let numberString = String(number);
while (numberString.length < 3) {
numberString = "0" + numberString;
}
console.log(`${numberString} ${label}`);
}
function printFarmInventory(cows, chickens, pigs) {
printZeroPaddedWithLabel(cows, "Cows");
printZeroPaddedWithLabel(chickens, "Chickens");
printZeroPaddedWithLabel(pigs, "Pigs");
}
printFarmInventory(7, 11, 3);
它成功了!但是这个名字printZeroPaddedWithLabel有点尴尬。它将打印、零填充和添加标签这三件事混为一谈,放入了一个函数中。
与其将程序中重复的部分整体提取出来,不如试着挑出一个单一的概念。
function zeroPad(number, width) {
let string = String(number);
while (string.length < width) {
string = "0" + string;
}
return string;
}
function printFarmInventory(cows, chickens, pigs) {
console.log(`${zeroPad(cows, 3)} Cows`);
console.log(`${zeroPad(chickens, 3)} Chickens`);
console.log(`${zeroPad(pigs, 3)} Pigs`);
}
printFarmInventory(7, 16, 3);
一个名字清晰明显的函数,如zeroPad,让阅读代码的人更容易理解其作用。这样的函数在比这个特定程序更多的场景中也很有用。例如,你可以使用它来帮助打印对齐整齐的数字表。
我们的函数应该有多聪明和多功能?我们可以编写任何东西,从只能将数字填充为三个字符宽的简单函数,到一个复杂的通用数字格式化系统,能够处理分数、负数、对齐小数点、用不同字符填充等等。
一个有用的原则是,除非你绝对确定需要,否则不要添加聪明的功能。为你遇到的每一项功能编写通用的“框架”可能会让人心动,但要抵制这种冲动。你不会真正完成任何工作——你会忙于编写从未使用的代码。
函数与副作用
函数大致可以分为两类:一类是由于其副作用而被调用,另一类是由于其返回值而被调用(尽管也可能同时具有副作用和返回值)。
在农场示例中,第一个辅助函数printZeroPaddedWithLabel因其副作用而被调用:它打印一行。第二个版本zeroPad因其返回值而被调用。第二个函数在更多情况下有用并非偶然。创建值的函数比直接执行副作用的函数更容易以新的方式组合。
一个纯函数是一种特定类型的值生成函数,它不仅没有副作用,而且不依赖于其他代码的副作用——例如,它不读取可能会改变值的全局绑定。一个纯函数有一个愉快的特性,即在使用相同的参数调用时,它总是产生相同的值(而且不做其他事情)。对这样的函数的调用可以用它的返回值替代,而不改变代码的含义。当你不确定一个纯函数是否正确工作时,可以通过简单地调用它来测试,如果它在那个上下文中工作,那么在任何上下文中都将工作。非纯函数往往需要更多的支架来进行测试。
不必感到羞愧,当你编写不是纯函数的函数时。副作用往往是有用的。例如,console.log的纯版本是无法编写的,但console.log是非常有用的。在某些操作中,当我们使用副作用时,表达起来也更高效。
总结
本章教你如何编写自己的函数。当作为表达式使用时,function关键字可以创建一个函数值。当作为语句使用时,它可以用于声明一个绑定,并将函数作为其值。箭头函数是创建函数的另一种方式。
// Define f to hold a function value
const f = function(a) {
console.log(a + 2);
};
// Declare g to be a function
function g(a, b) {
return a * b * 3.5;
}
// A less verbose function value
let h = a => a % 3;
理解函数的一个关键部分是理解作用域。每个代码块都会创建一个新的作用域。在给定作用域中声明的参数和绑定是局部的,外部不可见。用var声明的绑定行为有所不同——它们会进入最近的函数作用域或全局作用域。
将程序执行的任务分离成不同的函数是有帮助的。你将不必如此频繁地重复自己,函数可以通过将代码分组为执行特定任务的片段来帮助组织程序。
练习
最小值
前一章介绍了标准函数Math.min,它返回最小的参数。我们现在可以自己编写这样的函数。定义一个名为min的函数,它接受两个参数并返回它们的最小值。
递归
我们已经看到可以使用%(余数运算符)来测试一个数字是偶数还是奇数,通过使用% 2来查看它是否可以被二整除。这里还有另一种方法来定义一个正整数是偶数还是奇数:
-
零是偶数。
-
一是奇数。 -
对于任何其他数字
N,其偶性与N - 2相同。
定义一个递归函数isEven,符合这个描述。该函数应该接受一个单一参数(一个正整数)并返回一个布尔值。
在50和75上测试一下。看看它在-1上的表现。为什么?你能想到修复这个问题的方法吗?
豆子计数
你可以通过在字符串后写[N]来获取字符串中的第N个字符或字母(例如,string[2])。得到的值将是一个只包含一个字符的字符串(例如,“b”)。第一个字符的位置是0,这导致最后一个字符位于string.length - 1的位置。换句话说,一个两个字符的字符串长度为2,其字符的位置分别为0和1。
编写一个名为countBs的函数,它以字符串作为唯一参数并返回一个数字,表示字符串中大写字母B的数量。
接下来,编写一个名为countChar的函数,行为类似于countBs,但它接受一个第二个参数,表示要计数的字符(而不仅仅是计数大写字母B)。重写countBs以利用这个新函数。
在两个场合,我被问到:“请问,巴贝奇先生,如果你输入错误的数字,机器会输出正确的答案吗?” [. . .] 我无法正确理解会引发这样问题的那种思想混乱。
—查尔斯·巴贝奇,哲学家生平的片段(1864)
<https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0056-01.jpg>
第五章:数据结构:对象和数组
数字、布尔值和字符串是构建数据结构的基本元素。然而,许多类型的信息需要多个元素。对象使我们能够将值(包括其他对象)分组,以构建更复杂的结构。
到目前为止,我们构建的程序受到限制,因为它们只处理简单数据类型。在本章学习了数据结构的基础知识后,你将足够了解如何开始编写有用的程序。
本章将通过一个或多或少现实的编程示例,逐步介绍相关概念。示例代码通常会基于本书前面介绍的函数和绑定进行构建。
本书的在线编码沙箱(*eloquentjavascript.net/code)提供了一种在特定章节上下文中运行代码的方法。如果你决定在其他环境中进行示例练习,请务必首先从沙箱页面下载本章的完整代码。
变成松鼠
不时,通常在晚上8点到10点之间,雅克发现自己变成了一只小毛茸茸的啮齿动物,长着蓬松的尾巴。
一方面,雅克很高兴自己并没有经典的狼人症。变成松鼠比变成狼所造成的问题要少。他不必担心意外吃掉邻居(那样就尴尬了
但是雅克更希望完全摆脱这种状态。转变的不规则发生让他怀疑它们可能是由某种因素触发的。起初,他认为只有在靠近橡树的日子才会发生这种情况。然而,避开橡树并没有解决问题。
切换到更科学的方法,雅克开始记录他每天所做的事情以及是否发生了变化。凭借这些数据,他希望缩小触发转变的条件。
他需要的第一件事是一个数据结构来存储这些信息。
数据集
要处理一块数字数据,我们首先需要找到一种在计算机内存中表示它的方法。假设我们想表示一组数字:2、3、5、7和11。
我们可以用字符串来创造性地表示——毕竟,字符串可以有任意长度,因此我们可以将大量数据放入其中,并使用"2 3 5 7 11"作为我们的表示。但这样做很尴尬。我们必须以某种方式提取数字并将其转换回数字才能访问它们。
幸运的是,JavaScript 提供了一种特定的数据类型,用于存储值的序列。它被称为数组,并以方括号中的值列表表示,值之间用逗号分隔。
let listOfNumbers = [2, 3, 5, 7, 11];
console.log(listOfNumbers[2]);
// → 5
console.log(listOfNumbers[0]);
// → 2
console.log(listOfNumbers[2 - 1]);
// → 3
获取数组内部元素的表示法也使用方括号。紧随表达式之后的一对方括号,内部包含另一个表达式,将查找左侧表达式中与方括号中给出的索引对应的元素。
数组的第一个索引是零,而不是一,因此第一个元素可以通过listOfNumbers[0]来获取。零基计数在技术中有着悠久的传统,并在某些方面很有道理,但这需要一些适应。可以将索引视为从数组开始处跳过的项数。
属性
在之前的章节中,我们见过一些表达式,比如myString.length(获取字符串的长度)和Math.max(最大函数)。这些表达式访问某个值的属性。在第一个例子中,我们访问myString中值的长度属性。在第二个例子中,我们访问Math对象中的名为max的属性(它是一个与数学相关的常量和函数的集合)。
几乎所有 JavaScript 值都有属性。例外的是null和undefined。如果你试图访问这些非值之一的属性,你会得到一个错误。
null.length;
// → TypeError: null has no properties
在 JavaScript 中访问属性的两种主要方式是使用点和方括号。value.x和value[x]都访问value上的一个属性——但不一定是同一个属性。两者的区别在于x的解释。当使用点时,点后的单词是属性的字面名称。当使用方括号时,括号内的表达式会被评估以获取属性名称。value.x获取名为"x"的value属性,而value[x]则获取名为x的变量的值,并将其转换为字符串作为属性名称。
如果你知道你感兴趣的属性名为color,那么你可以使用value.color。如果你想提取由绑定i中的值命名的属性,你可以使用value[i]。属性名称是字符串。它们可以是任何字符串,但点表示法仅对看起来像有效绑定名称的名称有效——以字母或下划线开头,仅包含字母、数字和下划线。如果你想访问名为2或John Doe的属性,你必须使用方括号:value[2]或value["John Doe"]。
数组中的元素作为数组的属性存储,使用数字作为属性名。因为你不能用点符号与数字一起使用,并且通常想要使用一个绑定来持有索引,所以你必须使用括号表示法来访问它们。
就像字符串一样,数组也有一个长度属性,用于告诉我们数组中有多少个元素。
方法
字符串和数组值除了length属性外,还包含多个保存函数值的属性。
let doh = "Doh";
console.log(typeof doh.toUpperCase);
// → function
console.log(doh.toUpperCase());
// → DOH
每个字符串都有一个toUpperCase属性。当调用时,它将返回一个副本,其中所有字母都被转换为大写。还有一个toLowerCase,反向操作。
有趣的是,尽管对toUpperCase的调用没有传递任何参数,但这个函数以某种方式可以访问字符串"Doh",这是我们调用其属性的值。你将在第六章中了解这如何运作。
包含函数的属性通常被称为它们所属值的方法,比如“toUpperCase是字符串的方法。”
这个示例演示了两种可以用来操作数组的方法。
let sequence = [1, 2, 3];
sequence.push(4);
sequence.push(5);
console.log(sequence);
// → [1, 2, 3, 4, 5]
console.log(sequence.pop());
// → 5
console.log(sequence);
// → [1, 2, 3, 4]
push方法将值添加到数组的末尾。pop方法则相反,移除数组中的最后一个值并返回它。
这些有点傻的名称是对栈操作的传统术语。在编程中,栈是一种数据结构,允许你将值推入其中并按相反顺序弹出它们,因此最后添加的东西最先被移除。栈在编程中很常见——你可能还记得上一章中的函数调用栈,它是同一思想的一个实例。
对象
回到人狼松鼠。每日日志条目可以表示为一个数组,但条目不仅仅由数字或字符串组成——每个条目需要存储一系列活动和一个布尔值,以指示雅克是否变成了松鼠。理想情况下,我们希望将这些信息组合成一个单一的值,然后将这些组合值放入日志条目的数组中。
类型为object的值是任意属性的集合。创建对象的一种方法是将花括号作为表达式使用。
let day1 = {
squirrel: false,
events: ["work", "touched tree", "pizza", "running"]
};
console.log(day1.squirrel);
// → false
console.log(day1.wolf);
// → undefined
day1.wolf = false;
console.log(day1.wolf);
// → false
在花括号内,你写一个用逗号分隔的属性列表。每个属性都有一个名称,后面跟着一个冒号和一个值。当一个对象分多行书写时,像这个示例中那样缩进有助于可读性。名称不是有效绑定名称或有效数字的属性必须加上引号。
let descriptions = {
work: "Went to work",
"touched tree": "Touched a tree"
};
这意味着花括号在 JavaScript 中有两种含义。在语句的开始,它们开始一个语句块。在其他位置,它们描述一个对象。幸运的是,几乎没有必要在语句开头使用花括号中的对象,因此这两者之间的歧义并不是问题。唯一的例外是,当你想从简写箭头函数中返回一个对象时——你不能写n => {prop: n},因为花括号会被解释为函数体。相反,你必须在对象周围加一组括号,以明确它是一个表达式。
读取一个不存在的属性将返回值undefined。
可以使用=运算符将值分配给属性表达式。如果属性已存在,这将替换该属性的值;如果不存在,则在对象上创建一个新属性。
简要回到我们的触手绑定模型——属性绑定是类似的。它们抓取值,但其他绑定和属性可能会握住那些相同的值。你可以将对象视为具有任意数量触手的章鱼,每个触手上都有一个名字。
delete运算符就像切断章鱼的触手。它是一个一元运算符,当应用于对象属性时,会从对象中移除指定属性。这并不是常见的操作,但它是可能的。
let anObject = {left: 1, right: 2};
console.log(anObject.left);
// → 1
delete anObject.left;
console.log(anObject.left);
// → undefined
console.log("left" in anObject);
// → false
console.log("right" in anObject);
// → true
二元的in运算符,当应用于字符串和对象时,会告诉你该对象是否具有该名称的属性。将属性设置为undefined和实际删除它之间的区别在于,在第一种情况下,对象仍然拥有该属性(它只是不具有非常有趣的值),而在第二种情况下,该属性不再存在,因此返回false。
要找出一个对象有哪些属性,可以使用Object.keys函数。给这个函数一个对象,它将返回一个字符串数组——对象的属性名称。
console.log(Object.keys({x: 0, y: 0, z: 2}));
// → ["x", "y", "z"]
有一个Object.assign函数,可以将一个对象的所有属性复制到另一个对象中。
let objectA = {a: 1, b: 2};
Object.assign(objectA, {b: 3, c: 4});
console.log(objectA);
// → {a: 1, b: 3, c: 4}
数组本质上是一种专门用于存储事物序列的对象。如果你评估typeof [],它会返回"object"。你可以将数组视为长长的、扁平的章鱼,所有的触手整齐排列,标记上数字。
雅克将他保持的日记表示为对象数组。
let journal = [
{events: ["work", "touched tree", "pizza",
"running", "television"],
squirrel: false},
{events: ["work", "ice cream", "cauliflower",
"lasagna", "touched tree", "brushed teeth"],
squirrel: false},
{events: ["weekend", "cycling", "break", "peanuts",
"beer"],
squirrel: true},
/* And so on... */
];
可变性
我们很快就会进入实际编程,但首先,还有一个理论部分需要理解。
我们看到对象值是可以修改的。前几章讨论的值类型,如数字、字符串和布尔值,都是不可变的——无法更改这些类型的值。你可以将它们组合并推导出新值,但当你获得一个特定的字符串值时,该值将始终保持不变。它内部的文本无法更改。如果你有一个包含"cat"的字符串,其他代码无法更改你的字符串中的某个字符使其拼成"rat"。
对象的工作方式不同。你可以更改它们的属性,导致同一个对象值在不同时间具有不同的内容。
当我们有两个数字,120和120时,我们可以将它们视为完全相同的数字,无论它们是否引用相同的物理位。对于对象来说,拥有对同一对象的两个引用与拥有两个不同的对象(它们包含相同的属性)之间是有区别的。考虑以下代码:
let object1 = {value: 10};
let object2 = object1;
let object3 = {value: 10};
console.log(object1 == object2);
// → true
console.log(object1 == object3);
// → false
object1.value = 15;
console.log(object2.value);
// → 15
console.log(object3.value);
// → 10
object1和object2绑定指向同一个对象,这就是为什么更改object1也会更改object2的值。它们被称为具有相同的身份。绑定object3指向一个不同的对象,最初包含与object1相同的属性,但过着独立的生活。
绑定可以是可变的或常量的,但这与它们的值的行为是分开的。尽管数字值不会改变,你可以使用let绑定来跟踪一个通过改变绑定指向的值而变化的数字。同样,尽管const绑定的对象本身无法更改并将继续指向同一对象,但该对象的内容可能会发生变化。
const score = {visitors: 0, home: 0};
// This is OK
score.visitors = 1;
// This isn't allowed
score = {visitors: 1, home: 1};
当你使用 JavaScript 的==运算符比较对象时,它按身份进行比较:只有当两个对象的值完全相同时,它才会返回true。比较不同的对象将返回false,即使它们具有相同的属性。JavaScript 中没有内置的“深度”比较操作,可以按内容比较对象,但你可以自己编写这个操作(这也是本章末尾的一个练习)。
《狼人日志》
雅克启动了他的 JavaScript 解释器,并设置了他记录日记所需的环境。
let journal = [];
function addEntry(events, squirrel) {
journal.push({events, squirrel});
}
注意,添加到日记中的对象看起来有点奇怪。它不是像events: events这样声明属性,而只是给出了一个属性名称:events。这是一种简写方式,意思是相同的——如果大括号表示法中的属性名称后面没有值,则它的值来自同名的绑定。
每天晚上10点——有时是第二天早晨,从书架的顶层下来的时候——雅克会记录当天的事情。
addEntry(["work", "touched tree", "pizza", "running",
"television"], false);
addEntry(["work", "ice cream", "cauliflower", "lasagna",
"touched tree", "brushed teeth"], false);
addEntry(["weekend", "cycling", "break", "peanuts",
"beer"], true);
一旦他收集到足够的数据点,他打算利用统计学找出哪些事件可能与松鼠化相关。
相关性是统计变量之间依赖关系的度量。统计变量与编程变量并不完全相同。在统计学中,通常有一组测量值,每个变量针对每个测量值进行测量。变量之间的相关性通常以一个介于–1和1之间的值表示。零相关性意味着变量之间没有关系。相关性为1表示两者完全相关——如果你知道一个,你也知道另一个。负1也意味着变量之间完全相关,但它们是相反的——当一个为真时,另一个为假。
要计算两个布尔变量之间的相关性度量,我们可以使用phi 系数(φ)。这是一个公式,其输入是一个频率表,包含不同变量组合被观察到的次数。公式的输出是一个介于–1和1之间的数字,用于描述相关性。
我们可以将吃披萨的事件放入一个频率表中,每个数字表示这种组合在我们的测量中出现的次数。
<https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0065-01.jpg>
如果我们称该表为n,我们可以使用以下公式计算φ:
<https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0065-02.jpg>
(如果在这一点上你放下书本,专注于对十年级数学课的可怕回忆——等等!我并不打算用无尽的神秘符号折磨你——现在就只有这个公式。即便如此,我们所做的只是把它转化为 JavaScript。)
表示法n[01]表示测量次数,其中第一个变量(松鼠性)为假(0),第二个变量(披萨)为真(1)。在披萨表中,n[01]是9。
值n[1•]指的是第一个变量为真时所有测量的总和,在示例表中是5。同样,n[•0]指的是第二个变量为假时的测量总和。
所以对于披萨表,分割线以上的部分(被除数)是1 × 76 − 4 × 9 = 40,而以下的部分(除数)是5 × 85 × 10 × 80的平方根,或者 <https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0065-03.jpg>。结果是φ ≈ 0.069,这个值很小。吃披萨似乎对变换没有影响。
计算相关性
我们可以用一个四元素数组在 JavaScript 中表示一个二乘二表([76, 9, 4, 1])。我们也可以使用其他表示方式,例如包含两个二元素数组的数组 ([[76, 9], [4, 1]]),或者属性名为"11"和"01"的对象,但平面数组简单且访问表的表达式简洁明了。我们将数组的索引解释为两位二进制数,其中最左边(最重要)的数字指的是松鼠变量,最右边(最不重要)的数字指的是事件变量。例如,二进制数10指的是Jacques变成松鼠但事件(比如"披萨")没有发生的情况。这种情况发生了四次。由于二进制10在十进制中是2,因此我们将在数组的索引2存储这个数字。
这是一个计算此类数组中φ系数的函数:
function phi(table) {
return (table[3] * table[0] - table[2] * table[1]) /
Math.sqrt((table[2] + table[3]) *
(table[0] + table[1]) *
(table[1] + table[3]) *
(table[0] + table[2]));
}
console.log(phi([76, 9, 4, 1]));
// → 0.068599434
这是φ公式直接转化为 JavaScript 的形式。Math.sqrt是平方根函数,由标准 JavaScript 环境中的Math对象提供。我们必须从表中添加两个字段以获取n[1•]这样的字段,因为行或列的总和并没有直接存储在我们的数据结构中。
Jacques记录他的日记三个月。生成的数据集可以在本章的编码沙盒中找到 (*[eloquentjavascript.net/code#4](https://eloquentjavascript.net/code#4)*),存储在JOURNAL`绑定中,并且可以下载。
要从日志中提取特定事件的二维表格,我们必须遍历所有条目,并统计该事件与松鼠变身相关的出现次数。
function tableFor(event, journal) {
let table = [0, 0, 0, 0];
for (let i = 0; i < journal.length; i++) {
let entry = journal[i], index = 0;
if (entry.events.includes(event)) index += 1;
if (entry.squirrel) index += 2;
table[index] += 1;
}
return table;
}
console.log(tableFor("pizza", JOURNAL));
// → [76, 9, 4, 1]
数组有一个includes方法,可以检查给定值是否存在于数组中。该函数利用这个方法来判断它感兴趣的事件名称是否属于某一天的事件列表。
tableFor循环的主体通过检查每个日志条目是否包含感兴趣的特定事件,以及该事件是否发生在与松鼠事件同时,来确定每个日志条目对应表格中的哪个框。然后,循环会将1加到表格中的正确框内。
现在我们拥有计算个别相关性的工具。剩下的唯一一步是为记录的每种事件类型找到相关性,并查看是否有什么特别之处。
数组循环
在tableFor函数中,有一个这样的循环:
for (let i = 0; i < JOURNAL.length; i++) {
let entry = JOURNAL[i];
// Do something with entry
}
这种循环在经典 JavaScript 中很常见——逐个遍历数组元素是一种常见操作,为此,你需要在数组的长度上运行一个计数器,并依次挑选出每个元素。
在现代 JavaScript 中,有一种更简单的方法来编写这样的循环。
for (let entry of JOURNAL) {
console.log(`${entry.events.length} events.`);
}
当for循环在变量定义后使用of这个词时,它将遍历在of后面给出的值中的元素。这不仅适用于数组,也适用于字符串和其他一些数据结构。我们将在第六章中讨论它是如何工作的。
最终分析
我们需要为数据集中发生的每种事件类型计算相关性。为此,我们首先需要找到每种事件类型。
function journalEvents(journal) {
let events = [];
for (let entry of journal) {
for (let event of entry.events) {
if (!events.includes(event)) {
events.push(event);
}
}
}
return events;
}
console.log(journalEvents(JOURNAL));
// → ["carrot", "exercise", "weekend", "bread", ...]
通过将任何不在其中的事件名称添加到事件数组中,该函数收集每种类型的事件。
使用那个函数,我们可以看到所有的相关性。
for (let event of journalEvents(JOURNAL)) {
console.log(event + ":", phi(tableFor(event, JOURNAL)));
}
// → carrot: 0.0140970969
// → exercise: 0.0685994341
// → weekend: 0.1371988681
// → bread: -0.0757554019
// → pudding: -0.0648203724
// And so on...
大多数相关性似乎接近零。吃胡萝卜、面包或布丁显然不会引发松鼠变身。变身似乎在周末更常发生。让我们过滤结果,只显示相关性大于0.1或小于-0.1的情况。
for (let event of journalEvents(JOURNAL)) {
let correlation = phi(tableFor(event, JOURNAL));
if (correlation > 0.1 || correlation < -0.1) {
console.log(event + ":", correlation);
}
}
// → weekend: 0.1371988681
// → brushed teeth: -0.3805211953
// → candy: 0.1296407447
// → work: -0.1371988681
// → spaghetti: 0.2425356250
// → reading: 0.1106828054
// → peanuts: 0.5902679812
啊哈!有两个因素的相关性明显强于其他因素。吃花生对变成松鼠的几率有很强的正面影响,而刷牙则有显著的负面影响。
有趣。让我们试试:
for (let entry of JOURNAL) {
if (entry.events.includes("peanuts") &&
!entry.events.includes("brushed teeth")) {
entry.events.push("peanut teeth");
}
}
console.log(phi(tableFor("peanut teeth", JOURNAL)));
// → 1
这是一个强烈的结果。这种现象恰恰发生在雅克吃花生而没刷牙的时候。如果他在牙齿卫生方面不是如此邋遢,他甚至可能不会注意到自己的病症。
知道这一点后,雅克完全不再吃花生,发现他的变身停止了。
但他只花了几个月就注意到,这种完全人类的生活方式缺少了一些东西。没有他的野外冒险,雅克几乎感觉不到生命的存在。他决定宁愿成为一只全职的野生动物。在森林中建造一个漂亮的小树屋,并配备一个花生酱分配器和十年的花生酱供应后,他最后一次改变形态,过上了短暂而充满活力的松鼠生活。
深入数组学
在结束这一章之前,我想向你介绍几个与对象相关的概念。我将从一些普遍有用的数组方法开始。
我们在本章早些时候看到了push和pop,它们分别在数组的末尾添加和移除元素。对应于在数组开头添加和移除元素的方法称为unshift和shift。
let todoList = [];
function remember(task) {
todoList.push(task);
}
function getTask() {
return todoList.shift();
}
function rememberUrgently(task) {
todoList.unshift(task);
}
这个程序管理一个任务队列。你通过调用remember("groceries")将任务添加到队列的末尾,当你准备好执行某个任务时,你可以调用getTask()从队列中获取(并移除)最前面的项目。rememberUrgently函数也添加一个任务,但它将任务添加到队列的前面,而不是后面。
要搜索特定值,数组提供了indexOf方法。该方法从数组的开始到结束进行搜索,并返回请求值找到时的索引——如果未找到则返回-1。要从末尾而不是从开头进行搜索,还有一个类似的方法叫做lastIndexOf。
console.log([1, 2, 3, 2, 1].indexOf(2));
// → 1
console.log([1, 2, 3, 2, 1].lastIndexOf(2));
// → 3
indexOf和lastIndexOf都接受一个可选的第二个参数,用于指示从哪里开始搜索。
另一个基本的数组方法是slice,它接受起始和结束索引,并返回一个只包含它们之间元素的数组。起始索引是包含的,结束索引是不包含的。
console.log([0, 1, 2, 3, 4].slice(2, 4));
// → [2, 3]
console.log([0, 1, 2, 3, 4].slice(2));
// → [2, 3, 4]
当没有提供结束索引时,切片将获取起始索引之后的所有元素。你也可以省略起始索引,以复制整个数组。
concat方法可用于将数组连接在一起,以创建一个新的数组,这与+运算符对字符串的作用类似。
以下示例展示了concat和slice的实际应用。它接受一个数组和一个索引,并返回一个新的数组,该数组是原始数组的副本,给定索引处的元素被移除。
function remove(array, index) {
return array.slice(0, index)
.concat(array.slice(index + 1));
}
console.log(remove(["a", "b", "c", "d", "e"], 2));
// → ["a", "b", "d", "e"]
如果你向concat传递一个不是数组的参数,该值将被当作一个单元素数组添加到新数组中。
字符串及其属性
我们可以从字符串值中读取像length和toUpperCase这样的属性。但是如果我们尝试添加一个新属性,它不会生效。
let kim = "Kim";
kim.age = 88;
console.log(kim.age);
// → undefined
字符串、数字和布尔值的值不是对象,尽管语言不会抱怨你尝试在它们上设置新属性,但实际上并不会存储这些属性。如前所述,这些值是不可变的,无法更改。
但这些类型确实有内置属性。每个字符串值都有许多方法。一些非常有用的方法是slice和indexOf,它们与数组的同名方法类似。
console.log("coconuts".slice(4, 7));
// → nut
console.log("coconut".indexOf("u"));
// → 5
一个不同之处在于字符串的indexOf可以搜索包含多个字符的字符串,而对应的数组方法仅查找单个元素。
console.log("one two three".indexOf("ee"));
// → 11
trim方法从字符串的开始和结束移除空白字符(空格、换行符、制表符及类似字符)。
console.log(" okay \n ".trim());
// → okay
上一章中的zeroPad函数也作为一个方法存在。它被称为padStart,并接受所需的长度和填充字符作为参数。
console.log(String(6).padStart(3, "0"));
// → 006
你可以通过split在每个出现的另一个字符串上拆分一个字符串,然后用join再次连接它。
let sentence = "Secretarybirds specialize in stomping";
let words = sentence.split(" ");
console.log(words);
// → ["Secretarybirds", "specialize", "in", "stomping"]
console.log(words.join(". "));
// → Secretarybirds. specialize. in. stomping
字符串可以通过repeat方法重复,它创建一个包含多个原始字符串副本的新字符串,将它们粘合在一起。
console.log("LA".repeat(3));
// → LALALA
我们已经看到字符串类型的长度属性。访问字符串中的单个字符看起来就像访问数组元素(有一个复杂性,我们将在第五章中讨论)。
let string = "abc";
console.log(string.length);
// → 3
console.log(string[1]);
// → b
剩余参数
对于一个函数接受任意数量的参数是非常有用的。例如,Math.max计算它所给的所有参数中的最大值。要编写这样的函数,你在函数最后一个参数前加上三个点,如下所示:
function max(...numbers) {
let result = -Infinity;
for (let number of numbers) {
if (number > result) result = number;
}
return result;
}
console.log(max(4, 1, 9, -2));
// → 9
当调用这样的函数时,剩余参数被绑定到一个包含所有后续参数的数组。如果它之前有其他参数,它们的值不属于那个数组。当它像max一样是唯一的参数时,它将包含所有参数。
你可以使用类似的三个点符号来调用一个带有参数数组的函数。
let numbers = [5, 1, 7];
console.log(max(...numbers));
// → 7
这会将数组“展开”到函数调用中,将其元素作为单独的参数传递。可以将这样的数组与其他参数一起包含,例如max(9, ...numbers, 2)。
方括号数组符号同样允许三点操作符将另一个数组展开到新数组中。
let words = ["never", "fully"];
console.log(["will", ...words, "understand"]);
// → ["will", "never", "fully", "understand"]
这在花括号对象中也有效,它将另一个对象的所有属性添加进来。如果一个属性被多次添加,最后添加的值将胜出。
let coordinates = {x: 10, y: 0};
console.log({...coordinates, y: 5, z: 1});
// → {x: 10, y: 5, z: 1}
Math对象
正如我们所见,Math是一个包含数字相关实用函数的集合,例如Math.max(最大值)、Math.min(最小值)和Math.sqrt(平方根)。
Math对象被用作一个容器,以分组一堆相关的功能。只有一个Math对象,它几乎从来没有作为一个值有用。相反,它提供了一个命名空间,使所有这些函数和值不必是全局绑定。
过多的全局绑定会“污染”命名空间。被占用的名称越多,你越可能意外覆盖某些现有绑定的值。例如,在你的某个程序中,你可能会想将某个东西命名为max。由于JavaScript内置的max函数安全地嵌套在Math对象中,你不必担心覆盖它。
许多语言会阻止你,或者至少会警告你,当你定义一个已经被占用的名称的绑定时。JavaScript对使用let或const声明的绑定这样做,但——反而——对标准绑定或使用var或function声明的绑定不这样做。
回到Math对象。如果你需要进行三角函数运算,Math可以提供帮助。它包含cos(余弦)、sin(正弦)和tan(正切),以及它们的反函数acos、asin和atan。数字π(圆周率)——或者至少是适合JavaScript数字的最接近的近似值——可以通过Math.PI获取。旧有的编程传统是将常量的名称全部用大写字母书写。
function randomPointOnCircle(radius) {
let angle = Math.random() * 2 * Math.PI;
return {x: radius * Math.cos(angle),
y: radius * Math.sin(angle)};
}
console.log(randomPointOnCircle(2));
// → {x: 0.3667, y: 1.966}
如果你对正弦和余弦不熟悉,别担心。我会在第十四章中解释它们的用法。
上一个例子使用了Math.random。这是一个每次调用都会返回一个在0(包含)和1(不包含)之间的新伪随机数的函数。
console.log(Math.random());
// → 0.36993729369714856
console.log(Math.random());
// → 0.727367032552138
console.log(Math.random());
// → 0.40180766698904335
尽管计算机是确定性的机器——如果给定相同的输入,它们总是以相同的方式反应——但它们可以生成看似随机的数字。为此,计算机保持一些隐藏值,每当你请求一个新的随机数时,它会对这个隐藏值进行复杂的计算以生成新值。它存储一个新值并返回从中派生出的某个数字。这样,它就可以以看似随机的方式生成不断新的、难以预测的数字。
如果我们想要一个整数随机数而不是小数,我们可以对Math.random的结果使用Math.floor(向下舍入到最接近的整数)。
console.log(Math.floor(Math.random() * 10));
// → 2
将随机数乘以10会得到一个大于或等于0并且小于10的数字。由于Math.floor向下舍入,这个表达式将以相等的概率生成0到9之间的任何数字。
还有一些函数,如Math.ceil(“向上取整”,将数字向上舍入到最接近的整数)、Math.round(舍入到最接近的整数)和Math.abs,它返回一个数的绝对值,这意味着它将负值取反,但保持正值不变。
解构
让我们暂时回到phi函数。
function phi(table) {
return (table[3] * table[0] - table[2] * table[1]) /
Math.sqrt((table[2] + table[3]) *
(table[0] + table[1]) *
(table[1] + table[3]) *
(table[0] + table[2]));
}
这个函数难以阅读的一个原因是我们有一个指向数组的绑定,但我们更希望有指向数组元素的绑定——也就是说,let n00 = table[0]等等。幸运的是,JavaScript中有一种简洁的方法可以做到这一点。
function phi([n00, n01, n10, n11]) {
return (n11 * n00 - n10 * n01) /
Math.sqrt((n10 + n11) * (n00 + n01) *
(n01 + n11) * (n00 + n10));
}
这对于使用let、var或const创建的绑定同样有效。如果你知道你正在绑定的值是一个数组,你可以使用方括号“查看”该值的内部,绑定其内容。
对于对象,可以使用花括号而不是方括号进行类似的操作。
let {name} = {name: "Faraji", age: 23};
console.log(name);
// → Faraji
请注意,如果你尝试解构null或undefined,你会得到一个错误,就像你直接尝试访问这些值的属性时一样。
可选属性访问
当你不确定某个值是否会生成对象,但仍然希望在它确实生成对象时读取其属性时,可以使用一种点表示法的变体:object?.property。
function city(object) {
return object.address?.city;
}
console.log(city({address: {city: "Toronto"}}));
// → Toronto
console.log(city({name: "Vera"}));
// → undefined
表达式a?.b与a.b的意思相同,前提是a不为null或undefined。当它为null或undefined时,则评估为undefined。这在像示例中不确定某个属性是否存在或某个变量可能持有undefined值时是非常方便的。
类似的表示法可以与方括号访问结合使用,甚至可以通过在括号或方括号前放置?.来使用函数调用。
console.log("string".notAMethod?.());
// → undefined
console.log({}.arrayProp?.[0]);
// → undefined
JSON
由于属性抓取其值而不是包含它,因此对象和数组在计算机内存中以持有地址(内存中的位置)的比特序列存储。一个包含另一个数组的数组至少由一个内存区域用于内部数组,另一个内存区域用于外部数组,后者包含(除了其他内容)表示内部数组地址的数字。
如果你想将数据保存到文件中以备后用或通过网络发送到另一台计算机,你必须以某种方式将这些内存地址的纠结转换为可以存储或发送的描述。我想你可以连同你感兴趣的值的地址一起发送整个计算机内存,但这似乎不是最佳方法。
我们可以做的是序列化数据。这意味着它被转换为一种扁平描述。一种流行的序列化格式称为JSON(发音为“杰森”),代表JavaScript对象表示法。它在网络上广泛用作数据存储和通信格式,甚至在JavaScript以外的语言中也使用。
JSON的书写方式与JavaScript的数组和对象相似,但有一些限制。所有属性名称必须用双引号括起来,且仅允许简单的数据表达式——不允许函数调用、绑定或涉及实际计算的内容。JSON中不允许注释。
日志条目在作为JSON数据表示时可能看起来像这样:
{
"squirrel": false,
"events": ["work", "touched tree", "pizza", "running"]
}
JavaScript提供了JSON.stringify和JSON.parse函数,用于在这种格式之间转换数据。第一个函数接受一个JavaScript值并返回一个JSON编码的字符串。第二个函数接受这样的字符串并将其转换为它编码的值。
let string = JSON.stringify({squirrel: false,
events: ["weekend"]});
console.log(string);
// → {"squirrel":false,"events":["weekend"]}
console.log(JSON.parse(string).events);
// → ["weekend"]
摘要
对象和数组提供了将多个值组合为单个值的方法。这允许我们将一堆相关的事物放入一个袋子中,然后拿着袋子四处走,而不是试图单独抱住所有个别事物。
大多数JavaScript值都有属性,唯一的例外是null和undefined。属性通过value.prop或value["prop"]来访问。对象通常使用名称作为其属性,并存储一组或多组固定的属性。而数组则通常包含数量不等的概念上相同的值,并使用数字(从0开始)作为其属性的名称。
数组中确实有一些命名属性,例如length和一些方法。方法是存在于属性中的函数,并且(通常)对其属性的值进行操作。
你可以使用一种特殊的for循环遍历数组:for (let element of array)。
练习
范围的总和
本书的引言提到以下内容作为计算一系列数字总和的好方法:
console.log(sum(range(1, 10)));
编写一个范围函数,该函数接受两个参数,start和end,并返回一个包含从start到包括end的所有数字的数组。
接下来,编写一个sum函数,接受一个数字数组并返回这些数字的总和。运行示例程序,查看它是否确实返回55。
作为额外作业,修改你的范围函数,使其接受一个可选的第三个参数,指示构建数组时使用的“步长”值。如果未给出步长,元素应该以1的增量增加,对应于旧行为。函数调用range(1, 10, 2)应该返回[1, 3, 5, 7, 9]。确保这也适用于负步长值,以便range(5, 2, -1)生成[5, 4, 3, 2]。
反转数组
数组有一个反转方法,通过反转元素出现的顺序来改变数组。对于这个练习,请编写两个函数:reverseArray和reverseArrayInPlace。第一个reverseArray应该以数组作为参数,并生成一个新的数组,包含相同元素但顺序相反。第二个reverseArrayInPlace应该执行反转方法所做的事情:修改作为参数传入的数组,反转其元素。两者均不得使用标准反转方法。
回想一下前一章关于副作用和纯函数的笔记,你认为哪种变体在更多情况下会有用?哪一个运行得更快?
一个列表
作为通用的值块,对象可以用来构建各种数据结构。一种常见的数据结构是列表(不要与数组混淆)。列表是一个嵌套的对象集合,第一个对象持有对第二个对象的引用,第二个对象持有对第三个对象的引用,依此类推。
let list = {
value: 1,
rest: {
value: 2,
rest: {
value: 3,
rest: null
}
}
};
结果对象形成一条链,如下图所示:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0078-01.jpg
列表的一个优点是它们可以共享部分结构。例如,如果我创建两个新值{value: 0, rest: list}和{value: -1, rest: list}(其中list指的是之前定义的绑定),它们都是独立的列表,但共享组成其最后三个元素的结构。原始列表仍然是一个有效的三元素列表。
编写一个函数arrayToList,当给定[1, 2, 3]作为参数时,构建出如所示的列表结构。还要编写一个listToArray函数,它可以从列表中生成一个数组。添加辅助函数prepend,该函数接收一个元素和一个列表,并创建一个新的列表,将元素添加到输入列表的前面;以及nth,该函数接收一个列表和一个数字,并返回列表中给定位置的元素(零表示第一个元素),如果没有这样的元素则返回undefined。
如果你还没有这样做,也要编写nth的递归版本。
深度比较
==操作符通过身份比较对象,但有时你可能更希望比较它们实际属性的值。
编写一个函数deepEqual,它接收两个值,仅当它们是相同的值或是具有相同属性的对象时返回true,其中属性的值在使用deepEqual的递归调用时比较相等。
要确定值是否应该直接比较(使用===操作符)或比较其属性,你可以使用typeof操作符。如果它对两个值都返回“object”,那么你应该进行深度比较。但你必须考虑一个愚蠢的例外:由于历史原因,typeof null也会返回“object”。
当你需要遍历对象的属性进行比较时,Object.keys函数会很有用。
构建软件设计有两种方式:一种是将其简化到明显没有缺陷,另一种是将其复杂化到没有明显缺陷。
—C.A.R. Hoare,1980 ACM 图灵奖演讲
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0080-01.jpg
第六章:高阶函数
一个大型程序是一个高成本的程序,这不仅仅是因为构建它所需的时间。大小几乎总是意味着复杂性,而复杂性会使程序员感到困惑。困惑的程序员又会在程序中引入错误(bug)。因此,大型程序提供了大量隐藏这些错误的空间,使它们难以被发现。
让我们简要回顾一下引言中的最后两个示例程序。第一个是自包含的,长六行。
let total = 0, count = 1;
while (count <= 10) {
total += count;
count += 1;
}
console.log(total);
第二个依赖于两个外部函数,且只有一行长。
console.log(sum(range(1, 10)));
哪一个更可能包含错误?
如果我们计算求和和范围的定义的大小,第二个程序也很大——甚至比第一个还要大。但我仍然认为,它更可能是正确的。
这是因为解决方案使用了与待解决问题相对应的词汇。对一系列数字求和并不是关于循环和计数器,而是关于范围和总和。
这个词汇的定义(函数sum和range)仍然会涉及循环、计数器和其他附带的细节。但因为它们表达的概念比整个程序更简单,所以更容易正确实现。
抽象
在编程的上下文中,这类词汇通常被称为抽象。抽象使我们能够在更高(或更抽象)的层次上讨论问题,而不被无趣的细节分散注意力。
作为类比,比较这两种豌豆汤的食谱。第一个是这样的:
每人放1杯干豌豆到容器中。加水直到豌豆完全浸没。将豌豆在水中浸泡至少12小时。将豌豆从水中取出,放入烹饪锅中。每人加4杯水。盖上锅盖,让豌豆慢炖2小时。每人取半个洋葱。用刀切成小块,加入豌豆。每人取一根芹菜。用刀切成小块,加入豌豆。每人取一根胡萝卜。切成小块。用刀!加入豌豆。再煮10分钟。
而这就是第二个食谱:
每人:1杯干豌豆、4杯水、半个切碎的洋葱、一根芹菜和一根胡萝卜。
将豌豆浸泡12小时。慢炖2小时。切碎并添加蔬菜。再煮10分钟。
第二个更简短,更容易理解。但你确实需要理解一些与烹饪相关的词汇,比如浸泡、慢炖、切碎,还有,我想,蔬菜。
在编程时,我们不能依赖于所有需要的词汇都在字典中等待我们。因此,我们可能会陷入第一个食谱的模式——逐步确定计算机必须执行的精确步骤,而忽视了它们所表达的更高层次的概念。
在编程中,注意到自己在过低的抽象层次工作是一项有用的技能。
抽象重复
到目前为止,我们看到的普通函数是构建抽象的好方法。但有时它们并不够。
程序执行某个操作若干次是很常见的。你可以为此编写一个for循环,如下所示:
for (let i = 0; i < 10; i++) {
console.log(i);
}
我们能否将“做某事N次”抽象为一个函数?实际上,编写一个调用console.log N次的函数是很简单的。
function repeatLog(n) {
for (let i = 0; i < n; i++) {
console.log(i);
}
}
但如果我们想做些其他事情,而不是记录数字呢?由于“做某事”可以表示为一个函数,而函数只是值,我们可以将我们的操作作为函数值传递。
function repeat(n, action) {
for (let i = 0; i < n; i++) {
action(i);
}
}
repeat(3, console.log);
// → 0
// → 1
// → 2
我们不必传递一个预定义的函数来重复。通常,现场创建一个函数值更为简单。
let labels = [];
repeat(5, i => {
labels.push(`Unit ${i + 1}`);
});
console.log(labels);
// → ["Unit 1", "Unit 2", "Unit 3", "Unit 4", "Unit 5"]
这在结构上有点像for循环——它首先描述循环的类型,然后提供一个主体。然而,主体现在写成了一个函数值,它被包裹在调用repeat的括号中。这就是为什么它必须用结束大括号和结束括号来关闭。在这种情况下,主体是一个单独的小表达式,你也可以省略大括号,将循环写在一行上。
高阶函数
操作其他函数的函数,或者通过将它们作为参数传递,或通过返回它们,被称为高阶函数。既然我们已经看到函数是常规值,那么这样的函数存在并没有什么特别之处。这个术语源于数学,在数学中,函数与其他值之间的区别更为重要。
高阶函数使我们能够对动作进行抽象,而不仅仅是值。它们有几种形式。例如,我们可以有创建新函数的函数。
function greaterThan(n) {
return m => m > n;
}
let greaterThan10 = greaterThan(10);
console.log(greaterThan10(11));
// → true
我们还可以有改变其他函数的函数。
function noisy(f) {
return (...args) => {
console.log("calling with", args);
let result = f(...args);
console.log("called with", args, ", returned", result);
return result;
};
}
noisy(Math.min)(3, 2, 1);
// → calling with [3, 2, 1]
// → called with [3, 2, 1] , returned 1
我们甚至可以编写提供新类型控制流的函数。
function unless(test, then) {
if (!test) then();
}
repeat(3, n => {
unless(n % 2 == 1, () => {
console.log(n, "is even");
});
});
// → 0 is even
// → 2 is even
有一个内置的数组方法forEach,它提供类似于for/of循环的高阶函数。
["A", "B"].forEach(l => console.log(l));
// → A
// → B
脚本数据集
高阶函数在数据处理方面表现出色。要处理数据,我们需要一些实际的示例数据。本章将使用一个关于脚本的数据集——如拉丁字母、西里尔字母或阿拉伯字母等书写系统。
记得Unicode吗?它是将数字分配给书写语言中每个字符的系统,来自第一章?这些字符大多与特定的脚本相关。标准包含140种不同的脚本,其中81种仍在使用中,59种是历史脚本。
尽管我只能流利地阅读拉丁字符,但我很欣赏人们用至少80种其他书写系统书写文本的事实,其中许多我甚至无法识别。例如,这里有一段泰米尔文的手写样本:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0085-01.jpg
示例数据集包含有关Unicode中定义的140种脚本的一些信息。它在本章节的编码沙箱中可用(*eloquentjavascript.net/code#5),作为SCRIPTS绑定。该绑定包含一个对象数组,每个对象描述一种脚本。
{
name: "Coptic",
ranges: [[994, 1008], [11392, 11508], [11513, 11520]],
direction: "ltr",
year: -200,
living: false,
link: "https://en.wikipedia.org/wiki/Coptic_alphabet"
}
这样的对象告诉我们脚本的名称、分配给它的Unicode范围、书写方向、(大致的)起源时间、是否仍在使用以及更多信息的链接。书写方向可以是“ltr”表示从左到右,“rtl”表示从右到左(阿拉伯语和希伯来语文本的书写方式),或者“ttb”表示从上到下(如蒙古文书写)。
ranges属性包含一个Unicode字符范围的数组,每个范围都是一个包含下限和上限的两个元素数组。这些范围内的任何字符代码都被分配给该脚本。下限是包含的(代码994是一个科普特字符),而上限是不包含的(代码1008不是)。
过滤数组
如果我们想找到数据集中仍在使用的脚本,下面的函数可能会有所帮助。它过滤掉数组中未通过测试的元素。
function filter(array, test) {
let passed = [];
for (let element of array) {
if (test(element)) {
passed.push(element);
}
}
return passed;
}
console.log(filter(SCRIPTS, script => script.living));
// → [{name: "Adlam", ...}, ...]
该函数使用名为test的参数,一个函数值,以填补计算中的“空缺”——决定收集哪些元素的过程。
注意filter函数如何不是从现有数组中删除元素,而是构建一个新数组,仅包含通过测试的元素。这个函数是纯粹的。它不会修改传入的数组。
像forEach一样,filter是一个标准数组方法。这个例子仅定义了该函数,以展示它在内部的工作原理。从现在开始,我们将这样使用它:
console.log(SCRIPTS.filter(s => s.direction == "ttb"));
// → [{name: "Mongolian", ...}, ...]
使用map进行转换
假设我们有一个表示脚本的对象数组,这些对象是通过某种方式过滤SCRIPTS数组而生成的。我们希望得到一个名称的数组,这样更容易检查。
map方法通过对数组的所有元素应用函数并从返回值构建一个新数组来转换数组。新数组将具有与输入数组相同的长度,但其内容将通过函数映射到新形式。
function map(array, transform) {
let mapped = [];
for (let element of array) {
mapped.push(transform(element));
}
return mapped;
}
let rtlScripts = SCRIPTS.filter(s => s.direction == "rtl");
console.log(map(rtlScripts, s => s.name));
// → ["Adlam", "Arabic", "Imperial Aramaic", ...]
像forEach和filter一样,map也是一个标准数组方法。
使用reduce进行汇总
对数组进行的另一个常见操作是从中计算出单个值。我们反复提到的例子,求和一组数字,就是这个操作的一个实例。另一个例子是找出字符最多的脚本。
表示此模式的高阶操作称为reduce(有时也称为fold)。它通过重复从数组中提取单个元素并将其与当前值结合来构建一个值。在求和时,你将从零开始,并对每个元素将其加到总和中。
reduce的参数除了数组外,还有一个组合函数和一个起始值。这个函数比过滤和映射稍微复杂一些,所以请仔细看看:
function reduce(array, combine, start) {
let current = start;
for (let element of array) {
current = combine(current, element);
}
return current;
}
console.log(reduce([1, 2, 3, 4], (a, b) => a + b, 0));
// → 10
标准数组方法reduce,自然对应这个函数,具有额外的便利性。如果你的数组至少包含一个元素,你可以省略起始参数。该方法会将数组的第一个元素作为起始值,并从第二个元素开始减少。
console.log([1, 2, 3, 4].reduce((a, b) => a + b));
// → 10
要使用reduce(两次)来找到字符最多的脚本,我们可以写如下内容:
function characterCount(script) {
return script.ranges.reduce((count, [from, to]) => {
return count + (to - from);
}, 0);
}
console.log(SCRIPTS.reduce((a, b) => {
return characterCount(a) < characterCount(b) ? b : a;
}));
// → {name: "Han", ...}
characterCount函数通过对分配给脚本的范围进行求和来减少其大小。注意在reducer函数的参数列表中使用了解构。第二次调用reduce则利用这个功能通过不断比较两个脚本并返回较大的一个来找到最大的脚本。
汉字在 Unicode 标准中分配了超过 89,000 个字符,使其成为数据集中最大的书写系统。汉字有时用于中文、日文和韩文。这些语言共享许多字符,尽管它们通常以不同的方式书写。美国的 Unicode 联盟决定将它们视为一个书写系统,以节省字符编码。这被称为汉字统一,并且仍然让一些人感到非常愤怒。
可组合性
想想如果没有高阶函数,我们将如何编写之前的示例(寻找最大的脚本)。代码并没有变得差很多。
let biggest = null;
for (let script of SCRIPTS) {
if (biggest == null ||
characterCount(biggest) < characterCount(script)) {
biggest = script;
}
}
console.log(biggest);
// → {name: "Han", ...}
还有几个绑定,程序多了四行,但仍然非常可读。
这些函数提供的抽象在你需要组合操作时表现得尤为出色。例如,让我们编写代码来找到数据集中存活和已亡脚本的平均来源年份。
function average(array) {
return array.reduce((a, b) => a + b) / array.length;
}
console.log(Math.round(average(
SCRIPTS.filter(s => s.living).map(s => s.year))));
// → 1165
console.log(Math.round(average(
SCRIPTS.filter(s => !s.living).map(s => s.year))));
// → 204
如你所见,Unicode中的已亡脚本平均来说比存活的脚本要古老。这并不是一个特别有意义或令人惊讶的统计数据。但我希望你会同意,用来计算它的代码并不难以阅读。你可以把它看作一个管道:我们从所有脚本开始,过滤出存活(或已亡)的脚本,从中取出年份,取平均值,然后四舍五入结果。
你当然也可以将这段计算写成一个大循环。
let total = 0, count = 0;
for (let script of SCRIPTS) {
if (script.living) {
total += script.year;
count += 1;
}
}
console.log(Math.round(total / count));
// → 1165
然而,很难看出计算的内容及其方式。而且,由于中间结果并未作为一致的值表示,要将像平均数这样的内容提取到单独的函数中将会更加复杂。
在计算机实际执行的方面,这两种方法也相当不同。第一种方法在运行过滤和映射时会建立新的数组,而第二种只计算一些数字,工作量更少。通常你可以选择可读性更高的方法,但如果你在处理巨大的数组并且多次执行,那么较少抽象的风格可能值得额外的速度。
字符串和字符编码
这个数据集的一个有趣用法是确定一段文本使用了什么脚本。让我们看看一个实现这一点的程序。
记住,每种脚本都有一个与之相关的字符代码范围数组。给定一个字符代码,我们可以使用这样的函数来查找对应的脚本(如果有的话):
function characterScript(code) {
for (let script of SCRIPTS) {
if (script.ranges.some(([from, to]) => {
return code >= from && code < to;
})) {
return script;
}
}
return null;
}
console.log(characterScript(121));
// → {name: "Latin", ...}
some方法是另一个高阶函数。它接受一个测试函数,并告诉你该函数是否对数组中的任何元素返回true。
但我们如何获取字符串中的字符代码呢?
在第一章中,我提到 JavaScript 字符串被编码为一系列 16 位数字。这些被称为代码单元。最初,Unicode 字符代码应该适合这样的单元(这给你提供了稍微超过 65,000 个字符)。当变得清楚这不够时,许多人对每个字符需要使用更多内存感到抵触。为了应对这些担忧,UTF-16这种格式(JavaScript 字符串也使用该格式)被发明。它使用单个 16 位代码单元描述大多数常见字符,但对于其他字符则使用一对这样的单元。
今天,UTF-16通常被认为是一个糟糕的主意。它似乎几乎是故意设计来引发错误的。编写假装代码单元和字符是同一事物的程序非常简单。如果你的语言不使用两个单元的字符,这看起来似乎工作得很好。但一旦有人尝试用这样一个程序处理一些不太常见的汉字,就会出现问题。幸运的是,随着表情符号的出现,大家都开始使用两个单元的字符,这样处理这些问题的负担更公平地分配了。
不幸的是,对 JavaScript 字符串的明显操作,如通过length属性获取它们的长度和使用方括号访问它们的内容,只处理代码单元。
// Two emoji characters, horse and shoe
let horseShoe = "";
console.log(horseShoe.length);
// → 4
console.log(horseShoe[0]);
// → (Invalid half-character)
console.log(horseShoe.charCodeAt(0));
// → 55357 (Code of the half-character)
console.log(horseShoe.codePointAt(0));
// → 128052 (Actual code for horse emoji)
JavaScript的charCodeAt方法返回一个代码单元,而不是完整的字符代码。稍后添加的codePointAt方法确实给出了完整的 Unicode 字符,因此我们可以用它从字符串中获取字符。但传递给codePointAt的参数仍然是代码单元序列中的一个索引。要遍历字符串中的所有字符,我们仍然需要处理一个字符占用一个或两个代码单元的问题。
在第四章中,我提到可以在字符串上使用for/of循环。与codePointAt类似,这种循环在人们对UTF-16存在的问题有深刻认识时被引入。当你用它遍历字符串时,它会给你真实的字符,而不是代码单元。
let roseDragon = "";
for (let char of roseDragon) {
console.log(char);
}
// →
// →
如果你有一个字符(它将是一个由一个或两个代码单元组成的字符串),你可以使用codePointAt(0)来获取它的代码。
识别文本
我们有一个characterScript函数和一种正确遍历字符的方法。下一步是计算每种脚本所包含的字符数量。以下计数抽象将在这里很有用:
function countBy(items, groupName) {
let counts = [];
for (let item of items) {
let name = groupName(item);
let known = counts.find(c => c.name == name);
if (!known) {
counts.push({name, count: 1});
} else {
known.count++;
}
}
return counts;
}
console.log(countBy([1, 2, 3, 4, 5], n => n > 2));
// → [{name: false, count: 2}, {name: true, count: 3}]
countBy函数期望一个集合(任何可以用for/of循环遍历的内容)和一个为给定元素计算组名的函数。它返回一个对象数组,每个对象命名一个组,并告诉你在该组中找到的元素数量。
它使用另一个数组方法find,该方法遍历数组中的元素并返回第一个函数返回true的元素。如果没有找到这样的元素,它将返回undefined。
使用countBy,我们可以编写一个函数来告诉我们在一段文本中使用了哪些脚本。
function textScripts(text) {
let scripts = countBy(text, char => {
let script = characterScript(char.codePointAt(0));
return script ? script.name : "none";
}).filter(({name}) => name != "none");
let total = scripts.reduce((n, {count}) => n + count, 0);
if (total == 0) return "No scripts found";
return scripts.map(({name, count}) => {
return `${Math.round(count * 100 / total)}% ${name}`;
}).join(", ");
}
console.log(textScripts('英国的狗说"woof", 俄罗斯的狗说"тяв"'));
// → 61% Han, 22% Latin, 17% Cyrillic
该函数首先通过名称计数字符,使用characterScript为它们分配名称,并在字符不属于任何脚本时回退到字符串"none"。filter调用从结果数组中删除"none"的条目,因为我们对这些字符不感兴趣。
为了能够计算百分比,我们首先需要计算属于某个脚本的字符总数,可以通过reduce来计算。如果没有找到这样的字符,函数将返回一个特定字符串。否则,它会通过map将计数条目转换为可读字符串,然后用join将它们组合起来。
总结
能够将函数值传递给其他函数是 JavaScript一个非常有用的特性。它允许我们编写带有“缺口”的计算模型函数。调用这些函数的代码可以通过提供函数值来填补这些缺口。
数组提供了许多有用的高阶方法。你可以使用forEach遍历数组中的元素。filter方法返回一个新数组,只包含通过谓词函数的元素。你可以通过使用map将每个元素放入一个函数来转换数组。你可以使用reduce将数组中的所有元素组合成一个单一值。some方法测试是否有任何元素与给定的谓词函数匹配,而find则找到第一个匹配谓词的元素。
练习
扁平化
使用reduce方法与concat方法结合,将一个数组的数组“扁平化”为一个包含所有原始数组元素的单一数组。
你自己的循环
编写一个高阶函数loop,提供类似于for循环语句的功能。它应该接受一个值、一个测试函数、一个更新函数和一个主体函数。每次迭代时,它应该首先在当前循环值上运行测试函数,如果返回false,则停止。然后调用主体函数,传递当前值,最后调用更新函数以生成新值并重新开始。
在定义函数时,你可以使用常规循环来实际执行循环。
一切
数组有一个every方法,与some方法类似。当给定的函数对数组中的每个元素返回true时,该方法返回true。从某种意义上说,some是作用于数组的||运算符的一个版本,而every则像是&&运算符。
将每个实现作为一个函数,该函数接受一个数组和一个谓词函数作为参数。写两个版本,一个使用循环,另一个使用some方法。
主导书写方向
编写一个函数,用于计算文本字符串中的主导书写方向。记住,每个脚本对象都有一个direction属性,可能是“ltr”(从左到右)、“rtl”(从右到左)或“ttb”(从上到下)。
主导方向是指大多数具有相关脚本的字符的方向。前面章节中定义的characterScript和countBy函数在这里可能会很有用。
抽象数据类型通过编写一种特殊类型的程序来实现,该程序定义了可以在其上执行的操作类型。
—芭芭拉·利斯科夫,使用抽象数据类型编程
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0094-01.jpg


浙公网安备 33010602011771号