第 4 版现已推出。点击此处阅读

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

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

查尔斯·巴贝奇,哲学家生涯片段(1864)
Picture of a weresquirrel

数字、布尔值和字符串是构建数据结构的原子。但许多类型的的信息都需要不止一个原子。对象允许我们对值(包括其他对象)进行分组,从而构建更复杂的结构。

到目前为止,我们编写的程序一直受限于只操作简单数据类型。本章将介绍基本数据结构。学完本章后,你将具备编写实用程序的足够知识。

本章将逐步讲解一个或多或少现实的编程示例,并根据问题的需要介绍相关概念。示例代码通常会基于之前章节介绍的函数和绑定。

会变身松鼠

偶尔,通常在晚上 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 中的值的 length 属性。在第二种情况下,我们访问了 Math 对象(一个包含与数学相关的常量和函数的集合)中名为 max 的属性。

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

null.length;
// → TypeError: null has no properties

在 JavaScript 中访问属性的两种主要方法是使用点和方括号。value.xvalue[x] 都访问 value 的属性——但并不一定是同一个属性。区别在于如何解释 x。使用点时,点后面的词是属性的字面名称。使用方括号时,方括号之间的表达式会被计算以获取属性名称。value.x 获取 value 中名为“x”的属性,而 value[x] 尝试计算表达式 x,并将结果(转换为字符串)用作属性名称。

因此,如果你知道你感兴趣的属性名为color,你就可以说 value.color。如果你想提取绑定 i 中保存的值所命名的属性,你可以说 value[i]。属性名称是字符串。它们可以是任何字符串,但点表示法只能用于看起来像有效绑定名称的名称。因此,如果你想访问名为2John Doe 的属性,你必须使用方括号:value[2]value["John Doe"]

数组中的元素作为数组的属性存储,使用数字作为属性名称。因为你不能对数字使用点表示法,并且通常希望使用保存索引的绑定,所以你必须使用方括号表示法来获取它们。

数组的 length 属性告诉我们它包含多少个元素。此属性名称是一个有效的绑定名称,并且我们事先知道它的名称,因此要查找数组的长度,你通常会编写 array.length,因为这比 array["length"] 更容易编写。

方法

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

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

每个字符串都有一个 toUpperCase 属性。调用它时,它将返回字符串的副本,其中所有字母都已转换为大写。也有 toLowerCase,反过来进行。

有趣的是,尽管调用 toUpperCase 没有传递任何参数,但该函数不知何故可以访问字符串 "Doh",即我们调用了其属性的值。第 6 章中将介绍其工作原理。

包含函数的属性通常称为它们所属值的方法,例如“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 方法则相反,移除数组中的最后一个值并将其返回。

这些有点愚蠢的名称是操作堆栈的传统术语。在编程中,堆栈是一种数据结构,允许你将值压入其中,并以相反的顺序弹出它们,以便最后添加的值先被移除。这些在编程中很常见——你可能还记得上一章中介绍的函数调用堆栈,它是同一个概念的一个实例。

对象

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

对象类型的值是属性的任意集合。创建对象的一种方法是使用大括号作为表达式。

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 中有两种含义。在语句开头,它们开始一个语句块。在任何其他位置,它们描述一个对象。幸运的是,在语句开头使用大括号括起来的对象很少有用,因此这两种含义之间的歧义问题不大。

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

object1object2绑定抓住了同一个对象,这就是为什么更改object1也会更改object2的值的原因。它们被认为具有相同的标识。绑定object3指向一个不同的对象,该对象最初包含与object1相同的属性,但处于独立状态。

绑定也可以是可变的或常量的,但这与它们的值行为无关。即使数字值不会改变,也可以使用let绑定来跟踪不断变化的数字,方法是更改绑定指向的值。类似地,虽然const绑定到对象的绑定本身不能更改,并且将继续指向同一个对象,但该对象的内容可能会更改。

const score = {visitors: 0, home: 0};
// This is okay
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那样声明属性,它只是给出了一个属性名称。这是一种简写,意思相同——如果大括号表示法中的属性名称后面没有跟值,它的值将从具有相同名称的绑定中获取。

所以,每天晚上 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 到 1 之间的数字,它描述了相关性。

可以将吃披萨的事件放入类似这样的频数表中,其中每个数字都表示该组合在测量值中出现的次数

Eating pizza versus turning into a squirrel

如果将该表称为n,可以使用以下公式计算ϕ

