第 3 版已发布。点击这里阅读

第 4 章
数据结构:对象和数组

有两次,有人问我,“巴贝奇先生,如果你把错误的数字输入机器,它会输出正确的答案吗?”[...] 我无法理解这种混乱的思想会引发这样的问题。

查尔斯·巴贝奇,哲学家生活片段 (1864)

数字、布尔值和字符串是构建数据结构的砖块。但你无法用一块砖建造一座房子。对象使我们能够将值(包括其他对象)分组在一起,从而构建更复杂的数据结构。

到目前为止,我们构建的程序严重受到简单数据类型的限制。本章将向您的工具箱添加对数据结构的基本理解。在本章结束后,您将拥有足够的知识来开始编写一些有用的程序。

本章将通过一个或多或少的现实编程示例进行讲解,并在需要的时候介绍相应的概念。示例代码将经常建立在之前章节中介绍的函数和变量的基础之上。

会变成松鼠的人

每隔一段时间,通常在晚上 8 点到 10 点之间,雅克就会发现自己变成了一个小小的毛茸茸的啮齿动物,长着一条蓬松的尾巴。

一方面,雅克很高兴他没有患上传统的狼人症。变成松鼠往往比变成狼引起的问题少。他不必担心不小心吃了邻居(会很尴尬),而是担心被邻居家的猫吃掉。在两次醒来时发现自己赤裸裸地迷失在橡树顶部的细枝上后,他开始在晚上锁好房间的门窗,并在地板上放几颗核桃来让自己有事可做。

The weresquirrel

这样就解决了猫和橡树的问题。但雅克仍然遭受着这种病情的困扰。这种变换的不规律性让他怀疑它们可能是由某些东西触发的。有一段时间,他认为这种变化只发生在他接触树木的日子。所以他完全停止了接触树木,甚至避免靠近树木。但问题依然存在。

雅克打算采用更科学的方法,开始每天记录下自己做的事情以及是否发生了变化。他希望通过这些数据缩小触发变化的条件范围。

他做的第一件事就是设计一种数据结构来存储这些信息。

数据集

要处理一块数字数据,我们首先需要找到一种方法来将其表示在机器的内存中。例如,假设我们想表示一组数字:2、3、5、7 和 11。

我们可以用字符串来进行创造性的表达——毕竟,字符串可以是任意长度的,所以我们可以将大量数据存储在字符串中——并使用 "2 3 5 7 11" 来表示这些数字。但这很麻烦。您需要以某种方式提取数字并将它们转换回数字才能访问它们。

幸运的是,JavaScript 提供了一种专门用于存储值序列的数据类型。它被称为数组,用方括号括起来的逗号分隔的值列表表示。

var listOfNumbers = [2, 3, 5, 7, 11];
console.log(listOfNumbers[2]);
// → 5
console.log(listOfNumbers[2 - 1]);
// → 3

访问数组中元素的表示法也使用方括号。表达式后紧跟一对方括号,方括号内包含另一个表达式,将查找左侧表达式中对应于括号内表达式所表示的索引的元素。

数组的第一个索引是零,而不是一。因此,第一个元素可以通过 listOfNumbers[0] 读取。如果您没有编程背景,这种约定可能需要一些时间才能习惯。但基于零的计数在技术领域有着悠久的传统,只要这种约定始终如一地遵循(在 JavaScript 中就是这样),它就可以很好地工作。

属性

我们在之前的示例中看到了一些可疑的表达式,比如 myString.length(获取字符串的长度)和 Math.max(最大值函数)。这些表达式访问某个值的属性。在第一个示例中,我们访问了 myString 中的值的 length 属性。在第二个示例中,我们访问了 Math 对象(它是一个包含数学相关值和函数的集合)中的名为 max 的属性。

几乎所有 JavaScript 值都有属性。例外情况是 nullundefined。如果您尝试访问这些非值之一的属性,就会出现错误。

null.length;
// → TypeError: Cannot read property 'length' of null

在 JavaScript 中访问属性的两种最常见的方法是使用点和方括号。value.xvalue[x] 都会访问 value 的属性——但并不一定是同一个属性。区别在于 x 的解释方式。当使用点时,点后的部分必须是有效的变量名,它直接表示属性。当使用方括号时,方括号之间的表达式会被求值以获得属性名。value.x 获取名为“x”的 value 属性,而 value[x] 尝试求值表达式 x 并将结果用作属性名。

