译文 Go 常见错误集锦之混淆了 slice 中的长度 (length) 和容量 (capacity)

yudotyang · 2021年09月25日 · 233 次阅读
本帖已被设为精华帖!

本文是对《100 Go Mistackes:How to Avoid Them》一书的翻译。因翻译水平有限,难免存在翻译准确性问题,敬请谅解。更多内容请关注公众号--Go 学堂

对于 Go 研发人员来说,对于 slice 结构中的长度(length)和容量(capacity)经常混淆是很常见的。完全理解这两个概念对有效处理 slice 的核心操作是至关重要的。例如:对 slice 的初始化,使用 append 添加元素,拷贝元素或分隔 slice 等操作。否则,可能导致使用 append 操作切片时性能低下,甚至是内存泄露。

在 Go 语言中,slice 的底层实现是数组,也就是说,切片的数据实际上是被存储在数组中的。如果后端的数组空间已经满了或是空数组,则 slice 结构体负责处理数组容量的扩容或缩容逻辑。

此外,slice 的结构体中共拥有三个字段:

  • 一个指针,指向后端的数组,
  • 一个 length 字段,代表该 slice 中包含的元素个数。
  • 一个 capacity(容量)字段,代表后端数组能够容纳的元素个数。

我们通过两个例子来演示一下 slice 的结构。

首先,我们使用给定的长度和容量来初始化一个 slice:

s := make([]int, 3, 6) 

① 第二个参数 3 代表长度(length),第三个参数 6 代表容量(capacity)

如下图所示:

该切片创建了一个能够容纳 6 个元素(容量)的数组。同时,因为长度 length 被设置成了 3,所以,Go 仅仅初始化前 3 个元素。因为 slice 的元素是 [] int 类型,所以前 3 个元素用 int 的零值 0 来初始化。剩余的元素空间只被分配,但没有使用。

如果打印这个切片,将会得到如下结果:[0 0 0]。

如果我们设置 s[1] = 1,那么,该切片的第 2 个元素将会被更新,但对该 slice 的长度和容量不会有任何影响。如下图所以:

但是,不允许访问切片长度(length)以外的元素,即使长度以外的内存空间也已经被分配了。例如,s[4] = 0 会引发 panic:

panic:runtime error: index out of range [4] with length 3

那么,我们该如何使用 slice 中剩余的空间呢?通过内建的 append 函数:

s = append(s, 2)

该操作将会往 s 切片中添加一个新的元素。该元素使用第一个图中灰色的元素块(即分配了空间但又没被使用的位置)来存储元素 2。如下图所以:

这时,slice 的长度 length 从 3 变成了 4,即该 slice 现在有 4 个元素。

那如果我们再多加入 3 个元素 slice 会发生什么?后端的数组空间会不会不足够大了?

s = append(s, 3)
s = append(s, 4)
s = append(s, 5)
fmt.Println(s)

如果我们执行这部分代码,我们会注意到该 slice 依然能满足我们的需求:

[0 1 0 2 3 4 5]

因为数组是一个固定长度的结构,只能将元素 4 给存储进去。当我们想插入元素 5 时,该数组就已经满了,Go 会创建另一个数组,并且空间大小是原来容量的 2 倍,然后将原数组中的所有元素都拷贝到新数组中去,再在新数组中插入元素 5,如下图所示:

现在 slice 的的指针字段指向了新的数组。那原来的那个数组会怎么样呢?如果没有被引用,将会被 GC 进行回收。

下面,我们来看看对一个 slice 进行切分的影响:

s1 := make([]int, 3, 6) 
s2 := s1[1:3] 

① 一个长度为 3,容量为 6 的切片 ② 从索引 1 到 3 进行切分

如下图所示

首先,s1 被初始化成一个长度为 3,容量为 6 的切片。当通过切分 s1 创建 s2 切片时,s1 和 s2 的指针字段都指向同一个后端数组。但是,s2 的第一个元素的索引是从数组的索引 1 开始的。因此,切片 s2 的长度和容量是和 s1 不同的:长度为 2,容量为 5.

如果我们更新 s1[1] 或 s2[0],那么对于后端数组来说,变更是一样的。因此,该变更对两个切片都是可见的,如图所示:

那,如果现在往 s2 中 append 一个元素会发生什么呢?会对 s1 有影响吗?

s2 = append(s2, 2)

这样,会将共享的数组进行修改,但只有 s2 的长度会发生改变,如图所示:

s1 的长度依然是 3,容量是 6.因此,如果我们打印 s1 和 s2,那么被加入的元素只对 s2 可见:

s1 = [0 1 0], s2 = [1 0 2]

在使用 append 时,理解这个行为会降低出错的概率。

最后一个需要注意的是,如果我们持续往 s2 中 append 元素,直到数组满了位置,会发生什么呢? 我们再往 s2 中增加 3 个元素,直到将后端的数组填满,没有任何可用的空间:

s2 = append(s2, 3)
s2 = append(s2, 4)
s2 = append(s2, 5) 

① 在该阶段,后端的数组就已经满了。

这段代码会导致创建另一个新的数组,如图所示:

注意,这时 s1 和 s2 分别指向了两个不同的数组。实际上,s1 依然是一个长度为 3,容量为 6 的切片,同时也有一些可用的 buffer 空间,因此,它依然是引用了最初的那个数组。同时,新创建的数组,会从 s2 的起始位置将数据拷贝到自己的空间上来。这也就是为什么新数组的第一个元素是 1,而不是 0 的原因。

总之,切片中的 length 是该切片中当前已存储的元素个数,切片的容量是该切片指向的数组的元素个数。往一个满了的切片(切片长度=切片容量)中添加新元素会触发创建一个新的数组,并且新数组的容量是原来的 2 倍,该新数组会将原数组中的元素都拷贝过来,同时将 slice 中的指针更新到指向新数组。

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
astaxie 将本帖设为了精华贴 09月26日 02:01
yulibaozi GoCN 每日新闻 (2021-10-06) 中提及了此贴 10月06日 10:40
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册