ϕ =
n11n00n10n01
n1•n0•n•1n•0

(如果此时你正在放下书,专注于对 10 年级数学课的可怕回忆——坚持住!我无意用无尽的难以理解的符号折磨你——现在只是这个公式而已。即使有了这个公式,我们所做的也仅仅是将它转化为 JavaScript。)

符号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 中表示一个二乘二表。也可以使用其他表示形式,例如包含两个二元素数组的数组 ([[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绑定中,并存储在一个可下载的文件中。

为了从日记中提取特定事件的二乘二表,必须遍历所有条目并统计该事件相对于松鼠变形发生的次数。

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后面的值的元素。这不仅适用于数组,也适用于字符串和其他一些数据结构。我们将在第6章中讨论它如何工作。

最终分析

我们需要计算数据集中发生的每种事件类型的相关性。为此,首先我们需要找到每种事件类型。

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

通过遍历所有事件并将那些不在events数组中的事件添加到数组中,该函数收集了每种事件类型。

利用它,我们可以看到所有相关性。

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

这是一个强有力的结果。这种现象恰好发生在雅克吃花生却没有刷牙的时候。如果他不是那么邋遢,不注重牙齿卫生,他可能根本不会注意到自己的病症。

知道了这一点,雅克彻底停止了吃花生,发现自己的变形没有再出现。

几年来,雅克的生活都很顺利。但不知何时,他失去了工作。由于他生活在一个糟糕的国家,没有工作就意味着没有医疗服务,他被迫到马戏团工作,在那里他以“神奇的松鼠人”的身份表演,每场演出前都往嘴里塞花生酱。

有一天,雅克厌倦了这种悲惨的生活,没能变回人形,跳过马戏团帐篷的裂缝,消失在森林里。他再也没有出现过。

进一步的数组学

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

我们看到了pushpop,它们分别在数组的末尾添加和删除元素,本章前面已经介绍过了。用于在数组开头添加和删除元素的相应方法分别称为unshiftshift

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

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将取开始索引之后的所有元素。你也可以省略开始索引来复制整个数组。

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

如果你向concat传递一个不是数组的参数,那么该值将被添加到新数组中,就像它是一个包含一个元素的数组一样。

字符串及其属性

我们可以从字符串值中读取诸如lengthtoUpperCase之类的属性。但如果你试图添加一个新的属性,它不会被保存。

let kim = "Kim";
kim.age = 88;
console.log(kim.age);
// → 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

来自上一章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

我们已经看到了字符串类型的length属性。访问字符串中的单个字符看起来像访问数组元素(有一个我们在第5章中将讨论的警告)。

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

Math 对象

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

Math对象被用作一个容器来分组一组相关的功能。只有一个Math对象,它几乎没有用作值。相反,它提供了一个命名空间,因此所有这些函数和值不必是全局绑定。

拥有过多的全局绑定会“污染”命名空间。占用的名称越多,你越有可能意外覆盖某个现有绑定的值。例如,在你的某个程序中想要命名为max并不奇怪。由于JavaScript的内置max函数安全地隐藏在Math对象中,所以我们不必担心覆盖它。

许多语言会在你定义一个已经存在的名称的绑定时阻止你,或者至少会警告你。JavaScript对于用letconst声明的绑定会这样做,但——奇怪的是——对于标准绑定以及用varfunction声明的绑定不会这样做。

回到 Math 对象。如果你需要进行三角函数运算,Math 可以提供帮助。它包含 cos(余弦)、sin(正弦)和 tan(正切),以及它们的逆函数 acosasinatan。数字 π(pi)——或者至少最接近的 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}

如果你不熟悉正弦和余弦,不用担心。在本手册的 第 14 章 中使用它们时,我会解释它们。

前面的示例使用了 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));
}

这对于使用 letvarconst 创建的绑定也适用。如果你知道要绑定的值是一个数组,可以使用方括号“查看”该值内部,并将它的内容绑定起来。

类似的技巧适用于对象,使用大括号而不是方括号。

let {name} = {name: "Faraji", age: 23};
console.log(name);
// → Faraji

请注意,如果你尝试解构 nullundefined,会报错,就像直接尝试访问这些值的属性一样。

JSON

因为属性只包含它们的价值,而不是包含它们本身,所以对象和数组在计算机的内存中被存储为一系列位,这些位包含它们内容的地址——它们在内存中的位置。因此,包含另一个数组的数组(至少)包含一个内部数组的内存区域,以及另一个外部数组的内存区域,其中包含(除其他外)表示内部数组位置的二进制数。