因此,如果您知道您感兴趣的属性名为“length”,您就说 value.length。如果您想提取由变量 i 中的值表示的属性,您就说 value[i]。并且由于属性名可以是任何字符串,如果您想访问名为“2”或“John Doe”的属性,您必须使用方括号:value[2]value["John Doe"]。即使您提前知道属性的精确名称,这也是必需的,因为“2”和“John Doe”都不是有效的变量名,因此无法通过点表示法访问它们。

数组中的元素存储在属性中。由于这些属性的名称是数字,我们经常需要从变量中获取它们的名称,因此我们必须使用方括号语法来访问它们。数组的 length 属性告诉我们它包含多少个元素。此属性名称是一个有效的变量名,并且我们事先知道它的名称,因此要查找数组的长度,您通常会写 array.length,因为这样写起来比 array["length"] 更容易。

方法

除了 length 属性之外,字符串和数组对象还包含许多指向函数值的属性。

var doh = "Doh";
console.log(typeof doh.toUpperCase);
// → function
console.log(doh.toUpperCase());
// → DOH

每个字符串都有一个 toUpperCase 属性。当被调用时,它将返回字符串的副本,其中所有字母都已转换为大写。还有 toLowerCase。您可以猜到它的作用。

有趣的是,即使调用 toUpperCase 没有传递任何参数,但该函数以某种方式可以访问字符串 "Doh",也就是我们调用其属性的值。这将在第 6 章中进行解释。

包含函数的属性通常被称为其所属值的方法。例如,“toUpperCase 是字符串的方法”。

此示例演示了数组对象具有的某些方法。

var mack = [];
mack.push("Mack");
mack.push("the", "Knife");
console.log(mack);
// → ["Mack", "the", "Knife"]
console.log(mack.join(" "));
// → Mack the Knife
console.log(mack.pop());
// → Knife
console.log(mack);
// → ["Mack", "the"]

push 方法可用于在数组末尾添加值。pop 方法的作用相反:它删除数组末尾的值并将其返回。可以使用 join 方法将字符串数组展平成单个字符串。传递给 join 的参数确定粘贴在数组元素之间的文本。

对象

回到会变成松鼠的人。一组每日日志条目可以用数组表示。但条目不只是包含数字或字符串——每个条目都需要存储活动列表和一个布尔值,该值指示雅克是否变成了松鼠。理想情况下,我们希望将这些值组合在一起形成一个单一的值,然后将这些组合的值放入日志条目的数组中。

对象类型的值是属性的任意集合,我们可以根据需要添加或删除这些属性。创建对象的一种方法是使用花括号表示法。

var day1 = {
  squirrel: false,
  events: ["work", "touched tree", "pizza", "running",
           "television"]
};
console.log(day1.squirrel);
// → false
console.log(day1.wolf);
// → undefined
day1.wolf = false;
console.log(day1.wolf);
// → false

在花括号内,我们可以提供以逗号分隔的属性列表。每个属性都写成名称,后跟一个冒号,然后是一个表达式,该表达式提供属性的值。空格和换行符没有意义。当对象跨越多行时,像在前面的示例中那样缩进它可以提高可读性。名称不是有效的变量名或有效数字的属性必须用引号括起来。

var descriptions = {
  work: "Went to work",
  "touched tree": "Touched a tree"
};

这意味着花括号在 JavaScript 中有两种含义。在语句的开头,它们开始一个语句块。在任何其他位置,它们都表示一个对象。幸运的是,在语句的开头使用花括号对象几乎没有用处,并且在典型的程序中,这两种用法之间几乎不存在歧义。

读取不存在的属性将产生 undefined 值,这在我们第一次尝试读取前面的示例中的 wolf 属性时会发生。

可以使用 = 运算符将值赋予属性表达式。如果属性已存在,这将替换属性的值;如果属性不存在,这将创建一个新的属性。

简要回到我们的变量绑定触手模型——属性绑定类似。它们抓住值,但其他变量和属性可能会持有相同的这些值。您可以将对象想象成拥有任意数量触手的章鱼,每个触手上都刻着它的名字。

