> 文档中心 > Go语言实战之切片的内部实现和基础功能

Go语言实战之切片的内部实现和基础功能


写在前面


  • 嗯,学习GO,所以有了这篇文章
  • 博文内容为《GO语言实战》读书笔记之一
  • 主要涉及切片相关知识

没事,只不过是恢复原状罢了,我本来就是一无所有的。 ——濑川初原《食灵零》


切片的内部实现和基础功能

切片是一种数据结构(类似于JavaArrayList),围绕动态数组的概念构建的,可以按需自动增长和缩小。切片的动态增长是通过内置函数append来实现的。这个函数可以快速且高效地增长切片。还可以通过对切片再次切片来缩小一个切片的大小。

因为切片的底层内存也是在连续块中分配的,所以切片还能获得索引迭代以及为垃圾回收优化的好处。

内部实现

切片是一个很小的对象,对底层数组进行了抽象,并提供相关的操作方法。切片有3个字段数据结构,这些数据结构包含Go语言需要操作底层数组的元数据

  • 指向底层数组指针
  • 切片访问的元素个数(即长度)
  • 切片允许增长,到的元素个数(即容量)

Go语言实战之切片的内部实现和基础功能

创建和初始化

Go语言中有几种方法可以创建和初始化切片。是否能提前知道切片需要的容量通常会决定要如何创建切片

make 和切片字面量

  • 如果只指定长度,那么切片的容量和长度相等
// 其长度和容量都是 5 个元素slice := make([]string, 5)
  • 使用长度和容量声明整型切片
