Go 学习笔记(七)slice 详解

本文原创地址:博客园骏马金龙Go 基础系列:Go slice 详解

slice 表示切片 (分片),例如对一个数组进行切片,取出数组中的一部分值。在现代编程语言中,slice(切片) 几乎成为一种必备特性,它可以从一个数组 (列表) 中取出任意长度的子数组(列表),为操作数据结构带来非常大的便利性,如 python、perl 等都支持对数组的 slice 操作,甚至 perl 还支持对 hash 数据结构的 slice。

但 Go 中的 slice 和这些语言的 slice 不太一样,前面所说的语言中,slice 是一种切片的操作,切片后返回一个新的数据对象。而 Go 中的 slice 不仅仅是一种切片动作, 还是一种数据结构 (就像数组一样)。

slice 的存储结构

Go 中的 slice 依赖于数组,它的底层就是数组,所以数组具有的优点,slice 都有。且 slice 支持可以通过 append 向 slice 中追加元素,长度不够时会动态扩展,通过再次 slice 切片,可以得到得到更小的 slice 结构,可以迭代、遍历等。

实际上 slice 是这样的结构:先创建一个有特定长度和数据类型的底层数组,然后从这个底层数组中选取一部分元素,返回这些元素组成的集合 (或容器),并将 slice 指向集合中的第一个元素。换句话说, slice 自身维护了一个指针属性,指向它底层数组中的某些元素的集合

例如,初始化一个 slice 数据结构:

my_slice := make([]int, 3,5)

// 输出slice
fmt.Println(my_slice)    // 输出:[0 0 0]

这表示先声明一个长度为 5、数据类型为 int 的底层数组,然后从这个底层数组中从前向后取 3 个元素 (即 index 从 0 到 2) 作为 slice 的结构。

如下图:

1.png

每一个 slice 结构都由 3 部分组成:容量 (capacity)、长度(length) 和指向底层数组某元素的指针,它们各占 8 字节(1 个机器字长,64 位机器上一个机器字长为 64bit,共 8 字节大小,32 位架构则是 32bit,占用 4 字节),所以任何一个 slice 都是 24 字节(3 个机器字长)

  • Pointer:表示该 slice 结构从底层数组的哪一个元素开始,该指针指向该元素
  • Capacity:即底层数组的长度,表示这个 slice 目前最多能扩展到这么长
  • Length:表示 slice 当前的长度,如果追加元素,长度不够时会扩展,最大扩展到 Capacity 的长度 (不完全准确,后面数组自动扩展时解释),所以 Length 必须不能比 Capacity 更大,否则会报错

对上面创建的 slice 来说,它的长度为 3,容量为 5,指针指向底层数组的 index=0。

可以通过 len()函数获取 slice 的长度,通过 cap() 函数获取 slice 的 Capacity。

my_slice := make([]int,3,5)

fmt.Println(len(my_slice))  // 3
fmt.Println(cap(my_slice))  // 5

还可以直接通过 print()或 println() 函数去输出 slice,它将得到这个 slice 结构的属性值,也就是 length、capacity 和 pointer:

my_slice := make([]int,3,5)
println(my_slice)      // [3/5]0xc42003df10

[3/5]表示 length 和 capacity,0xc42003df10表示指向的底层数组元素的指针。

务必记住 slice 的本质是[x/y]0xADDR,记住它将在很多地方有助于理解 slice 的特性。另外,个人建议,虽然 slice 的本质不是指针,但仍然可以将它看作是一种包含了另外两种属性的不纯粹的指针,也就是说,直接认为它是指针 。其实不仅 slice 如此,map 也如此。

创建、初始化、访问 slice

有几种创建 slice 数据结构的方式。

一种是使用 make():

// 创建一个length和capacity都等于5的slice
slice := make([]int,5)

// length=3,capacity=5的slice
slice := make([]int,3,5)

make()比 new() 函数多一些操作,new()函数只会进行内存分配并做默认的赋 0 初始化,而 make() 可以先为底层数组分配好内存,然后从这个底层数组中再额外生成一个 slice 并初始化。另外,make 只能构建 slice、map 和 channel 这 3 种结构的数据对象,因为它们都指向底层数据结构,都需要先为底层数据结构分配好内存并初始化。

还可以直接赋值初始化的方式创建 slice:

// 创建长度和容量都为4的slice,并初始化赋值
color_slice := []string{"red","blue","black","green"}