Artist's representation of an object

delete 运算符会从这样的章鱼身上切断一条触手。它是一个一元运算符,当应用于属性访问表达式时,会从对象中删除指定的属性。这不是一件常见的事,但它是可行的。

var 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 和实际删除它之间的区别在于,在第一种情况下,对象仍然具有该属性(只是没有一个很有趣的值),而在第二种情况下,该属性不再存在,并且 in 将返回 false

因此,数组只是专门用于存储事物序列的一种对象。如果评估 typeof [1, 2],这将产生 "object"。你可以将它们看作是长而扁平的章鱼,所有触手都整齐地排列在一起,并用数字标记。

Artist's representation of an array

所以我们可以用一个对象数组来表示雅克的日记。

var 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 时,我们可以认为它们是完全相同的数字,无论它们是否引用相同的物理位。但是对于对象,有两个引用指向同一个对象和有两个不同的对象包含相同的属性之间是有区别的。考虑以下代码

var object1 = {value: 10};
var object2 = object1;
var 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

object1object2 变量指向同一个对象,这就是为什么更改 object1 也会更改 object2 的值的原因。变量 object3 指向一个不同的对象,该对象最初包含与 object1 相同的属性,但它具有独立的生命周期。

JavaScript 的 == 运算符,在比较对象时,只有当两个对象是完全相同的值时才会返回 true。比较不同的对象将返回 false,即使它们具有相同的内容。JavaScript 中没有内置的“深度”比较运算,它会查看对象的内容,但你可以自己编写(这将是本章末尾的练习之一)。

狼人的日志

所以雅克启动了他的 JavaScript 解释器,并设置了记录日记所需的環境。

var journal = [];

function addEntry(events, didITurnIntoASquirrel) {
  journal.push({
    events: events,
    squirrel: didITurnIntoASquirrel
  });
}

然后,每天晚上十点——或者有时是第二天早上,从书架的最高层爬下来后——他记录下这一天。

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);

一旦他有了足够的数据点,他就打算计算他的松鼠化与当天事件之间的相关性,并希望从这些相关性中学习一些有用的东西。

相关性是衡量变量之间依赖关系的指标(这里的“变量”指的是统计学意义上的变量,而不是 JavaScript 意义上的变量)。它通常用一个介于 -1 到 1 之间的系数来表示。零相关性意味着变量之间没有关系,而相关性为 1 表示两者完全相关——如果你知道一个,你也知道另一个。负一也意味着变量是完全相关的,但它们是相反的——当一个为真时,另一个为假。

对于二元(布尔)变量,φ 系数 (ϕ) 提供了一个良好的相关性度量,并且计算起来相对容易。为了计算 ϕ,我们需要一个包含对两个变量的各种组合进行的观测次数的表 n。例如,我们可以将吃披萨的事件放入这样的表格中

Eating pizza versus turning into a squirrel

ϕ 可以使用以下公式计算,其中 n 指的是表格

ϕ =
n11n00 - n10n01
n1•n0•n•1n•0

符号 n01 表示第一个变量(松鼠化)为假 (0) 而第二个变量(披萨)为真 (1) 的测量次数。在这个例子中,n01 为 9。

n1• 指的是第一个变量为真的所有测量值的总和,在这个例子中为 5。同样,n•0 指的是第二个变量为假的测量值的总和。

因此,对于披萨表格,除法线以上的部分(被除数)将是 1×76 - 4×9 = 40,而除法线以下的部分(除数)将是 5×85×10×80 的平方根,也就是 √340000。这使得 ϕ ≈ 0.069,这是一个很小的值。吃披萨似乎对转化没有影响。

计算相关性

我们可以用一个四元素数组([76, 9, 4, 1])在 JavaScript 中表示一个 2×2 的表格。我们也可以使用其他表示方法,例如包含两个两个元素数组的数组([[76, 9], [4, 1]])或使用 "11""01" 作为属性名的对象,但扁平数组很简单,并且使访问表格的表达式很简洁。我们将数组的索引解释为两位二进制数,其中最左边的(最高有效)位指的是松鼠变量,而最右边的(最低有效)位指的是事件变量。例如,二进制数 10 指的是雅克确实变成了松鼠,但事件(比如“披萨”)没有发生的情况。这种情况发生了四次。由于二进制 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 对象提供。我们必须将表格中的两个字段相加才能获得像 n1• 这样的字段,因为我们的数据结构中没有直接存储行或列的总和。

