数组是如何工作的?

251 阅读12分钟

hi, 我是小黄瓜没有刺。一枚菜鸟瓜🥒,期待关注➕ 点赞,共同成长~

简单数组可能是编程中最流行的数据结构。它是一个直接而强大的工具--它让你用快速的随机访问来表示一个有序的项目列表。不管你是在寻找索引1还是索引500--在数组中,两种访问都需要相同的时间。

如果你已经做了一段时间的开发人员,你可能每天都在使用数组,而没有过多地考虑它们。但是你有没有想过,数组究竟是如何工作的?

在这篇文章中,我想深入了解数组的技术细节,并弄清楚你如何自己发明数组。我们将从一个只支持数字的简单数组开始,然后再建立一个我们都知道并喜爱的灵活、动态的数组,这就是JavaScript。

让我们开始吧!

Memory API

为了实现我们的数组,我们将使用计算机的内存API(或其超级简化版本)。这个API有四个方法。

  1. get(address: number) - 返回一个特定内存地址的值。
  2. set(address: number, value: number) - 将给定的地址设置为给定的值。
  3. allocate(bytes: number) - 分配给定数量的字节,返回第一个分配的字节的指针。
  4. free(pointer: Pointer) - 释放指针所指向的内存位置。

如果你以前用低级语言写过代码,这些方法中的一些可能对你来说很熟悉;如果没有,那也没关系--我们将在这一节中复习所有的工作。

Reading, Writing, and Addresses

作为一个起点,你可以把内存看作是一个超长的数组,数组的每个元素代表一个字节。

image.png

你可以使用它们的地址来指代内存中的特定字节--就像你可以使用索引来指代数组中的特定元素。

Memory Allocation

我们的计算机内存系统背后的一个关键限制是,你不能自由读写内存中的任何旧地址。事实上,当你开始时,你根本就不能读写任何地址!这也是一个很好的理由。

而且还有一个很好的理由--你的计算机内存是由数百个不同的程序并行运行的。想象一下,如果程序可以改变正在被其他程序使用的内存,那该多好啊!

为了突破这一限制,你必须通过分配内存来要求获得空间。为了分配空间,你使用allocate函数,传入你需要的具体字节数。

image.png

allocate调用返回所分配块的第一个字节的地址。在这种情况下,调用返回地址0,因为我们的2字节块是从地址0开始的。

很好!现在我们有了分配好的内存块,我们就可以开始工作了。现在我们有了分配的内存片,我们可以随意读写。

images.gif

注意到我们不允许写到block + 2 - 这是因为我们只分配了2个字节,所以第三个字节,block + 2,是出界的。

Freeing Up Space

最后,一旦你用完了数据,不再需要它,你可以释放这些空间,这样电脑就可以把内存用于其他事情。

const pointer = Mem.allocate(/* num of bytes */ 12)

// do stuff

Mem.free(pointer)

我们真的必须这样做吗?

如果你觉得这真的很乏味,那是因为它确实很乏味。在用低级语言编写时,与内存管理有关的错误一直在发生。值得庆幸的是,JavaScript为你自动完成了这个内存管理过程,让你能够专注于真正的编写代码,而不是与内存讨价还价。

从这个无分配的工作流程中你可能会注意到一件事,那就是你最终没有直接引用地址。相反,这些地址存在于变量中,你所需要做的就是移动它们。总的来说,典型的内存管理工作流程看起来像这样。

images (1).gif

作为一个简单的总结。

  • 就我们的目的而言,内存是一个长的字节数组,每个字节都有一个相关的地址。
  • 要使用内存,你需要首先分配它;这个分配过程返回所分配块的第一个字节的地址。
  • 一旦分配完毕,你就可以自由地读和写该内存位置。
  • 当你完成后,你可以释放所分配的空间,这样它就可以在其他地方使用。

Building the Array