// 创建长度和容量为100的slice,并为第100个元素赋值为3
slice := []int{99:3}

注意区分 array 和 slice:

// 创建长度为3的int数组
array := [3]int{10, 20, 30}

// 创建长度和容量都为3的slice
slice := []int{10, 20, 30}

由于 slice 底层是数组,所以可以使用索引的方式访问 slice,或修改 slice 中元素的值:

// 创建长度为5、容量为5的slice
my_slice := []int{11,22,33,44,55}

// 访问slice的第2个元素
print(my_slice[1])

// 修改slice的第3个元素的值
my_slice[2] = 333

由于 slice 的底层是数组,所以访问my_slice[1]实际上是在访问它的底层数组的对应元素。slice 能被访问的元素只有 length 范围内的元素,那些在 length 之外,但在 capacity 之内的元素暂时还不属于 slice,只有在 slice 被扩展时 (见下文 append),capacity 中的元素才被纳入 length,才能被访问。

nil slice 和空 slice

当声明一个 slice,但不做初始化的时候,这个 slice 就是一个 nil slice。

// 声明一个nil slice
var nil_slice []int

nil slice 表示它的指针为 nil,也就是这个 slice 不会指向哪个底层数组。也因此,nil slice 的长度和容量都为 0。

|--------|---------|----------|
|  nil   |   0     |     0    |
|  ptr   | Length  | Capacity |
|--------|---------|----------|

还可以创建空 slice(Empty Slice),空 slice 表示长度为 0,容量为 0,但却有指向的 slice,只不过指向的底层数组暂时是长度为 0 的空数组。

// 使用make创建
empty_slice := make([]int,0)

// 直接创建
empty_slice := []int{}

Empty Slice 的结构如下:

|--------|---------|----------|
|  ADDR  |   0     |     0    |
|  ptr   | Length  | Capacity |
|--------|---------|----------|

虽然 nil slice 和 Empty slice 的长度和容量都为 0,输出时的结果都是[],且都不存储任何数据,但它们是不同的。nil slice 不会指向底层数组,而空 slice 会指向底层数组,只不过这个底层数组暂时是空数组。

可以使用 println() 来输出验证:

package main

func main() {
	var nil_s []int
	empty_s:= []int{}
	println(nil_s)
	println(empty_s)
}

输出结果:

[0/0]0x0
[0/0]0xc042085f50

当然,无论是 nil slice 还是 empty slice,都可以对它们进行操作,如 append()函数、len() 函数和 cap() 函数。

对 slice 进行切片

可以从 slice 中继续切片生成一个新的 slice,这样能实现 slice 的缩减。

对 slice 切片的语法为:

SLICE[A:B]
SLICE[A:B:C]

其中 A 表示从 SLICE 的第几个元素开始切, B 控制切片的长度 (B-A),C 控制切片的容量 (C-A),如果没有给定 C,则表示切到底层数组的最尾部

还有几种简化形式:

SLICE[A:]  // 从A切到最尾部
SLICE[:B]  // 从最开头切到B(不包含B)
SLICE[:]   // 从头切到尾,等价于复制整个SLICE

例如:

my_slice := []int{11,22,33,44,55}

// 生成新的slice,从第二个元素取,切取的长度为2
new_slice := my_slice[1:3]

注意,截取时 "左闭右开"。所以上面new_slice是从my_slice的 index=1 开始截取,截取到 index=3 为止,但不包括 index=3 这个元素。所以,新的 slice 是由my_slice中的第 2 个元素、第 3 个元素组成的新的数据结构,长度为 2。

以下是 slice 切片生成新的 slice 后的结构:

2.png

不难发现,一个底层数组,可以生成无数个 slice,且对于 new_slice 而言,它并不知道底层数组 index=0 的那个元素。

还可以控制切片时新 slice 的容量:

my_slice := []int{11,22,33,44,55}

// 从第二个元素取,切取的长度为2,容量也为2
new_slice := my_slice[1:3:3]

这时新 slice 的 length 等于 capacity,底层数组的 index=4、5 将对 new_slice 永不可见,即使后面对 new_slice 进行 append() 导致底层数组扩容也仍然不可见。具体见下文。

由于多个 slice 共享同一个底层数组,所以当修改了某个 slice 中的元素时,其它包含该元素的 slice 也会随之改变,因为 slice 只是一个指向底层数组的指针 (只不过这个指针不纯粹,多了两个额外的属性 length 和 capacity),实际上修改的是底层数组的值,而底层数组是被共享的。