雅克记录了三个月的日记。生成的数据集在本章的编码沙箱中可用,它存储在 JOURNAL 变量中,并且可以在一个可下载的文件中获得。

要从这个日记中提取一个特定事件的 2×2 表格,我们必须遍历所有条目,并统计该事件与松鼠转化相关联发生的次数。

function hasEvent(event, entry) {
  return entry.events.indexOf(event) != -1;
}

function tableFor(event, journal) {
  var table = [0, 0, 0, 0];
  for (var i = 0; i < journal.length; i++) {
    var entry = journal[i], index = 0;
    if (hasEvent(event, entry)) index += 1;
    if (entry.squirrel) index += 2;
    table[index] += 1;
  }
  return table;
}

console.log(tableFor("pizza", JOURNAL));
// → [76, 9, 4, 1]

hasEvent 函数测试一个条目是否包含给定的事件。数组有一个 indexOf 方法,该方法试图在数组中找到给定的值(在本例中是事件名称),并返回找到它的索引,如果找不到则返回 -1。因此,如果对 indexOf 的调用没有返回 -1,那么我们就知道在该条目中找到了该事件。

tableFor 中循环的代码体通过检查条目是否包含它感兴趣的特定事件以及该事件是否与松鼠事件一起发生来确定每个日记条目属于表格中的哪个框。然后,循环将该框对应的数组中的数字加 1。

现在我们有了计算单个相关性的工具。剩下的唯一步骤是为每个记录的事件类型找到相关性,看看是否有什么突出。但是,我们应该如何存储这些相关性,一旦我们计算出来呢?

对象作为映射

一种可能的方法是将所有相关性存储在一个数组中,使用具有 namevalue 属性的对象。但这使得查找给定事件的相关性变得有些麻烦:你必须遍历整个数组才能找到具有正确 name 的对象。我们可以将这个查找过程封装在一个函数中,但我们仍然需要编写更多代码,并且计算机需要做更多的工作。

一个更好的方法是使用以事件类型命名的对象属性。我们可以使用方括号访问符号来创建和读取属性,并可以使用 in 运算符来测试给定的属性是否存在。

var map = {};
function storePhi(event, phi) {
  map[event] = phi;
}

storePhi("pizza", 0.069);
storePhi("touched tree", -0.081);
console.log("pizza" in map);
// → true
console.log(map["touched tree"]);
// → -0.081

映射是一种从一个域的值(在本例中是事件名称)到另一个域的相应值(在本例中是 ϕ 系数)的方法。

使用这样的对象有一些潜在的问题,我们将在第 6 章中讨论,但现在,我们不用担心这些问题。

如果我们想找到所有我们为其存储了系数的事件呢?属性不像数组那样形成一个可预测的序列,因此我们无法使用普通的 for 循环。JavaScript 提供了一种专门用于遍历对象属性的循环结构。它看起来有点像一个普通的 for 循环,但它使用 in 这个词来区分自己。

for (var event in map)
  console.log("The correlation for '" + event +
              "' is " + map[event]);
// → The correlation for 'pizza' is 0.069
// → The correlation for 'touched tree' is -0.081

最终分析

为了找到数据集中存在的所有事件类型,我们只需依次处理每个条目,然后遍历该条目中的事件。我们维护一个对象 phis,它包含到目前为止我们看到的每种事件类型的相关系数。每当我们遇到一个不在 phis 对象中的类型时,我们计算其相关性,并将其添加到该对象中。

function gatherCorrelations(journal) {
  var phis = {};
  for (var entry = 0; entry < journal.length; entry++) {
    var events = journal[entry].events;
    for (var i = 0; i < events.length; i++) {
      var event = events[i];
      if (!(event in phis))
        phis[event] = phi(tableFor(event, journal));
    }
  }
  return phis;
}

var correlations = gatherCorrelations(JOURNAL);
console.log(correlations.pizza);
// → 0.068599434

让我们看看结果。

for (var event in correlations)
  console.log(event + ": " + correlations[event]);
