掘金 后端 ( ) • 2024-06-26 15:05

Slice 的原理

在Go语言中,切片(Slice)是一个非常重要的数据结构,用于引用数组中的连续小段,而无需复制此小段。Go 数组的长度不可以改变,因此在实际使用中,更多的场景是动态数组,也就是切片。

切片是描述一个底层数据的结构体,这个底层数据既可以是数组,也可以是另一个切片。一个切片有三个属性:指针、长度和容量。

  • • 指针指向第一个切片元素对应的底层数组元素的地址,要注意的是,数组的下标是从0开始的。例如,如果有一个由5个元素构成的数组,然后创建了一个基于该数组的切片,切片的起始元素是数组的第2个元素,那么切片的指针就指向数组的第2个元素,而不是第一个
  • • 长度表示它使用的内存空间,也就是从切片开始,到切片结束,共有多少个元素
  • • 容量表示从底层数组的开始位置,到该数组结束,共有多少个元素。

Slice的数据结构体定义:

type Slice struct {
    array unsafe.Pointer // 底层数组的指针
    len   int            // 当前已经使用的长度
    cap   int            // 底层数组的实际容量
}

slice简单用法

//  定义一个切片:切片使用make函数创建,需要指定其类型,长度和容量
s := make([]int, 5, 10)  // 创建一个长度为 5,容量为 10 的int类型 slice

//  初始化切片:可以在声明切片时直接初始化
s := []int{1, 2, 3, 4, 5} // 创建并初始化一个长度和容量都为5的int类型的切片

// 访问切片元素:通过索引访问切片元素
s := []int{1, 2, 3, 4, 5} 
fmt.Println(s[0]) // 输出1

// 修改切片元素:通过索引修改切片元素
s := []int{1, 2, 3, 4, 5} 
s[0] = 10 // 修改第一个元素
fmt.Println(s[0]) // 输出10

// 切片是引用类型:当你将一个切片赋给另一个切片,两者都会引用相同的底层数组。如果一个切片变了,另一个切片也会跟着改变
s1 := []int{1, 2, 3, 4, 5}
s2 := s1
s1[0] = 10
fmt.Println(s2[0]) // 输出10

arr := [5]int{1,2,3,4,5}
s := arr[1:4]  // 创建一个包含 arr[1], arr[2], arr[3] 三个元素的 slice

slice的修改

在 Go 中,slice 的扩容是通过内建的 append 函数实现的。但是这个操作可能会带来一个陷阱:不同的 slice 可能引用同一个底层数组。当一个 slice 对底层数组进行修改时,其他也在引用同一底层数组的 slice 也会受到影响。

s1 := []int{1,2,3}
s2 := s1
s2 = append(s2, 4)  // 由于 s2 对底层数组进行了修改,s1 中的数据也会被影响。

copy操作

可以使用内建的copy函数复制一个slice的数据到另一个slice

s1 := []int{1, 2, 3, 4, 5}
s2 := make([]int, len(s1))

copy(s2, s1) // 将 s1 的元素复制到 s2

s1和s2是两个独立的slice,他们分别拥有自己的底层数组。对一个slice的改动不会影响到另一个

注意:目标 slice(这里是s2)必须足够的大以接纳源slice所有元素,否则只会复制目标slice能容纳的元素

slice扩容的陷阱

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

slice1.webp

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

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

slice2.webp

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

s2 = append(s2, 2)

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

slice3.webp

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

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

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

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

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

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

slice4.webp

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

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