当同一个底层数组有很多 slice 的时候,一切将变得混乱不堪,因为我们不可能记住谁在共享它,通过修改某个 slice 的元素时,将也会影响那些可能我们不想影响的 slice。所以,需要一种特性,保证各个 slice 的底层数组互不影响,相关内容见下面的 "扩容"。

copy() 函数

可以将一个 slice 拷贝到另一个 slice 中。

$ go doc builtin copy
func copy(dst, src []Type) int

这表示将 src slice 拷贝到 dst slice,src 比 dst 长,就截断,src 比 dst 短,则只拷贝 src 那部分。

copy 的返回值是拷贝成功的元素数量,所以也就是 src slice 或 dst slice 中最小的那个长度。

例如:

s1 := []int{11, 22, 33}
s2 := make([]int, 5)
s3 := make([]int,2)

num := copy(s2, s1)
copy(s3,s1)

fmt.Println(num)   // 3
fmt.Println(s2)    // [11,22,33,0,0]
fmt.Println(s3)    // [11,22]

此外,copy 还能将字符串拷贝到 byte slice 中,因为字符串实际上就是[]byte

func main() {
	s1 := []byte("Hello")
	num := copy(s1, "World")
	fmt.Println(num)
	fmt.Println(s1)    // 输出[87 111 114 108 100 32]
	fmt.Println(string(s1))  //输出"World"
}

append() 函数

可以使用 append() 函数对 slice 进行扩展,因为它追加元素到 slice 中,所以一定会增加 slice 的长度。

但必须注意,append()的结果必须被使用。所谓被使用,可以将其输出、可以赋值给某个 slice。如果将 append() 放在空上下文将会报错:append()已评估,但未使用。同时这也说明,append() 返回一个新的 slice,原始的 slice 会保留不变。

例如:

my_slice := []int{11,22,33,44,55}
new_slice := my_slice[1:3]

// append()追加一个元素2323,返回新的slice
app_slice := append(new_slice,2323)

上面的 append() 在new_slice的后面增加了一个元素 2323,所以app_slice[2]=2323。但因为这些 slice 共享同一个底层数组,所以 2323 也会反映到其它 slice 中。

现在的数据结构图如下:

3.png

当然,如果 append() 的结果重新赋值给 new_slice,则new_slice会增加长度。

同样,由于 string 的本质是 []byte,所以 string 可以 append 到 byte slice 中:

s1 := []byte("Hello")
s2 := append(s1, "World"...)
fmt.Println(string(s2))   // 输出:HelloWorld

扩容

当 slice 的 length 已经等于 capacity 的时候,再使用 append() 给 slice 追加元素,会自动扩展底层数组的长度。

底层数组扩展时,会生成一个新的底层数组。所以旧底层数组仍然会被旧 slice 引用,新 slice 和旧 slice 不再共享同一个底层数组

func main() {
	my_slice := []int{11,22,33,44,55}
	new_slice := append(my_slice,66)

	my_slice[3] = 444   // 修改旧的底层数组

	fmt.Println(my_slice)   // [11 22 33 444 55]
	fmt.Println(new_slice)  // [11 22 33 44 55 66]

	fmt.Println(len(my_slice),":",cap(my_slice))     // 5:5
	fmt.Println(len(new_slice),":",cap(new_slice))   // 6:10
}

从上面的结果上可以发现,底层数组被扩容为 10,且是新的底层数组。

实际上,当底层数组需要扩容时,会按照当前底层数组长度的 2 倍进行扩容,并生成新数组。如果底层数组的长度超过 1000 时,将按照 25% 的比率扩容,也就是 1000 个元素时,将扩展为 1250 个,不过这个增长比率的算法可能会随着 go 版本的递进而改变。

实际上,上面的说法应该改一改: 当 capacity 需要扩容时,会按照当前 capacity 的 2 倍对数组进行扩容 。或者说,是按照 slice 的本质[x/y]0xADDR的容量 y 来判断如何扩容的。之所以要特别强调这两种不同,是因为很容易搞混。

例如,扩容的对象是底层数组的真子集时:

my_slice := []int{11,22,33,44,55}

// 限定长度和容量,且让长度和容量相等
new_slice := my_slice[1:3:3]   // [22 33]