func main() {// 其长度和容量都是 5 个元素slice := make([]int, 3, 5)fmt.Println(slice)}============[Running] go run "d:\GolandProjects\code-master\demo\make.go"[0 0 0]

剩余的2 个元素可以在后期操作中合并到切片,如果基于这个切片创建新的切片,新切片会和原有切片共享底层数组,也能通过后期操作来访问多余容量的元素。

  • 不允许创建容量小于长度的切片,
func main() {// 其长度和容量都是 5 个元素slice := make([]int, 5, 3)fmt.Println(slice)}=================[Running] go run "d:\GolandProjects\code-master\demo\make.go"# command-line-argumentsd:\GolandProjects\code-master\demo\make.go:10:15: len larger than cap in make([]int)

另一种常用的创建切片的方法是使用切片字面量,只是不需要指定[]运算符里的值。初始的长度和容量会基于初始化时提供的元素的个数确定.

  • 通过切片字面量来声明切片
slice:= [] string{"Red", "Blue", "Green", "Yellow", "Pink"}//其长度和容量都是 3 个元素slice := []int{10, 20, 30}

当使用切片字面量时,可以设置初始长度和容量,创建长度和容量都是100 个元素的切片

  • 使用索引声明切片
// 使用空字符串初始化第 100 个元素slice := []string{99: ""}
  • 声明数组和声明切片的不同
// 创建有 3 个元素的整型数组array := [3]int{10, 20, 30}// 创建长度和容量都是 3 的整型切片slice := []int{10, 20, 30}

nil 和空切片

  • 创建nil切片:描述一个不存在的切片时
// 创建 nil 整型切片var slice []int

Go语言实战之切片的内部实现和基础功能

  • 声明空切片:表示空集合时空切片很有用
// 使用 make 创建空的整型切片slice := make([]int, 0)// 使用切片字面量创建空的整型切片slice := []int{}

Go语言实战之切片的内部实现和基础功能
不管是使用 nil 切片还是空切片,对其调用内置函数 append、len 和 cap 的效果都是一样的。

使用切片

赋值和切片

对切片里某个索引指向的元素赋值和对数组里某个索引指向的元素赋值的方法完全一样。使用[]操作符就可以改变某个元素的值

  • 使用切片字面量来声明切片
// 其容量和长度都是 5 个元素slice := []int{10, 20, 30, 40, 50}// 改变索引为 1 的元素的值slice[1] = 25

切片之所以被称为切片,是因为创建一个新的切片就是把底层数组切出一部分

  • 使用切片创建切片
// 其长度和容量都是 5 个元素slice := []int{10, 20, 30, 40, 50}
  • 使用切片创建切片,如何计算长度和容量
// 其长度和容量都是 5 个元素slice := []int{10, 20, 30, 40, 50}// 创建一个新切片// 其长度为 2 个元素,容量为 4 个元素newSlice := slice[1:3]

对底层数组容量是k的切片 slice[i:j]来说

  • 长度: j - i = 2
  • 容量: k - i = 4

这里书里讲的个人感觉不太好理解,其实类似JavaString的subString,换句话讲,前开后闭(即前包后不包),切取原数组索引1到3的元素,这里的元素个数即为新的切片长度,切取的容量为原数组第一个切点到数组末尾(默认)。其实这里有第三个索引值,后面我们会讲.

Go语言实战之切片的内部实现和基础功能

我们有了两个切片,它们共享同一段底层数组,但通过不同的切片会看到底层数组的不同部分,这个和java里的List方法subList特别像,都是通控制索引来对底层数组进行切片,所以本质上,切片后的数组可以看做是原数组视图

  • 修改切片内容可能导致的结果
// 其长度和容量都是 5 个元素slice := []int{10, 20, 30, 40, 50}// 其长度是 2 个元素,容量是 4 个元素newSlice := slice[1:3]// 修改 newSlice 索引为 1 的元素// 同时也修改了原来的 slice 的索引为 2 的元素newSlice[1] = 35
  • 表示索引越界的语言运行时错误
// 其长度和容量都是 5 个元素slice := []int{10, 20, 30, 40, 50}// 其长度为 2 个元素,容量为 4 个元素newSlice := slice[1:3]// 修改 newSlice 索引为 3 的元素// 这个元素对于 newSlice 来说并不存在newSlice[3] = 45

切片增长
相对于数组而言,使用切片的一个好处是,可以按需增加切片的容量。Go语言内置的 append函数会处理增加长度时的所有操作细节。

函数append总是会增加新切片的长度,而容量有可能会改变,也可能不会改变,这取决于被操作的切片的可用容量

  • 使用append向切片增加元素
package mainimport ("fmt")func main() {// 其长度和容量都是 5 个元素slice := []int{10, 20, 30, 40, 50}// 创建一个新切片// 其长度为 2 个元素,容量为 4 个元素newSlice := slice[1:3]fmt.Println(newSlice)// 使用原有的容量来分配一个新元素// 将新元素赋值为 60newSlice = append(newSlice, 60)fmt.Println(newSlice)}
[Running] go run "d:\GolandProjects\code-master\demo\hello.go"[20 30][20 30 60][Done] exited with code=0 in 1.28 seconds

Go语言实战之切片的内部实现和基础功能

如果切片的底层数组没有足够的可用容量,append函数会创建一个新的底层数组,将被引用的现有的值复制到新数组里,再追加新的值.

package mainimport ("fmt")func main() {// 其长度和容量都是 5 个元素slice := []int{10, 20, 30, 40, 50}// 使用原有的容量来分配一个新元素// 将新元素赋值为 60newSlice := append(slice, 60)fmt.Println(newSlice)}
[Running] go run "d:\GolandProjects\code-master\demo\hello.go"[10 20 30 40 50 60][Done] exited with code=0 in 1.236 seconds

函数append会智能地处理底层数组的容量增长。在切片的容量小于1000个元素时,总是会成倍地增加容量。一旦元素个数超过 1000,容量的增长因子会设为1.25,也就是会每次增加 25%的容量。随着语言的演化,这种增长算法可能会有所改变。

创建切片时的 3 个索引

通过第三个索引值设置容量,如果没有第三个索引值,默认容量是到数组最后一个。

package mainimport ("fmt")func main() {// 创建字符串切片// 其长度和容量都是 5 个元素source := []string{"Apple", "Orange", "Plum", "Banana", "Grape"}// 将第三个元素切片,并限制容量// 其长度为 1 个元素,容量为 2 个元素slice := source[2:3:4]fmt.Println(slice)}

为了设置容量,从索引位置 2 开始,加上希望容量中包含的元素的个数(2),就得到了第三个值 4。

[Running] go run "d:\GolandProjects\code-master\demo\hello.go"[Plum][Done] exited with code=0 in 0.998 seconds
  • 设置容量大于已有容量的语言运行时错误
[Running] go run "d:\GolandProjects\code-master\demo\hello.go"panic: runtime error: slice bounds out of range [::9] with capacity 5

如果在创建切片时设置切片的容量和长度一样,就可以强制让新切片的第一个append操作创建新的底层数组,与原有的底层数组分离。新切片与原有的底层数组分离后,可以安全地进行后续修改.

  • 设置长度和容量一样的好处
package mainimport ("fmt")func main() {// 创建字符串切片// 其长度和容量都是 5 个元素source := []string{"Apple", "Orange", "Plum", "Banana", "Grape"}// 将第三个元素切片,并限制容量// 其长度为 1 个元素,容量为 1 个元素slice := source[2:3:3]// 向 slice 追加新字符串slice = append(slice, "Kiwi")fmt.Println(slice)}

通过设置长度和容量一样,之后对数组的append操作都是复制原有元素新建的数组,实现了和原来数组完全隔离。

[Running] go run "d:\GolandProjects\code-master\demo\hello.go"[Plum Kiwi][Done] exited with code=0 in 1.286 seconds

内置函数append也是一个可变参数的函数,如果使用...运算符,可以将一个切片的所有元素追加到另一个切片里

package mainimport ("fmt")func main() {// 创建两个切片,并分别用两个整数进行初始化s1 := []int{1, 2}s2 := []int{3, 4}// 将两个切片追加在一起,并显示结果fmt.Printf("%v\n", append(s1, s2...))}

使用 Printf 时用来显示 append 函数返回的新切片的值

[Running] go run "d:\GolandProjects\code-master\demo\hello.go"[1 2 3 4][Done] exited with code=0 in 1.472 second

迭代切片

既然切片是一个集合,可以迭代其中的元素。Go语言有个特殊的关键字range,它可以配合关键字for来迭代切片里的元素

  • 使用for range迭代切片
package mainimport ("fmt")func main() {// 创建一个整型切片// 其长度和容量都是 4 个元素slice := []int{10, 20, 30, 40}// 迭代每一个元素,并显示其值for index, value := range slice {fmt.Printf("Index: %d Value: %d\n", index, value)}}

当迭代切片时,关键字range 会返回两个值。第一个值是当前迭代到的索引位置,第二个值是该位置对应元素值的一份副本

[Running] go run "d:\GolandProjects\code-master\demo\hello.go"Index: 0 Value: 10Index: 1 Value: 20Index: 2 Value: 30Index: 3 Value: 40[Done] exited with code=0 in 1.543 seconds

需要强调的是,range 创建了每个元素的副本,而不是直接返回对该元素的引用

  • range 提供了每个元素的副本
  • 使用空白标识符(下划线)来忽略索引值
for _, value := range slice {fmt.Printf("Value: %d\n", value)}
  • 使用传统的for循环对切片进行迭代
package mainimport ("fmt")func main() {// 创建一个整型切片// 其长度和容量都是 4 个元素slice := []int{10, 20, 30, 40}// 迭代每一个元素,并显示其值for index := 2; index < len(slice); index++ {fmt.Printf("Index: %d Value: %d\n", index, slice[index])}}

有两个特殊的内置函数lencap,可以用于处理数组、切片和通道。对于切片,函数len返回切片的长度

[Running] go run "d:\GolandProjects\code-master\demo\hello.go"Index: 2 Value: 30Index: 3 Value: 40[Done] exited with code=0 in 1.235 seconds

函数cap返回切片的容量

package mainimport ("fmt")func main() {// 创建一个整型切片// 其长度和容量都是 4 个元素slice := []int{10, 20, 30, 40}// 迭代每一个元素,并显示其值for index := cap(slice)-1; index >= 0; index-- {fmt.Printf("Index: %d Value: %d\n", index, slice[index])}}========================[Running] go run "d:\GolandProjects\code-master\demo\hello.go"Index: 3 Value: 40Index: 2 Value: 30Index: 1 Value: 20Index: 0 Value: 10[Done] exited with code=0 in 1.372 seconds

多维切片

  • 声明多维切片
// 创建一个整型切片的切片slice := [][]int{{10}, {100, 200}}
  • 组合切片的切片
package mainimport ("fmt")func main() {// 创建一个整型切片的切片slice := [][]int{{10}, {100, 200}}// 为第一个切片追加值为 20 的元素slice[0] = append(slice[0], 20)fmt.Print(slice)}

Go语言里使用append函数处理追加的方式很简明:先增长切片,再将新的整型切片赋值给外层切片的第一个元素

[Running] go run "d:\GolandProjects\code-master\demo\hello.go"[[10 20] [100 200]][Done] exited with code=0 in 1.451 seconds

在函数间传递切片

在函数间传递切片就是要在函数间以值的方式传递切片。由于切片的尺寸很小,在函数间复制传递切片成本也很低。让我们创建一个大切片,并将这个切片以值的方式传递给函数 foo

// 分配包含 100 万个整型值的切片slice := make([]int, 1e6)// 将 slice 传递到函数 fooslice = foo(slice)// 函数 foo 接收一个整型切片,并返回这个切片func foo(slice []int) []int {...return slice} 

64位架构的机器上,一个切片需要24字节的内存:指针字段需要 8 字节,长度容量字段分别需要 8 字节

由于与切片关联的数据包含在底层数组里,不属于切片本身,所以将切片复制到任意函数的时候,对底层数组大小都不会有影响。复制时只会复制切片本身,不会涉及底层数组

Go语言实战之切片的内部实现和基础功能

在函数间传递 24 字节的数据会非常快速、简单。这也是切片效率高的地方。不需要传递指针和处理复杂的语法,只需要复制切片,按想要的方式修改数据,然后传递回一份新的切片副本。