如果你想将数据保存到文件中以备后用,或者通过网络将其发送到另一台计算机,你必须以某种方式将这些内存地址的纠缠转换为可以存储或发送的描述。我想,你可以将整个计算机内存以及你感兴趣的值的地址一起发送过去,但这似乎不是最好的方法。

我们可以做的是序列化数据。这意味着它被转换为一个扁平的描述。一种流行的序列化格式称为 JSON(发音为“Jason”),代表 JavaScript 对象表示法。它在 Web 上被广泛用作数据存储和通信格式,即使在 JavaScript 以外的其他语言中也是如此。

JSON 看起来类似于 JavaScript 编写数组和对象的方式,但也有一些限制。所有属性名都必须用双引号括起来,并且只允许简单的表达式——不允许多个函数调用、绑定或涉及实际计算的任何内容。JSON 中不允许使用注释。

用 JSON 数据表示的日记条目可能看起来像这样

{
  "squirrel": false,
  "events": ["work", "touched tree", "pizza", "running"]
}

JavaScript 提供了 JSON.stringifyJSON.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 中的大多数值都有属性,例外的是 nullundefined。属性可以通过 value.propvalue["prop"] 访问。对象倾向于使用名称作为它们的属性,并存储或多或少固定的属性集。另一方面,数组通常包含数量可变的概念上相同的价值,并且使用数字(从 0 开始)作为它们的属性名称。

数组中确实有一些命名的属性,例如 length 和一些方法。方法是驻留在属性中的函数,它们(通常)会对它们所属的值进行操作。

你可以使用一种特殊的 for 循环——for (let element of array)——来遍历数组。

练习

一个范围的总和

本手册的 简介 提到了以下内容,作为计算一系列数字之和的好方法

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 方法来添加值。不要忘记在函数末尾返回数组。

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

步长参数可以是一个可选参数,其默认值(使用 = 运算符)为 1。

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

可能也值得使用一个不同的默认步长,即 -1,当范围的结束小于开始时。这样,range(5, 2) 就会返回一些有意义的东西,而不是陷入无限循环。可以在参数的默认值中引用前面的参数。

反转数组

数组有一个 reverse 方法,它通过颠倒元素出现的顺序来改变数组。在本练习中,编写两个函数 reverseArrayreverseArrayInPlace。第一个 reverseArray 以数组为参数,并生成一个新的数组,该数组具有相同元素,但顺序相反。第二个 reverseArrayInPlace 执行 reverse 方法所做的事情:它通过反转元素来修改给定为参数的数组。两者都不能使用标准的 reverse 方法。

回顾 上一章 中关于副作用和纯函数的说明,你预计哪种变体在更多情况下有用?哪一个运行速度更快?

// Your code here.

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

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

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

诀窍是交换第一个和最后一个元素,然后交换第二个和倒数第二个元素,依此类推。你可以通过循环遍历数组的一半长度来实现(使用Math.floor向下取整——对于具有奇数个元素的数组,你不需要处理中间元素),并将位置i处的元素与位置array.length - 1 - i处的元素交换。你可以使用局部绑定短暂地保存其中一个元素,用它的镜像覆盖它,然后将局部绑定中的值放到镜像原来所在的位置。

列表

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

let 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,它接受一个列表和一个数字,并返回列表中给定位置的元素(其中 0 指向第一个元素)或在没有该元素时返回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 (let node = list; node; node = node.rest) {}

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

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

深度比较

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

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

要找出应该直接比较值(为此使用=== 运算符)还是比较它们的属性,可以使用typeof 运算符。如果它对两个值都产生"object",则应该进行深度比较。但你必须考虑到一个愚蠢的例外情况:由于历史原因,typeof null 也产生"object"

当你需要遍历对象的属性进行比较时,Object.keys 函数将非常有用。

// Your code here.

let 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。注意,仅当两个参数都是对象时才比较属性。在所有其他情况下,你可以直接返回应用=== 的结果。

使用Object.keys 遍历属性。你需要测试两个对象是否具有相同的属性名称集以及这些属性是否具有相同的值。一种方法是确保两个对象具有相同的属性数量(属性列表的长度相同)。然后,当循环遍历一个对象的属性进行比较时,始终首先确保另一个对象确实具有该名称的属性。如果它们具有相同的属性数量,并且一个中的所有属性也存在于另一个中,那么它们具有相同的属性名称集。

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