// → carrot:   0.0140970969
// → exercise: 0.0685994341
// → weekend:  0.1371988681
// → bread:   -0.0757554019
// → pudding: -0.0648203724
// and so on...

大多数相关性似乎接近于零。吃胡萝卜、面包或布丁显然不会引发松鼠人。然而,它似乎在周末发生得更频繁。让我们过滤结果,只显示大于 0.1 或小于 -0.1 的相关性。

for (var event in correlations) {
  var correlation = correlations[event];
  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 (var i = 0; i < JOURNAL.length; i++) {
  var entry = JOURNAL[i];
  if (hasEvent("peanuts", entry) &&
     !hasEvent("brushed teeth", entry))
    entry.events.push("peanut teeth");
}
console.log(phi(tableFor("peanut teeth", JOURNAL)));
// → 1

嗯,这太明显了!这种现象正好发生在雅克吃花生却没有刷牙的时候。如果他不是那么邋遢,不注意牙齿卫生,他可能根本不会注意到自己的病。

知道了这一点,雅克干脆不再吃花生,发现这完全结束了他的变形。

雅克好了一段时间。但几年后,他丢了工作,最终被迫加入马戏团,在那里他以“神奇的松鼠人”的身份表演,在每次演出前都用花生酱塞满嘴巴。有一天,厌倦了这种可怜的生活,雅克没有变回人形,从马戏团帐篷的缝隙里跳了出去,消失在森林里。他再也没有出现过。

进一步的阵列学

在结束本章之前,我想向您介绍一些与对象相关的概念。我们将从介绍一些普遍有用的数组方法开始。

我们看到了 pushpop,它们在数组末尾添加和删除元素,本章前面提到了。用于在数组开头添加和删除内容的对应方法称为 unshiftshift

var todoList = [];
function rememberTo(task) {
  todoList.push(task);
}
function whatIsNext() {
  return todoList.shift();
}
function urgentlyRememberTo(task) {
  todoList.unshift(task);
}

前面的程序管理任务列表。通过调用 rememberTo("eat") 将任务添加到列表末尾,当您准备好执行某项操作时,调用 whatIsNext() 获取(并删除)列表中的第一个项目。urgentlyRememberTo 函数也会添加一个任务,但会将其添加到列表的开头而不是末尾。

indexOf 方法有一个名为 lastIndexOf 的兄弟方法,它从数组末尾而不是开头开始搜索给定元素。

console.log([1, 2, 3, 2, 1].indexOf(2));
// → 1
console.log([1, 2, 3, 2, 1].lastIndexOf(2));
// → 3

indexOflastIndexOf 都有一个可选的第二个参数,指示从哪里开始搜索。

另一个基本方法是 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]

当没有给出结束索引时,slice 将获取开始索引之后的全部元素。字符串也有一个 slice 方法,效果类似。

concat 方法可以用来将数组粘合在一起,类似于 + 运算符对字符串的作用。以下示例展示了 concatslice 的工作原理。它接受一个数组和一个索引,并返回一个新数组,该数组是原始数组的副本,其中已删除给定索引处的元素。

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"]

字符串及其属性

我们可以从字符串值中读取 lengthtoUpperCase 之类的属性。但是如果您尝试添加一个新属性,它不会保留。

var myString = "Fido";
myString.myProperty = "value";
console.log(myString.myProperty);
// → undefined

字符串、数字和布尔值类型不是对象,尽管语言不会在您尝试为它们设置新属性时报错,但它实际上并没有存储这些属性。这些值是不可变的,不能更改。

但是这些类型确实有一些内置属性。每个字符串值都有若干方法。最常用的可能是 sliceindexOf,它们类似于同名的数组方法。

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

我们已经看到了字符串类型的 length 属性。访问字符串中的单个字符可以使用 charAt 方法,也可以简单地读取数字属性,就像您对数组所做的那样。

var string = "abc";
console.log(string.length);
// → 3
console.log(string.charAt(0));
// → a
console.log(string[1]);
// → b

arguments 对象

无论何时调用函数,都会在函数体运行的环境中添加一个名为 arguments 的特殊变量。此变量引用一个包含传递给函数的所有参数的对象。请记住,在 JavaScript 中,您可以传递给函数的参数比函数本身声明的参数多(或少)。