现在我们对内存和它的工作原理有了一些了解,让我们继续我们的阵列吧!我们将从几个假设开始,使我们的生活更容易。我们将从一些假设开始,以使我们的生活更容易。

数组有一个固定的长度(也就是说,它的大小不能增长),并且 数组只能包含数字。 这些假设可能看起来非常严格,但是不要担心--我们会在接下来的工作中减轻它们。

Allocating Space

我们需要做的第一件事是为我们的数组分配空间。但是,有多少空间呢?

const data = Mem.allocate(/* num of bytes */ ???)

幸好,我们的假设让我们提前确定了数组的大小。由于我们的数组是一个固定的长度,并且只能包含数字,所以我们需要分配的总字节数是。

total # of bytes = length * (# of bytes for 1 number)

对于1个数字的字节数,我们将使用JavaScript的大小为8字节。这意味着,如果我们想分配一个10个数字的数组,我们总共需要分配10*8=80字节。

Reading and Writing

接下来,我们需要实现读写操作,这样我们的数组就可以真正使用了。

arr.set(/* index */ 0, /* value */ 2)
const item = arr.get(/* index */ 0) // item = 2

记住,数组的核心属性是快速随机访问。这意味着检索索引500的元素应该和检索索引1的元素一样长。这就导致了我们的问题。

我们如何快速检索任意索引上的元素?

请记住,给定一个地址,你的计算机的内存可以快速获取该地址的项目。如果我们能够有效地将一个索引转化为一个内存地址,我们就能够快速检索位于那里的元素。

让我们用一个例子来解决这个问题。假设我们有一个包含三个数字的数组,每个数字有两个字节大。

image.png

只使用第一个字节的地址,我们如何得到索引0的元素的地址?索引1呢?或者索引2?看看上面的图,看看你是否能找到一个模式。

我们不需要为索引0做任何更多的工作--第一个字节的地址就是第一个元素的地址!但是其他的索引呢?但是其他索引呢?

从上图中,我们看到第二个元素的地址(在索引1处)是第一个字节的地址加上第一个元素的大小。

image.png

进一步扩展,我们发现第二个索引的地址是第一个字节的地址加上每个元素的两倍大小。

image.png

一般来说,一个元素在任何索引上的地址都可以用以下公式确定。

address = first byte address + (index * size of each element)

一旦我们有了元素的地址,我们就可以使用内存的get和set函数自由地读和写该位置。

这里需要强调的是,实际上数组包含什么类型的数据并不重要--如果每个元素的大小都一样,那么我们的公式就可以正常工作了

干得好! 我们现在有一个数组,可以用来表示数据。只是,这感觉有点局限性--如果我们没有提前知道我们需要多少个元素呢?或者如果我们想在同一个数组中存储不同的元素呢?

让我们一个一个地回答这些问题,从前者开始--让数组变得动态。

Growing the Array

到目前为止,我们的数组是静态的。我们在创建时定义了一个长度,但它永远不能超过这个初始长度。这是很有局限性的--如果我们不知道我们的数组将包含多少个元素呢?

在这一节中,我们将探索一种使数组动态化的方法,使它随着数组中项目数量的变化而增长或缩小。特别是,我们将探讨如何实现一个push函数,将项目添加到数组的末端。

const arr = [1, 2, 3]
arr.push(4) // adds 4 to the end of the array

A First Pass

首先,让我们考虑一下我们的数组还没有满的情况,也就是说,数组中的项目比我们初始化的长度少。

image.png

当我们调用push时,我们想把这个值加到数组的末尾,或者在本例中是索引2。因为这个块之前已经被分配了,我们可以直接在索引2处设置值。

images (2).gif

但是如果我们想再次调用push呢?数组的末端指向一个未分配的块,所以我们不能自由地写入我们的值--内存不允许我们这样做。那么,我们该如何增长我们的数组呢?

Increasing Capacity

