第四章: 数据结构:对象和数组
¶ 本章将致力于解决一些简单的问题。在这个过程中,我们将讨论两种新的值类型,数组和对象,并考察与它们相关的技巧。
¶ 考虑以下情况:你疯狂的阿姨艾米丽,据说养了超过五十只猫 (你从来没有数过),定期给你发邮件,让你了解她的日常。邮件通常是这样的
亲爱的侄子,
你妈妈告诉我你开始跳伞了。这是真的吗?你注意安全,年轻人!记得我丈夫发生什么事了吗?那只是从二楼摔下来!
总之,这里发生的事情非常令人兴奋。我整个星期都在努力吸引住在隔壁的德雷克先生的注意,但我认为他害怕猫。或者对猫过敏?我下次见到他时,要试着把肥伊戈尔放在他肩膀上,非常好奇会发生什么。
另外,我告诉你那个骗局进展得比预期要好。我已经拿回了五笔“付款”,只有一起投诉。不过,这开始让我有点良心不安了。你是对的,这可能在某种程度上是非法的。
(... 等等 ...)
爱你的,艾米丽阿姨
2006年4月27日死亡:黑色勒克莱尔
2006年4月5日出生(母亲是彭尼洛普夫人):红色狮子,第三代霍布尔斯医生,小易洛魁
¶ 为了哄这位老人家开心,你想要追踪她猫的族谱,这样你就可以添加类似“附注:我希望第二代霍布尔斯医生在本周六生日快乐!”,或者“老彭尼洛普夫人怎么样?她现在五岁了,不是吗?”,最好不要不小心问起死去的猫。你拥有大量来自你阿姨的旧邮件,幸运的是,她非常一致,总是以完全相同的格式在邮件末尾放置关于猫的出生和死亡信息。
¶ 你不太可能手动处理所有这些邮件。幸运的是,我们正好需要一个示例问题,所以我们将尝试编写一个程序来完成这项工作。首先,我们编写一个程序,它可以给我们一个列表,其中包含最后一次电子邮件后仍然存活的猫。
¶ 在你问之前,在通信开始时,艾米丽阿姨只有一只猫:斑点。(在那段时间里,她还比较传统。)
¶ 在开始输入之前,通常需要对程序要做什么有一个大致的概念。这里有一个计划
- 从只有“斑点”的猫名集合开始。
- 按时间顺序查看我们档案中的每封邮件。
- 查找以“出生”或“死亡”开头的段落。
- 将以“出生”开头的段落中的名字添加到我们的名字集合中。
- 从以“死亡”开头的段落中删除我们集合中的名字。
¶ 从段落中获取名字的方法如下
- 在段落中找到冒号。
- 获取冒号后面的部分。
- 通过查找逗号,将此部分拆分为单独的名字。
¶ 可能需要一些不相信才能接受艾米丽阿姨始终使用这种确切的格式,而且她从未忘记或拼错名字,但事实就是这样。
¶ 首先,让我告诉你关于属性的信息。许多 JavaScript 值都有与之关联的其他值。这些关联称为属性。每个字符串都有一个称为length
的属性,它引用一个数字,表示该字符串中字符的数量。
¶ 属性可以通过两种方式访问
var text = "purple haze"; show(text["length"]); show(text.length);
¶ 第二种方式是第一种方式的简写,它只在属性名称是有效的变量名称时起作用——当它不包含任何空格或符号,并且不以数字字符开头时。
¶ null
和 undefined
值没有属性。尝试从这样的值读取属性会导致错误。尝试以下代码,即使只是为了了解在这种情况下的浏览器产生的错误消息类型(对于某些浏览器,这种消息可能是非常含糊的)。
var nothing = null; show(nothing.length);
¶ 字符串值的属性无法更改。除了length
,还有很多其他的属性,我们将在后面看到,但是不允许添加或删除任何属性。
¶ 这与对象类型的值不同。它们的主要作用是保存其他值。可以说,它们有自己的属性形式的触手。你可以随意修改、删除或添加新的属性。
¶ 对象可以这样写
var cat = {colour: "grey", name: "Spot", size: 46}; cat.size = 47; show(cat.size); delete cat.size; show(cat.size); show(cat);
¶ 与变量一样,每个附加到对象的属性都由一个字符串标记。第一个语句创建一个对象,其中属性"colour"
保存字符串 "grey"
,属性 "name"
附加到字符串 "Spot"
,属性 "size"
引用数字 46
。第二个语句给名为 size
的属性一个新的值,这与修改变量的方式相同。
¶ 关键字delete
会删除属性。尝试读取一个不存在的属性会返回 undefined
值。
¶ 如果使用=
运算符设置一个还不存在的属性,则会将它添加到对象中。
var empty = {}; empty.notReally = 1000; show(empty.notReally);
¶ 属性名称不是有效变量名称的属性,在创建对象时必须用引号引起来,并且必须使用方括号访问
var thing = {"gabba gabba": "hey", "5": 10}; show(thing["5"]); thing["5"] = 20; show(thing[2 + 3]); delete thing["gabba gabba"];
¶ 正如你所见,方括号之间的部分可以是任何表达式。它会被转换为字符串,以确定它引用的属性名称。甚至可以使用变量来命名属性
var propertyName = "length"; var text = "mainline"; show(text[propertyName]);
¶ 运算符in
可用于测试对象是否具有某个属性。它返回一个布尔值。
var chineseBox = {}; chineseBox.content = chineseBox; show("content" in chineseBox); show("content" in chineseBox.content);
¶ 当对象值显示在控制台上时,可以单击它们来检查它们的属性。这会将输出窗口更改为“检查”窗口。右上角的小“x”可以用来返回到输出窗口,左箭头可以用来返回到先前检查对象的属性。
show(chineseBox);
¶ 显然,对象的值可以改变。在第二章中讨论的值类型都是不可变的,无法更改这些类型的现有值。可以将它们组合起来,并从它们推导出新的值,但是当你采用特定的字符串值时,它内部的文本无法改变。另一方面,对于对象,可以通过更改其属性来修改值的內容。
¶ 当有两个数字,120
和 120
时,从实际意义上讲,它们可以被认为是完全相同的数字。对于对象,有两个引用同一个对象的引用和有两个不同的对象包含相同的属性之间存在差异。考虑以下代码
var object1 = {value: 10}; var object2 = object1; var object3 = {value: 10}; show(object1 == object2); show(object1 == object3); object1.value = 15; show(object2.value); show(object3.value);
¶ object1
和 object2
是两个抓取相同值的变量。只有一个实际的对象,这就是为什么更改 object1
也会更改 object2
的值的原因。变量 object3
指向另一个对象,该对象最初包含与 object1
相同的属性,但它是独立存在的。
¶ JavaScript 的==
运算符在比较对象时,只有当给定的两个值是完全相同的时,才会返回 true
。比较具有相同内容的不同对象将返回 false
。这在某些情况下很有用,但在其他情况下则不切实际。
¶ 对象值可以扮演很多不同的角色。充当集合只是一个角色。我们将在本章中看到一些其他的角色,第八章展示了另一种使用对象的重要方法。
¶ 在猫问题的计划中——实际上,称之为算法,而不是计划,这样听起来我们知道自己在说什么——在算法中,它谈到了查看档案中的所有电子邮件。这个档案是什么样的?它从哪里来?
¶ 现在不必担心第二个问题。 第十四章讨论了一些将数据导入程序的方法,但现在你会发现,这些电子邮件只是神奇地出现了。在计算机内部,有些魔法真的很容易。
¶ 档案的存储方式仍然是一个有趣的问题。它包含许多电子邮件。电子邮件可以是一个字符串,这应该是显而易见的。整个档案可以放到一个巨大的字符串中,但这不太实用。我们想要的是一个独立字符串的集合。
¶ 对象用于存放集合。可以创建一个这样的对象
var mailArchive = {"the first e-mail": "Dear nephew, ...", "the second e-mail": "..." /* and so on ... */};
¶ 但这使得难以从头到尾遍历电子邮件——程序如何猜测这些属性的名称?这可以通过更可预测的属性名称来解决
var mailArchive = {0: "Dear nephew, ... (mail number 1)", 1: "(mail number 2)", 2: "(mail number 3)"}; for (var current = 0; current in mailArchive; current++) print("Processing e-mail #", current, ": ", mailArchive[current]);
¶ 幸运的是,存在一种专门用于此类用途的特殊类型的对象。它们被称为数组,并且它们提供了一些便利,例如一个length
属性,它包含数组中值的數量,以及一些对这种类型的集合有用的操作。
¶ 可以使用方括号([
和 ]
)创建新的数组。
var mailArchive = ["mail one", "mail two", "mail three"]; for (var current = 0; current < mailArchive.length; current++) print("Processing e-mail #", current, ": ", mailArchive[current]);
¶ 在这个例子中,元素的数字不再被明确指定。第一个自动获得数字 0,第二个获得数字 1,依此类推。
¶ 为什么从 0 开始?人们往往从 1 开始计数。虽然看起来不直观,但从 0 开始给集合中的元素编号往往更实用。现在先接受它,你以后会习惯的。
¶ 从元素 0 开始也意味着,在一个有 X
个元素的集合中,最后一个元素可以在位置 X - 1
找到。这就是为什么例子中的 for
循环检查 current < mailArchive.length
。位置 mailArchive.length
没有元素,所以一旦 current
具有该值,我们就停止循环。
¶ 编写一个名为 range
的函数,它接受一个参数,一个正数,并返回一个包含从 0 到包括给定数字的所有数字的数组。
¶ 可以通过简单地键入 []
来创建一个空数组。还要记住,可以通过使用 =
运算符为它们赋值来向对象添加属性,因此也向数组添加属性。当添加元素时,length
属性会自动更新。
function range(upto) { var result = []; for (var i = 0; i <= upto; i++) result[i] = i; return result; } show(range(4));
¶ 与我之前一直使用的 counter
或 current
不同,现在循环变量的名称简称为 i
。对于循环变量使用单字母,通常是 i
、j
或 k
,是程序员中普遍存在的习惯。这主要源于懒惰:我们宁愿键入一个字符,也不愿键入七个字符,而且像 counter
和 current
这样的名称并没有真正阐明变量的含义。
¶ 如果一个程序使用了太多毫无意义的单字母变量,它就会变得令人难以置信地混乱。在我的程序中,我尽量只在一些常见情况下这样做。小型循环就是其中一种情况。如果循环包含另一个循环,而另一个循环也使用一个名为 i
的变量,则内部循环将修改外部循环正在使用的变量,所有内容都将崩溃。可以使用 j
表示内部循环,但总的来说,当循环体很大时,你应该想出一个具有明确意义的变量名称。
¶ 字符串和数组对象除了 length
属性外,还包含许多指向函数值的属性。
var doh = "Doh"; print(typeof doh.toUpperCase); print(doh.toUpperCase());
¶ 每个字符串都有一个名为 toUpperCase
的属性。当调用时,它将返回字符串的副本,其中所有字母都已转换为大写。还有 toLowerCase
。猜猜它做什么。
¶ 请注意,即使对 toUpperCase
的调用没有传递任何参数,该函数也能够访问字符串 "Doh"
,而它是该函数的属性。这种工作原理将在 第 8 章 中详细介绍。
¶ 包含函数的属性通常被称为 方法,如“toUpperCase
是字符串对象的方法”。
var mack = []; mack.push("Mack"); mack.push("the"); mack.push("Knife"); show(mack.join(" ")); show(mack.pop()); show(mack);
¶ 方法 push
与数组相关联,可用于向数组添加值。它可以在上一练习中使用,作为 result[i] = i
的替代方法。然后是 pop
,它是 push
的反义词:它删除并返回数组中的最后一个值。 join
从字符串数组构建一个大的字符串。它接收的参数将粘贴在数组中的值之间。
¶ 回到那些猫,我们现在知道数组将是存储电子邮件存档的好方法。在本页上,函数 retrieveMails
可用于(神奇地)获取此数组。逐个处理它们也不再是难事。
var mailArchive = retrieveMails(); for (var i = 0; i < mailArchive.length; i++) { var email = mailArchive[i]; print("Processing e-mail #", i); // Do more things... }
¶ 我们也决定了表示存活猫群的方法。那么,下一个问题就是找出电子邮件中以 "born"
或 "died"
开头的段落。
¶ 第一个出现的问题是,段落究竟是什么。在这种情况下,字符串值本身不能帮助我们太多:JavaScript 的文本概念不会深入到“字符序列”的概念,因此我们必须用这些术语来定义段落。
¶ 之前我们看到存在换行符。大多数人使用它们来分割段落。然后,我们将段落视为电子邮件的一部分,它从换行符或内容开头开始,到下一个换行符或内容结尾结束。
¶ 我们甚至不必自己编写将字符串分割成段落的算法。字符串已经有了一个名为 split
的方法,它(几乎)与数组的 join
方法相反。它将字符串分割成一个数组,使用作为参数给出的字符串来确定在哪些位置进行切割。
var words = "Cities of the Interior"; show(words.split(" "));
¶ 因此,在换行符 ("\n"
) 上切割可用于将电子邮件分割成段落。
¶ split
和 join
并不完全是彼此的逆运算。string.split(x).join(x)
始终产生原始值,但 array.join(x).split(x)
则不然。你能举出一个数组的例子,其中 .join(" ").split(" ")
会产生不同的值吗?
var array = ["a", "b", "c d"]; show(array.join(" ").split(" "));
¶ 程序可以忽略不以“born”或“died”开头的段落。我们如何测试字符串是否以某个单词开头?方法 charAt
可用于从字符串中获取特定字符。x.charAt(0)
给出第一个字符,1
是第二个字符,依此类推。检查字符串是否以“born”开头的其中一种方法是
var paragraph = "born 15-11-2003 (mother Spot): White Fang"; show(paragraph.charAt(0) == "b" && paragraph.charAt(1) == "o" && paragraph.charAt(2) == "r" && paragraph.charAt(3) == "n");
¶ 但这有点笨拙——想象一下检查一个有十个字符的单词。不过,这里有一些东西需要学习:当一行变得过长时,可以将其分成多行。可以通过将新行的开头与原始行中起相似作用的第一个元素对齐,使结果更易读。
¶ 字符串也具有一个名为 slice
的方法。它会复制字符串的一部分,从第一个参数给出的位置处的字符开始,并在第二个参数给出的位置处的字符之前(不包括)结束。这允许以更短的方式编写检查。
show(paragraph.slice(0, 4) == "born");
¶ 编写一个名为 startsWith
的函数,它接受两个参数,都是字符串。当第一个参数以第二个参数中的字符开头时,它返回 true
,否则返回 false
。
function startsWith(string, pattern) { return string.slice(0, pattern.length) == pattern; } show(startsWith("rotation", "rot"));
¶ 当 charAt
或 slice
用于获取字符串中不存在的部分时会发生什么?当我显示的 startsWith
在模式长度超过要匹配的字符串时仍然有效吗?
show("Pip".charAt(250)); show("Nop".slice(1, 10));
¶ charAt
在给定位置没有字符时将返回 ""
,而 slice
将简单地省略新字符串中不存在的部分。
¶ 所以是的,那个版本的 startsWith
有效。当调用 startsWith("Idiots", "Most honoured colleagues")
时,由于 string
没有足够的字符,对 slice
的调用始终会返回一个比 pattern
短的字符串。因此,与 ==
的比较将返回 false
,这是正确的。
¶ 始终花点时间考虑程序的异常(但有效)输入。这些通常被称为 极端情况,对于在所有“正常”输入上都能完美运行的程序来说,在极端情况下出错是很常见的。
¶ 猫问题的唯一尚未解决的部分是从段落中提取姓名。算法是这样的
- 在段落中找到冒号。
- 获取冒号后面的部分。
- 通过查找逗号,将此部分拆分为单独的名字。
¶ 这必须同时适用于以 "died"
开头的段落和以 "born"
开头的段落。最好把它放到一个函数中,这样处理这两种不同类型的段落的两部分代码就可以使用它。
¶ 你能编写一个名为 catNames
的函数,它接受一个段落作为参数并返回一个姓名数组吗?
¶ 字符串有一个名为 indexOf
的方法,它可用于查找字符或子字符串在该字符串中的(第一个)位置。此外,当 slice
仅接受一个参数时,它将返回从给定位置到末尾的字符串部分。
¶ 使用控制台“探索”函数可能会有所帮助。例如,键入 "foo: bar".indexOf(":")
并查看你得到的结果。
function catNames(paragraph) { var colon = paragraph.indexOf(":"); return paragraph.slice(colon + 2).split(", "); } show(catNames("born 20/09/2004 (mother Yellow Bess): " + "Doctor Hobbles the 2nd, Noog"));
¶ 棘手的部分(原始算法描述忽略的部分)是处理冒号和逗号后的空格。在切片字符串时使用的 + 2
是为了省略冒号本身和它后面的空格。split
的参数包含逗号和空格,因为这才是姓名实际分隔的方式,而不仅仅是逗号。
¶ 此函数不会对问题进行任何检查。在这种情况下,我们假设输入始终是正确的。
¶ 现在剩下的就是将这些部分拼凑起来。一种方法如下所示
var mailArchive = retrieveMails(); var livingCats = {"Spot": true}; for (var mail = 0; mail < mailArchive.length; mail++) { var paragraphs = mailArchive[mail].split("\n"); for (var paragraph = 0; paragraph < paragraphs.length; paragraph++) { if (startsWith(paragraphs[paragraph], "born")) { var names = catNames(paragraphs[paragraph]); for (var name = 0; name < names.length; name++) livingCats[names[name]] = true; } else if (startsWith(paragraphs[paragraph], "died")) { var names = catNames(paragraphs[paragraph]); for (var name = 0; name < names.length; name++) delete livingCats[names[name]]; } } } show(livingCats);
¶ 这是一个相当大的密集代码块。我们将在稍后探讨如何使它更轻便。但首先,让我们看看我们的结果。我们知道如何检查特定猫是否存活
if ("Spot" in livingCats) print("Spot lives!"); else print("Good old Spot, may she rest in peace.");
¶ 但我们如何列出所有存活的猫?当与 for
一起使用时,in
关键字具有不同的含义
for (var cat in livingCats) print(cat);
¶ 这样的循环将遍历对象中属性的名称,这使我们能够枚举集合中的所有名称。
¶ 有些代码看起来像是无法理解的丛林。猫问题示例解决方案存在此问题。使之清晰明了的一种方法是添加一些策略性空行。这使它看起来更好,但并没有真正解决问题。
¶ 这里需要的是将代码分解。我们已经编写了两个辅助函数,startsWith
和 catNames
,它们都负责解决问题的某个小而易懂的部分。让我们继续这样做。
function addToSet(set, values) { for (var i = 0; i < values.length; i++) set[values[i]] = true; } function removeFromSet(set, values) { for (var i = 0; i < values.length; i++) delete set[values[i]]; }
¶ 这两个函数负责向集合中添加和删除名称。这已经从解决方案中去掉了最里面的两个循环。
var livingCats = {Spot: true}; for (var mail = 0; mail < mailArchive.length; mail++) { var paragraphs = mailArchive[mail].split("\n"); for (var paragraph = 0; paragraph < paragraphs.length; paragraph++) { if (startsWith(paragraphs[paragraph], "born")) addToSet(livingCats, catNames(paragraphs[paragraph])); else if (startsWith(paragraphs[paragraph], "died")) removeFromSet(livingCats, catNames(paragraphs[paragraph])); } }
¶ 如果我可以这么说,这是一个很大的改进。
¶ 为什么 addToSet
和 removeFromSet
将集合作为参数?如果需要,它们可以直接使用变量 livingCats
。原因是,这样它们就不会完全绑定到我们当前的问题。如果 addToSet
直接改变 livingCats
,则它必须被称为 addCatsToCatSet
或类似的名称。现在,它是一个更通用的工具。
¶ 即使我们从未打算将这些函数用于其他用途,这很可能,但这样编写它们也很有用。因为它们是“自给自足”的,它们可以独立地阅读和理解,而无需了解名为 livingCats
的外部变量。
¶ 这些函数不是纯函数:它们会改变作为 set
参数传递的对象。这使得它们比真正的纯函数稍微复杂一些,但仍然比那些随意改变任何值或变量的函数容易理解得多。
¶ 我们继续将算法分解成多个部分。
function findLivingCats() { var mailArchive = retrieveMails(); var livingCats = {"Spot": true}; function handleParagraph(paragraph) { if (startsWith(paragraph, "born")) addToSet(livingCats, catNames(paragraph)); else if (startsWith(paragraph, "died")) removeFromSet(livingCats, catNames(paragraph)); } for (var mail = 0; mail < mailArchive.length; mail++) { var paragraphs = mailArchive[mail].split("\n"); for (var i = 0; i < paragraphs.length; i++) handleParagraph(paragraphs[i]); } return livingCats; } var howMany = 0; for (var cat in findLivingCats()) howMany++; print("There are ", howMany, " cats.");
¶ 整个算法现在被一个函数封装起来。这意味着它在运行后不会留下混乱:livingCats
现在是函数中的局部变量,而不是顶级变量,因此它只在函数运行时存在。需要该集合的代码可以调用 findLivingCats
并使用它返回的值。
¶ 在我看来,将 handleParagraph
作为单独的函数也使事情变得更加清晰。但是,这个函数与猫算法密切相关,在任何其他情况下都毫无意义。最重要的是,它需要访问 livingCats
变量。因此,它是作为函数内部函数的完美候选。当它存在于 findLivingCats
内部时,很明显它只在该函数中相关,并且可以访问其父函数的变量。
¶ 这个解决方案实际上比上一个解决方案更大。尽管如此,它仍然更整洁,我希望您同意它更容易阅读。
¶ 该程序仍然忽略了电子邮件中包含的许多信息。其中包含出生日期、死亡日期和母亲的姓名。
¶ 从日期开始:存储日期的最佳方法是什么?我们可以创建一个包含三个属性的对象,year
、month
和 day
,并在其中存储数字。
var when = {year: 1980, month: 2, day: 1};
¶ 但是 JavaScript 已经为此目的提供了一种对象。可以使用关键字 new
创建这样的对象。
var when = new Date(1980, 1, 1); show(when);
¶ 就像我们已经见过的用大括号和冒号表示法一样,new
是创建对象值的一种方法。它不指定所有属性名称和值,而是使用函数来构建对象。这使得定义一种创建对象的标准程序成为可能。这样的函数称为 构造函数,在第 8 章 中,我们将看到如何编写它们。
¶ Date
构造函数可以以不同的方式使用。
show(new Date()); show(new Date(1980, 1, 1)); show(new Date(2007, 2, 30, 8, 20, 30));
¶ 如您所见,这些对象可以存储时间和日期。如果没有给出任何参数,则会创建一个表示当前时间和日期的对象。可以给出参数以请求特定日期和时间。参数的顺序是年、月、日、时、分、秒、毫秒。最后四个是可选的,如果未给出,它们将变为 0。
¶ 这些对象使用的月份数字从 0 到 11,这可能令人困惑。特别是因为日期数字确实从 1 开始。
¶ 可以使用许多 get...
方法来检查 Date
对象的内容。
var today = new Date(); print("Year: ", today.getFullYear(), ", month: ", today.getMonth(), ", day: ", today.getDate()); print("Hour: ", today.getHours(), ", minutes: ", today.getMinutes(), ", seconds: ", today.getSeconds()); print("Day of week: ", today.getDay());
¶ 所有这些方法,除了 getDay
,还具有一个 set...
变体,它可以用来改变日期对象的价值。
¶ 在对象内部,日期由它与 1970 年 1 月 1 日的毫秒数表示。您可以想象这是一个相当大的数字。
var today = new Date(); show(today.getTime());
¶ 对日期进行比较是一件非常有用的事情。
var wallFall = new Date(1989, 10, 9); var gulfWarOne = new Date(1990, 6, 2); show(wallFall < gulfWarOne); show(wallFall == wallFall); // but show(wallFall == new Date(1989, 10, 9));
¶ 使用 <
、>
、<=
和 >=
比较日期会完全按照您的预期执行。当使用 ==
将日期对象与其自身进行比较时,结果为 true
,这也是好的。但是,当使用 ==
将日期对象与另一个相等的日期对象进行比较时,我们会得到 false
。为什么?
¶ 如前所述,==
在比较两个不同的对象时将返回 false
,即使它们包含相同的属性。这在这里有点笨拙且容易出错,因为人们期望 >=
和 ==
采取或多或少类似的方式。可以这样测试两个日期是否相等
var wallFall1 = new Date(1989, 10, 9), wallFall2 = new Date(1989, 10, 9); show(wallFall1.getTime() == wallFall2.getTime());
¶ 除了日期和时间之外,Date
对象还包含有关 时区的相关信息。当阿姆斯特丹是下午 1 点时,根据一年中的时间,伦敦可能已经是中午,纽约则是早上 7 点。只有考虑其时区才能比较这些时间。可以使用 Date
的 getTimezoneOffset
函数来找出它与 GMT(格林威治标准时间)相差多少分钟。
var now = new Date(); print(now.getTimezoneOffset());
"died 27/04/2006: Black Leclère"
¶ 日期部分始终位于段落的同一个位置。多么方便。编写一个函数 extractDate
,它将这样的段落作为参数,提取日期并将其作为日期对象返回。
function extractDate(paragraph) { function numberAt(start, length) { return Number(paragraph.slice(start, start + length)); } return new Date(numberAt(11, 4), numberAt(8, 2) - 1, numberAt(5, 2)); } show(extractDate("died 27-04-2006: Black Leclère"));
¶ 它可以在没有对 Number
的调用情况下工作,但如前所述,我更喜欢不将字符串用作数字。引入了内部函数以避免三次重复 Number
和 slice
部分。
¶ 请注意月份数字的 - 1
。像大多数人一样,艾米丽阿姨从 1 开始计算月份,所以我们必须在将其提供给 Date
构造函数之前调整该值。(日期数字没有这个问题,因为 Date
对象以通常的人类方式计算日期。)
¶ 从现在起,存储猫的方式将有所不同。我们不再只是将值 true
放入集合中,而是存储一个包含有关猫的信息的对象。当猫死亡时,我们不会将其从集合中移除,我们只是向该对象添加一个属性 death
来存储该生物死亡的日期。
¶ 这意味着我们的 addToSet
和 removeFromSet
函数已经变得毫无用处。需要类似的东西,但它还必须存储出生日期,以及稍后的母亲姓名。
function catRecord(name, birthdate, mother) { return {name: name, birth: birthdate, mother: mother}; } function addCats(set, names, birthdate, mother) { for (var i = 0; i < names.length; i++) set[names[i]] = catRecord(names[i], birthdate, mother); } function deadCats(set, names, deathdate) { for (var i = 0; i < names.length; i++) set[names[i]].death = deathdate; }
¶ catRecord
是一个用于创建这些存储对象的独立函数。它可能在其他情况下有用,例如为 Spot 创建对象。“记录”是经常用于此类对象的术语,这些对象用于对有限数量的值进行分组。
¶ 所以让我们尝试从段落中提取母猫的名称。
"born 15/11/2003 (mother Spot): White Fang"
¶ 一种方法是...
function extractMother(paragraph) { var start = paragraph.indexOf("(mother ") + "(mother ".length; var end = paragraph.indexOf(")"); return paragraph.slice(start, end); } show(extractMother("born 15/11/2003 (mother Spot): White Fang"));
¶ 请注意,起始位置必须根据 "(mother "
的长度进行调整,因为 indexOf
返回模式开始位置,而不是其结束位置。
¶ extractMother
所做的工作可以用更通用的方式表达。编写一个函数 between
,它接受三个参数,所有参数都是字符串。它将返回第一个参数中位于第二个和第三个参数给出的模式之间的部分。
¶ 所以 between("born 15/11/2003 (mother Spot): White Fang", "(mother ", ")")
将给出 "Spot"
。
¶ between("bu ] boo [ bah ] gzz", "[ ", " ]")
返回 "bah"
。
¶ 为了使第二个测试起作用,了解 indexOf
可以接受第二个可选参数很有用,该参数指定它应该从哪里开始搜索。
function between(string, start, end) { var startAt = string.indexOf(start) + start.length; var endAt = string.indexOf(end, startAt); return string.slice(startAt, endAt); } show(between("bu ] boo [ bah ] gzz", "[ ", " ]"));
¶ 拥有 between
使得能够以更简单的方式表达 extractMother
function extractMother(paragraph) { return between(paragraph, "(mother ", ")"); }
¶ 新的改进的猫算法如下所示
function findCats() { var mailArchive = retrieveMails(); var cats = {"Spot": catRecord("Spot", new Date(1997, 2, 5), "unknown")}; function handleParagraph(paragraph) { if (startsWith(paragraph, "born")) addCats(cats, catNames(paragraph), extractDate(paragraph), extractMother(paragraph)); else if (startsWith(paragraph, "died")) deadCats(cats, catNames(paragraph), extractDate(paragraph)); } for (var mail = 0; mail < mailArchive.length; mail++) { var paragraphs = mailArchive[mail].split("\n"); for (var i = 0; i < paragraphs.length; i++) handleParagraph(paragraphs[i]); } return cats; } var catData = findCats();
¶ 拥有这些额外的數據讓我們最終可以了解艾米丽阿姨所談論的猫。这样的函数可能很有用
function formatDate(date) { return date.getDate() + "/" + (date.getMonth() + 1) + "/" + date.getFullYear(); } function catInfo(data, name) { if (!(name in data)) return "No cat by the name of " + name + " is known."; var cat = data[name]; var message = name + ", born " + formatDate(cat.birth) + " from mother " + cat.mother; if ("death" in cat) message += ", died " + formatDate(cat.death); return message + "."; } print(catInfo(catData, "Fat Igor"));
¶ catInfo
中的第一个 return
语句用作逃生舱口。如果关于给定猫没有数据,则函数的其余部分将毫无意义,因此我们立即返回一个值,这将阻止其余代码运行。
¶ 过去,某些程序员群体认为包含多个 return
语句的函数是罪恶的。其理念是,这使得难以辨别哪些代码被执行了,哪些代码没有被执行。其他技术(将在第 5 章中讨论)使得这种理念背后的理由或多或少地过时了,但你仍然偶尔会遇到一些人批评使用“快捷”的 return 语句。
¶ catInfo
使用的 formatDate
函数在月份和日期部分只有一位数时,不会在前面添加一个零。编写一个新的版本来实现这个功能。
function formatDate(date) { function pad(number) { if (number < 10) return "0" + number; else return number; } return pad(date.getDate()) + "/" + pad(date.getMonth() + 1) + "/" + date.getFullYear(); } print(formatDate(new Date(2000, 0, 1)));
¶ 编写一个函数 oldestCat
,它接受一个包含猫的对象作为参数,并返回最年长的活猫的姓名。
function oldestCat(data) { var oldest = null; for (var name in data) { var cat = data[name]; if (!("death" in cat) && (oldest == null || oldest.birth > cat.birth)) oldest = cat; } if (oldest == null) return null; else return oldest.name; } print(oldestCat(catData));
¶ if
语句中的条件可能看起来有点吓人。它可以理解为“只有在当前猫没有死,并且 oldest
变量是 null
或一个比当前猫出生日期晚的猫时,才将当前猫存储在 oldest
变量中”。
¶ 请注意,当 data
中没有活着的猫时,此函数将返回 null
。你的解决方案在这种情况下会怎么做?
¶ 现在我们已经熟悉了数组,我可以向你展示一些相关的内容。每当调用一个函数时,一个名为 arguments
的特殊变量会被添加到函数体运行的环境中。该变量引用一个类似于数组的对象。它有一个属性 0
用于第一个参数,1
用于第二个参数,依此类推,用于函数接受的每个参数。它还有一个 length
属性。
¶ 但是,这个对象不是真正的数组,它没有像 push
这样的方法,并且当你向它添加内容时,它也不会自动更新 length
属性。为什么呢?我从来没弄明白,但这需要我们注意。
function argumentCounter() { print("You gave me ", arguments.length, " arguments."); } argumentCounter("Death", "Famine", "Pestilence");
¶ 有些函数可以接受任意数量的参数,比如 print
函数。这些函数通常循环遍历 arguments
对象中的值来对它们进行操作。其他函数可以接受可选参数,当调用者没有提供这些参数时,它们会获得一个合理的默认值。
function add(number, howmuch) { if (arguments.length < 2) howmuch = 1; return number + howmuch; } show(add(6)); show(add(6, 4));
¶ 扩展练习 4.2 中的 range
函数,使其接受第二个可选参数。如果只给出一个参数,它将像之前一样执行,生成从 0 到给定数字的范围。如果给出了两个参数,第一个参数表示范围的开始,第二个参数表示范围的结束。
function range(start, end) { if (arguments.length < 2) { end = start; start = 0; } var result = []; for (var i = start; i <= end; i++) result.push(i); return result; } show(range(4)); show(range(2, 4));
¶ 可选参数的工作方式与上面的 add
示例中的参数略有不同。当没有给出参数时,第一个参数将扮演 end
的角色,start
将变为 0
。
¶ 你可能还记得导言中的这行代码
print(sum(range(1, 10)));
¶ 我们现在有了 range
。要使这行代码工作,我们只需要一个 sum
函数。该函数接受一个包含数字的数组,并返回它们的总和。编写它,应该很简单。
function sum(numbers) { var total = 0; for (var i = 0; i < numbers.length; i++) total += numbers[i]; return total; } print(sum(range(1, 10)));
¶ 第 2 章提到了 Math.max
和 Math.min
函数。现在你知道了,你会注意到它们实际上是存储在名为 Math
的对象中的 max
和 min
属性。这是对象可以扮演的另一个角色:一个存储许多相关值的仓库。
¶ Math
中包含相当多的值,如果将它们全部直接放入全局环境中,它们就会被污染。被占用名称越多,意外覆盖某个变量的值的可能性就越大。例如,给某个东西命名为 max
并不算什么大问题。
¶ 大多数语言会在你定义一个已经存在的名称的变量时阻止你,或者至少会警告你。但 JavaScript 不会。
¶ 无论如何,你可以在 Math
中找到一整套数学函数和常数。所有三角函数都在那里——cos
、sin
、tan
、acos
、asin
、atan
。π 和 e,用大写字母 (PI
和 E
) 表示,这曾经是表示某个东西是常量的时尚方式。pow
是我们一直在编写的 power
函数的良好替代方案,它也接受负数和分数指数。sqrt
求平方根。max
和 min
可以给出两个值的最大值或最小值。 round
、floor
和 ceil
分别将数字四舍五入到最接近的整数、它下面的整数和它上面的整数。
¶ Math
中还有许多其他值,但这本书是入门教程,而不是 参考手册。当你怀疑语言中存在某样东西,但需要找出它的名称或工作原理时,参考手册就是你查询的对象。不幸的是,JavaScript 没有一个全面的完整参考手册。这主要是因为它目前的形态是不同浏览器在不同时间添加不同扩展的混乱过程的结果。导言中提到的 ECMA 标准文档提供了对基本语言的完整文档,但或多或少地难以阅读。对于大多数情况,你最好的选择是 Mozilla 开发者网络.
¶ 也许你已经想到了一个方法来找出 Math
对象中有哪些内容
for (var name in Math) print(name);
¶ 但是,什么也没出现。类似地,当你这样做
for (var name in ["Huey", "Dewey", "Loui"]) print(name);
¶ 你只会看到 0
、1
和 2
,而不是 length
、push
或 join
,它们肯定也在那里。显然,对象的一些属性是隐藏的 。这样做有一个很好的理由:所有对象都有一些方法,例如 toString
,它将对象转换为某种相关的字符串,当你例如在查找存储在对象中的猫时,你不想看到这些方法。
¶ Math
属性被隐藏的原因我并不清楚。可能是有人想让它成为一种神秘的对象。
¶ 你程序添加到对象的所有属性都是可见的。没有办法让它们隐藏,这很不幸,因为正如我们在第 8 章中将会看到的那样,如果能够在不将方法显示在 for
/in
循环中的情况下将方法添加到对象中,那将会很不错。
¶ 有些属性是只读的,你可以获取它们的值,但不能更改它们。例如,字符串值的属性都是只读的。
¶ 其他属性可能是“活动的”。更改它们会导致事情发生。例如,缩短数组的长度会导致多余的元素被丢弃
var array = ["Heaven", "Earth", "Man"]; array.length = 2; show(array);
- 这种方法有一些细微的问题,将在第 8 章中讨论和解决。对于本章,它足够好用。