function noArguments() {}
noArguments(1, 2, 3); // This is okay
function threeArguments(a, b, c) {}
threeArguments(); // And so is this

arguments 对象有一个 length 属性,它告诉我们实际传递给函数的参数数量。它还有一个名为 0、1、2 等等的属性,用于每个参数。

如果这听起来很像一个数组,您是对的,它确实很像一个数组。但是这个对象不幸的是没有数组方法(比如 sliceindexOf),所以它比真正的数组难用一些。

function argumentCounter() {
  console.log("You gave me", arguments.length, "arguments.");
}
argumentCounter("Straw man", "Tautology", "Ad hominem");
// → You gave me 3 arguments.

有些函数可以接受任意数量的参数,比如 console.log。这些函数通常会循环遍历其 arguments 对象中的值。它们可以用来创建非常友好的界面。例如,记得我们是如何创建雅克的日记条目吗?

addEntry(["work", "touched tree", "pizza", "running",
          "television"], false);

由于他要经常调用此函数,我们可以创建一个更易于调用的替代函数。

function addEntry(squirrel) {
  var entry = {events: [], squirrel: squirrel};
  for (var i = 1; i < arguments.length; i++)
    entry.events.push(arguments[i]);
  journal.push(entry);
}
addEntry(true, "work", "touched tree", "pizza",
         "running", "television");

此版本按正常方式读取其第一个参数(squirrel),然后遍历其余参数(循环从索引 1 开始,跳过第一个)以将它们收集到一个数组中。

Math 对象

正如我们所见,Math 是一个包含与数字相关的实用函数的大杂烩,例如 Math.max(最大值)、Math.min(最小值)和 Math.sqrt(平方根)。

Math 对象仅用作一个容器,用于将一组相关的功能组合在一起。只有一个 Math 对象,并且它几乎从未用作值。相反,它提供了一个命名空间,这样所有这些函数和值就不必是全局变量。

拥有过多的全局变量会“污染”命名空间。占用的名称越多,您就越有可能意外覆盖某个变量的值。例如,您不太可能想在程序中将某个东西命名为 max。由于 JavaScript 的内置 max 函数安全地嵌套在 Math 对象中,因此我们不必担心覆盖它。

许多语言会在您定义一个已使用名称的变量时阻止您,或者至少会警告您。JavaScript 既不会阻止您,也不会警告您,因此请小心。

回到 Math 对象。如果您需要进行三角运算,Math 可以提供帮助。它包含 cos(余弦)、sin(正弦)和 tan(正切),以及它们的逆函数,分别为 acosasinatan。π(pi)——或者至少是适合 JavaScript 数字的最接近的近似值——以 Math.PI 的形式提供。(有一个旧的编程传统,用全大写字母写常量值的名称。)

function randomPointOnCircle(radius) {
  var 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}

如果您不熟悉正弦和余弦,请不要担心。当它们在本手册中使用时,在第 13 章中,我会解释它们。

前面的示例使用了 Math.random。这是一个函数,每次调用它时都会返回一个介于零(包含)和一(不包含)之间的新伪随机数。

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 会得到一个大于或等于零且小于 10 的数。由于 Math.floor 向下舍入,因此此表达式将以相等的概率生成 0 到 9 之间的任何数字。

还有一些函数,Math.ceil(用于“向上取整”,向上舍入到一个整数)和 Math.round(舍入到最接近的整数)。

全局对象

全局范围,全局变量所在的范围,在 JavaScript 中也可以作为对象访问。每个全局变量都作为此对象的属性存在。在浏览器中,全局范围对象存储在 window 变量中。

var myVar = 10;
console.log("myVar" in window);
// → true
console.log(window.myVar);
// → 10

总结

对象和数组(它们是特定类型的对象)提供了一种将多个值分组到单个值中的方法。从概念上讲,这允许我们将一堆相关的东西放在一个袋子里,拿着这个袋子四处走动,而不是试图用我们的双臂环绕所有单个的东西,并试图单独抓住它们。

大多数 JavaScript 值都具有属性,例外是nullundefined。属性使用 value.propNamevalue["propName"] 访问。对象倾向于使用名称作为它们的属性,并存储或多或少固定的一组属性。另一方面,数组通常包含数量不同的概念上相同的值,并使用数字(从 0 开始)作为其属性的名称。