由于我们需要空间容纳更多的数据,我们的解决方案是分配更多的内存。但是,我们应该多分配多少内存呢?我们可能想做的一件事是为新元素分配足够的内存。

images (3).gif

如果数组是内存中唯一的东西,这就很好,因为在数组旁边总是会有空间。但在实践中,数组并不是生活在真空中。阵列周围的空间被其他东西占据是完全有可能的,实际上也是非常有可能的。

image.png

在这种情况下,你真的不能确定分配器会把你的新块分配到哪里。

好吧,如果这样都不行,那我们应该怎么做呢?我们唯一的选择是为整个数组和新元素分配内存。

images (4).gif

接下来,我们需要将阵列复制到新的空间。

images (5).gif

将新元素推入我们现在拥有的额外空间

images (6).gif

并腾出旧址。

images (8).gif

我们就有了我们的推送功能!

A Note on Performance

在实际代码中,一个推送调用之后通常会有更多的推送调用,因此分配一个以上的额外空间块通常更具有性能。

images (9).gif

这样,如果你多次调用push,你就不需要再次调整数组的大小。

images (10).gif

Mixing Types

伟大的工作! 到目前为止,我们已经做了一个数组。

  1. 可以快速找到一个给定索引的元素
  2. 可以随着更多的项目被添加到数组中而增长

这给我们留下了最后一个假设:我们的数组只能包含相同类型的数据。我们如何修改我们的数组以处理任何类型的数据呢?

The Problem

在我们研究在我们的数组中实现这一点之前,让我们先试着对这个问题有一个更好的认识。假设我们确实允许我们的数组包含多种类型,而我们所做的支持就是像以往那样一个接一个地排列项目。

以数组['ab', 10, true]为例,我们的数组可能看起来像这样(在内存中)。

image.png

现在,假设我们想得到位于索引2的布尔值 "true"。要做到这一点,我们将在我们的地址公式中插入 "2"。

address of index 2 = address at index 0 + (size of item * 2)

但是,嗯,等一下--我们缺少一个变量。我们应该把什么作为 "项目的大小"?我们有三个选择。

  1. 2字节,字符串 "ab "的大小
  2. 4个字节,数字10的大小
  3. 1字节,布尔值 "true "的大小。 事实证明,这些选项都没有给我们正确的答案。

如果我们输入2(使用第一个元素的大小),我们的公式给出的地址是4;而正确的地址是6,因为数字 "10 "占用了4个字节的空间。同样地,如果我们使用4,我们的地址会过高,而如果我们使用1,我们的地址会过低。

如果我们的公式不再起作用,我们将无法快速确定任何给定索引的地址--这是数组的核心特征 因此...

Boxing Our Items

事实证明,解决方案是通过给每个元素 "装箱",使其大小相同。这个框将基本上充当填充物,以便每个元素都在你期望的地方结束。

image.png

在上面的例子中,我们把数组中的每个元素都装在一个4字节的容器中--这是三种数据类型中最大的一个的大小。随着每个元素被填充为4的倍数,我们现在可以使用我们的公式来计算任何特定索引的地址。

Summary

如果你一路走到这里,谢谢你!我感谢你。我很感激你。事实证明,当我们与数组打交道时,有很多事情在后台发生。让我们总结一下我们所学到的东西。

计算机内存是有限制的。你只能通过先分配内存来使用特定的字节。正因为如此,使数组动态化并不是一件非常简单的事情。 阵列的索引被转换为内存地址。这个过程需要快速,这样我们的数组才能有快速的随机访问。 向数组添加更多的项目可能意味着我们必须在引擎盖下调整它的大小。但是只要我们正确地调整大小,我们就不需要经常调整大小。 为了使一个数组能够处理多种类型,我们必须对数组中的每个元素进行 "装箱",使它们在内存中占用相同的空间。 就这样吧! 希望你喜欢我们对数组如何工作的创造性方法,并再次感谢你的阅读!

本文翻译自: www.nan.fyi/how-arrays-…