数据结构:对象和数组

有两次,我被问到:“巴贝奇先生,如果你把错误的数字输入机器,会得出正确的答案吗?”……我无法理解会引发这种问题的想法混乱。

查尔斯·巴贝奇,《哲学家的一生片段》(1864 年)
Illustration of a squirrel next to a pile of books and a pair of glasses. A moon and stars are visible in the background.

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

到目前为止,我们构建的程序受到它们仅操作简单数据类型的限制。在本章学习了数据结构的基础知识之后,您将了解足以开始编写有用程序的知识。

本章将通过一个或多或少现实的编程示例进行讲解,在解决问题时引入概念。示例代码通常会基于本书前面章节中介绍的函数和绑定。

会变成松鼠的人

雅克偶尔,通常是在晚上 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 检索名为“x”的 value 属性,而 value[x] 采用名为 x 的绑定的值,并将其转换为字符串,用作属性名称。

如果您知道您感兴趣的属性名为color,您会说 value.color。如果您想提取由绑定 i 中保存的值命名的属性,您会说 value[i]。属性名称是字符串。它们可以是任何字符串,但点表示法仅适用于看起来像有效绑定名称的名称——以字母或下划线开头,并且只包含字母、数字和下划线。如果您想访问名为2John Doe 的属性,您必须使用方括号:value[2]value["John Doe"]

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

与字符串一样,数组也具有一个 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 中有两种含义。在语句的开头,它们开始一个语句块。在任何其他位置,它们描述一个对象。幸运的是,在语句的开头使用带大括号的对象很少有用,因此这两种含义之间的歧义并不是什么大问题。这种情况出现的一个情况是当您想从一个简短的箭头函数返回一个对象时——您不能写 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 和实际删除它之间的区别在于,在第一种情况下,对象仍然拥有该属性(只是没有一个非常有趣的价值),而在第二种情况下,该属性不再存在,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"。您可以将数组可视化为长而扁平的章鱼,所有触手都整齐地排成一行,并用数字标记。

Jacques 将用一个对象数组来表示他保存的日记。

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 中没有内置的“深度”比较运算符来根据内容比较对象,但您可以自己编写它(这是本章末尾的 练习 之一)。

狼人的日志

Jacques 启动了他的 JavaScript 解释器并设置了保存日记所需的环境

let journal = [];

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

请注意,添加到日记中的对象看起来有点奇怪。它没有像 events: events 那样声明属性,而只是给出了属性名:events。这是一种简写,表示相同的意思——如果花括号表示法中的属性名后面没有跟值,则它的值将从具有相同名称的绑定中获取。

每天晚上 10 点——或者有时第二天早上,从书架的顶层爬下来后——Jacques 会记录当天的事情

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 到 1 之间的数字,描述了相关性。

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

A two-by-two table showing the pizza variable on the horizontal, and the squirrel variable on the vertical axis. Each cell show how many time that combination occurred. In 76 cases, neither happened. In 9 cases, only pizza was true. In 4 cases only squirrel was true. And in one case both occurred.

如果我们把那个表称为n,我们可以使用以下公式计算ϕ

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

(如果此时您正在放下书本,专注于对十年级数学课的可怕回忆——等等!我无意用无休止的 cryptic 符号折磨您——现在只使用这个公式。即使使用这个公式,我们所做的只是将其转换为 JavaScript。)

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

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

因此,对于披萨表,除法线以上的部分(被除数)将是 1×76−4×9 = 40,而除法线以下的部分(除数)将是 5×85×10×80 的平方根,即 √340,000。这约等于 ϕ ≈ 0.069,非常小。吃披萨似乎不会对转化产生影响。

计算相关性

我们可以用一个四元素数组([76, 9, 4, 1])在 JavaScript 中表示一个 2×2 表。我们也可以使用其他表示,例如包含两个两个元素数组的数组([[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 对象提供。我们必须从表中添加两个字段才能获得像 n1• 这样的字段,因为我们的数据结构中没有直接存储行或列的总和。

Jacques 保存了他的日记三个月。结果数据集在本章的 编码沙盒 中可用,它存储在 JOURNAL 绑定中,并存储在一个可下载的 文件 中。

要从日记中提取特定事件的 2×2 表,我们必须循环遍历所有条目并统计事件在松鼠转化的情况下出现的次数

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

这甚至在花括号对象中也有效,它会将来自另一个对象的所有属性添加到新对象中。如果一个属性被添加多次,则最后一个添加的值获胜

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 对你使用 letconst 声明的绑定执行此操作,但奇怪的是,它不适用于标准绑定,也不适用于使用 varfunction 声明的绑定。

回到 Math 对象。如果你需要进行三角函数运算,Math 可以提供帮助。它包含 cos(余弦)、sin(正弦)和 tan(正切),以及它们的逆函数,分别为 acosasinatan。数字 π(圆周率)——或者至少是与 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,你会得到一个错误,就像你直接尝试访问这些值的属性一样。

可选属性访问

当你不能确定给定值是否会产生一个对象,但仍然想要在它产生对象时从它读取一个属性,你可以使用点表示法的变体: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(发音为“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,并返回一个包含从 startend(包括 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.

let myArray = ["A", "B", "C"];
console.log(reverseArray(myArray));
// → ["C", "B", "A"];
console.log(myArray);
// → ["A", "B", "C"];
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()是复制数组的好方法)可以,但这是一种作弊行为。

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

列表

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

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

结果对象形成了一个链,如以下图表所示。

A diagram showing the memory structure of a linked list. There are 3 cells, each with a value field holding a number, and a 'rest' field with an arrow to the rest of the list. The first cell's arrow points at the second cell, the second cell's arrow at the last cell, and the last cell's 'rest' field holds null.

列表的一个好处是它们可以共享结构的一部分。例如,如果我创建两个新值{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 (let node = list; node; node = node.rest) {}

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

nth的递归版本将类似地查看列表“尾部”的越来越小的一部分,同时对索引进行计数,直到它达到零,此时它可以返回它正在查看的节点的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