数组中确实有一些命名的属性,例如 length 和一些方法。方法是存在于属性中的函数,并且(通常)作用于它们所在的属性的值。

对象也可以用作映射,将值与名称相关联。in 运算符可用于找出对象是否包含具有给定名称的属性。相同的关键字也可以在 for 循环(for (var name in object))中使用,以循环遍历对象的属性。

练习

一个范围的总和

本书的介绍提到了以下内容,作为计算一系列数字总和的好方法

console.log(sum(range(1, 10)));

编写一个 range 函数,该函数接受两个参数 startend,并返回一个数组,其中包含从 start 到(包括)end 的所有数字。

接下来,编写一个 sum 函数,该函数接受一个数字数组并返回这些数字的总和。运行之前的程序,看看它是否确实返回 55。

作为额外作业,修改你的 range 函数以接受可选的第三个参数,该参数指示用于构建数组的“步长”值。如果没有给出步长,数组元素将按 1 的增量递增,对应于旧的行为。函数调用 range(1, 10, 2) 应返回 [1, 3, 5, 7, 9]。确保它也能处理负步长值,以便 range(5, 2, -1) 生成 [5, 4, 3, 2]

// Your code here.

console.log(range(1, 10));
// → [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
console.log(range(5, 2, -1));
// → [5, 4, 3, 2]
console.log(sum(range(1, 10)));
// → 55

构建数组最简单的方法是首先将一个变量初始化为 [](一个新的空数组),并反复调用其 push 方法添加一个值。不要忘记在函数结束时返回数组。

由于结束边界是包含的,因此你需要使用 <= 运算符而不是仅仅 < 来检查循环的结束。

要检查是否给出了可选的步长参数,可以检查 arguments.length 或将参数的值与 undefined 进行比较。如果没有给出,只需在函数顶部将其设置为默认值 (1)。

range 理解负步长值,最好的方法可能是编写两个单独的循环——一个用于向上计数,一个用于向下计数——因为当向下计数时,检查循环是否结束的比较必须是 >= 而不是 <=

当范围的结束小于开始时,使用不同的默认步长 -1 也是值得的。这样,range(5, 2) 会返回一些有意义的东西,而不是陷入无限循环。

反转数组

数组有一个方法 reverse,它通过反转其元素的出现顺序来更改数组。对于此练习,编写两个函数 reverseArrayreverseArrayInPlace。第一个 reverseArray 以数组作为参数,并生成一个新的数组,该数组包含相同元素,但顺序相反。第二个 reverseArrayInPlace 执行 reverse 方法所做的事情:它修改作为参数给出的数组,以反转其元素。两者都不能使用标准的 reverse 方法。

回顾一下上一章关于副作用和纯函数的说明,你预期哪种变体在更多情况下有用?哪一个更有效?

// Your code here.

console.log(reverseArray(["A", "B", "C"]));
// → ["C", "B", "A"];
var arrayValue = [1, 2, 3, 4, 5];
reverseArrayInPlace(arrayValue);
console.log(arrayValue);
// → [5, 4, 3, 2, 1]

实现 reverseArray 有两种显而易见的方法。第一种方法是简单地从前往后遍历输入数组,并使用新数组的 unshift 方法在它的开头插入每个元素。第二种方法是反向遍历输入数组,并使用 push 方法。反向迭代数组需要一个(有点笨拙的)for 规范,例如 (var i = array.length - 1; i >= 0; i--)

反转数组 inplace 更难。你必须小心不要覆盖你以后需要使用的元素。使用 reverseArray 或其他方法复制整个数组(array.slice(0) 是复制数组的好方法)可以,但算作作弊。

诀窍是交换第一个和最后一个元素,然后交换第二个和倒数第二个元素,依此类推。你可以通过遍历数组一半的长度(使用 Math.floor 向下取整——你不需要触碰奇数长度数组的中间元素)并将位置 i 处的元素与位置 array.length - 1 - i 处的元素进行交换来做到这一点。你可以使用一个局部变量暂时保存其中一个元素,用它的镜像覆盖它,然后将局部变量中的值放在镜像以前所在的位置。

列表