// 扩容
app_slice := append(new_slice,4444)

上面的new_slice的容量为 2,并没有对应到底层数组的最结尾,所以new_slicemy_slice的一个真子集。这时对new_slice扩容,将生成一个新的底层数组,新的底层数组容量为 4,而不是 10。如下图:

4.png

因为创建了新的底层数组,所以修改不同的 slice,将不会互相影响。为了保证每次都是修改各自的底层数组, 通常会切出仅一个长度、仅一个容量的新 slice,这样只要对它进行任何一次扩容,就会生成一个新的底层数组,从而让每个 slice 的底层数组都独立

my_slice := []int{11,22,33,44,55}

new_slice := my_slice[2:3:3]
app_slice := append(new_slice,3333)

事实上,这还是会出现共享的几率,因为没有扩容时,那个唯一的元素仍然是共享的,修改它肯定会影响至少 1 个 slice,只有切出长度为 0,容量为 0 的 slice,才能完全保证独立性,但这和新创建一个 slice 没有区别。

合并 slice

slice 和数组其实一样,都是一种值,可以将一个 slice 和另一个 slice 进行合并,生成一个新的 slice。

合并 slice 时,只需将 append() 的第二个参数后加上...即可,即append(s1,s2...)表示将 s2 合并在 s1 的后面,并返回新的 slice。

s1 := []int{1,2}
s2 := []int{3,4}

s3 := append(s1,s2...)

fmt.Println(s3)  // [1 2 3 4]

注意 append()最多允许两个参数,所以一次性只能合并两个 slice。但可以取巧,将 append() 作为另一个 append() 的参数,从而实现多级合并。例如,下面的合并 s1 和 s2,然后再和 s3 合并,得到s1+s2+s3合并后的结果。

sn := append(append(s1,s2...),s3...)

slice 遍历迭代

slice 是一个集合,所以可以进行迭代。

range 关键字可以对 slice 进行迭代,每次返回一个 index 和对应的元素值。可以将 range 的迭代结合 for 循环对 slice 进行遍历。

package main

func main() {
	s1 := []int{11,22,33,44}
	for index,value := range s1 {
		println("index:",index," , ","value",value)
	}
}

输出结果:

index: 0  ,  value 11
index: 1  ,  value 22
index: 2  ,  value 33
index: 3  ,  value 44

传递 slice 给函数

前面说过,虽然 slice 实际上包含了 3 个属性,它的数据结构类似于[3/5]0xc42003df10,但仍可以将 slice 看作一种指针。这个特性直接体现在函数参数传值上。

Go 中函数的参数是按值传递的,所以调用函数时会复制一个参数的副本传递给函数。如果传递给函数的是 slice,它将复制该 slice 副本给函数,这个副本实际上就是[3/5]0xc42003df10,所以传递给函数的副本仍然指向源 slice 的底层数组。

换句话说,如果函数内部对 slice 进行了修改,有可能会直接影响函数外部的底层数组,从而影响其它 slice。但并不总是如此,例如函数内部对 slice 进行扩容,扩容时生成了一个新的底层数组,函数后续的代码只对新的底层数组操作,这样就不会影响原始的底层数组。

例如:

package main

import "fmt"

func main() {
	s1 := []int{11, 22, 33, 44}
	foo(s1)
	fmt.Println(s1[1])     // 输出:23
}

func foo(s []int) {
	for index, _ := range s {
		s[index] += 1
	}
}

上面将输出 23,因为 foo() 直接操作原始的底层数组,对 slice 的每个元素都加 1。

slice 和内存浪费问题

由于 slice 的底层是数组,很可能数组很大,但 slice 所取的元素数量却很小,这就导致数组占用的绝大多数空间是被浪费的。

特别地,垃圾回收器 (GC) 不会回收正在被引用的对象,当一个函数直接返回指向底层数组的 slice 时,这个底层数组将不会随函数退出而被回收,而是因为 slice 的引用而永远保留,除非返回的 slice 也消失。

因此,当函数的返回值是一个指向底层数组的数据结构时 (如 slice),应当在函数内部将 slice 拷贝一份保存到一个使用自己底层数组的新 slice 中,并返回这个新的 slice。这样函数一退出,原来那个体积较大的底层数组就会被回收,保留在内存中的是小的 slice。
上一篇 Go 学习笔记(六)数组
Go 学习笔记(目录)
下一篇 Go 学习笔记(八)map 类型