对象作为值的通用块,可以用来构建各种数据结构。列表(不要与数组混淆)是一种常见的数据结构。列表是一组嵌套的对象,第一个对象保存对第二个对象的引用,第二个对象保存对第三个对象的引用,依此类推。

var list = {
  value: 1,
  rest: {
    value: 2,
    rest: {
      value: 3,
      rest: null
    }
  }
};

生成的对象形成一条链,如下所示

A linked list

列表的一个好处是它们可以共享结构的一部分。例如,如果我创建了两个新值 {value: 0, rest: list}{value: -1, rest: list}(其中 list 指向前面定义的变量),它们都是独立的列表,但它们共享构成最后三个元素的结构。此外,原始列表仍然是一个有效的三个元素列表。

编写一个函数 arrayToList,该函数在给定 [1, 2, 3] 作为参数时,构建与之前类似的数据结构,并编写一个 listToArray 函数,该函数从列表生成数组。还编写辅助函数 prepend,它接受一个元素和一个列表,并创建一个新列表,该列表将元素添加到输入列表的前面,以及 nth,它接受一个列表和一个数字,并返回列表中给定位置的元素,或者当没有这样的元素时返回 undefined

如果你还没有,也编写一个 nth 的递归版本。

// Your code here.

console.log(arrayToList([10, 20]));
// → {value: 10, rest: {value: 20, rest: null}}
console.log(listToArray(arrayToList([10, 20, 30])));
// → [10, 20, 30]
console.log(prepend(10, prepend(20, null)));
// → {value: 10, rest: {value: 20, rest: null}}
console.log(nth(arrayToList([10, 20, 30]), 1));
// → 20

构建列表最好从后往前进行。所以 arrayToList 可以反向遍历数组(见上一个练习),并且对于每个元素,向列表添加一个对象。你可以使用一个局部变量来保存迄今为止构建的列表的一部分,并使用类似 list = {value: X, rest: list} 的模式添加元素。

要遍历列表(在 listToArraynth 中),可以使用以下 for 循环规范

for (var node = list; node; node = node.rest) {}

你能看到它如何工作吗?循环的每次迭代,node 都指向当前的子列表,并且主体可以读取其 value 属性以获取当前元素。在每次迭代结束时,node 将移动到下一个子列表。当它为 null 时,我们已经到达列表的末尾,循环结束。

nth 的递归版本同样会查看列表“尾部”越来越小的一部分,同时将索引递减,直到它达到 0,此时它可以返回它正在查看的节点的 value 属性。要获取列表的第零个元素,你只需获取其头部节点的 value 属性。要获取第 N + 1 个元素,你只需获取此列表的 rest 属性中列表的第 N 个元素。

深度比较

== 运算符通过标识比较对象。但有时,你可能希望比较它们实际属性的值。

编写一个函数 deepEqual,它接受两个值,当且仅当它们是相同的值,或者它们是具有相同属性的对象,并且这些属性的值在使用对 deepEqual 的递归调用进行比较时也是相等时,才返回 true。

要确定是通过标识比较两个东西(为此使用 === 运算符)还是通过查看它们的属性来比较它们,你可以使用 typeof 运算符。如果它对两个值都生成 "object",那么你应该进行深度比较。但是你必须考虑到一个愚蠢的例外:由于历史原因,typeof null 也生成 "object"

// Your code here.

var obj = {here: {is: "an"}, object: 2};
console.log(deepEqual(obj, obj));
// → true
console.log(deepEqual(obj, {here: 1, object: 2}));
// → false
console.log(deepEqual(obj, {here: {is: "an"}, object: 2}));
// → true

你用于确定是否正在处理真实对象的测试将类似于 typeof x == "object" && x != null。务必仅当两个参数都是对象时才比较属性。在所有其他情况下,你可以立即返回应用 === 的结果。

使用 for/in 循环遍历属性。你需要测试这两个对象是否具有相同的一组属性名,以及这些属性的值是否相同。第一个测试可以通过计算两个对象中的属性,并在属性数量不同时返回 false 来完成。如果相同,则遍历一个对象的属性,并针对其中每个属性,验证另一个对象也具有该属性。属性的值通过对 deepEqual 的递归调用进行比较。

从函数中返回正确的值,最好的方法是在发现不匹配时立即返回 false,并在函数结束时返回 true。