简介

切片(slice)是 Go 语言提供的一种数据结构,使用非常简单、便捷。但是由于实现层面的原因,切片也经常会产生让人疑惑的结果。掌握切片的底层结构和原理,可以避免很多常见的使用误区。

底层结构

切片结构定义在源码runtime包下的 slice.go 文件中:

1
2
3
4
5
6
// src/runtime/slice.go
type slice struct {
  array unsafe.Pointer
  len int
  cap int
}
  • array:一个指针,指向底层存储数据的数组
  • len:切片的长度,在代码中我们可以使用len()函数获取这个值
  • cap:切片的容量,即在不扩容的情况下,最多能容纳多少元素。在代码中我们可以使用cap()函数获取这个值

我们可以通过下面的代码输出切片的底层结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
type slice struct {
  array unsafe.Pointer
  len   int
  cap   int
}

func printSlice() {
  s := make([]uint32, 1, 10)
  fmt.Printf("%#v\n", *(*slice)(unsafe.Pointer(&s)))
}

func main() {
  printSlice()
}

运行输出:

1
main.slice{array:(unsafe.Pointer)(0xc0000d6030), len:1, cap:10}

这里注意一个细节,由于runtime.slice结构是非导出的,我们不能直接使用。所以我在代码中手动定义了一个slice结构体,字段与runtime.slice结构相同。

我们结合切片的底层结构,先回顾一下切片的基础知识,然后再逐一看看切片的常见问题。

基础知识

创建切片

创建切片有 4 种方式:

  1. var

var声明切片类型的变量,这时切片值为nil

1
var s []uint32

这种方式创建的切片,array字段为空指针,lencap字段都等于 0。

  1. 切片字面量

使用切片字面量将所有元素都列举出来,这时切片长度和容量都等于指定元素的个数。

1
s := []uint32{1, 2, 3}

创建之后s的底层结构如下:

lencap字段都等于 3。

  1. make

使用make创建,可以指定长度和容量。格式为make([]type, len[, cap]),可以只指定长度,也可以长度容量同时指定:

1
2
3
s1 := make([]uint32)
s2 := make([]uint32, 1)
s3 := make([]uint32, 1, 10)
  1. 切片操作符

使用切片操作符可以从现有的切片或数组中切取一部分,创建一个新的切片。切片操作符格式为[low:high],例如:

1
2
3
4
5
var arr [10]uint32
s1 := arr[0:5]
s2 := arr[:5]
s3 := arr[5:]
s4 := arr[:]

区间是左闭右开的,即[low, high),包括索引low,不包括high。切取生成的切片长度为high-low

另外lowhigh都有默认值。low默认为 0,high默认为原切片或数组的长度。它们都可以省略,省略时,相当于取默认值。

使用这种方式创建的切片底层共享相同的数据空间,在进行切片操作时可能会造成数据覆盖,要格外小心。

添加元素

可以使用append()函数向切片中添加元素,可以一次添加 0 个或多个元素。如果剩余空间(即cap-len)足够存放元素则直接将元素添加到后面,然后增加字段len的值即可。反之,则需要扩容,分配一个更大的数组空间,将旧数组中的元素复制过去,再执行添加操作。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

import "fmt"

func main() {
  s := make([]uint32, 0, 4)

  s = append(s, 1, 2, 3)
  fmt.Println(len(s), cap(s)) // 3 4

  s = append(s, 4, 5, 6)
  fmt.Println(len(s), cap(s)) // 6 8
}

你不知道的 slice

  1. 空切片等于nil吗?

下面代码的输出什么?

1
2
3
4
5
6
7
8
9
func main() {
  var s1 []uint32
  s2 := make([]uint32, 0)

  fmt.Println(s1 == nil)
  fmt.Println(s2 == nil)
  fmt.Println("nil slice:", len(s1), cap(s1))
  fmt.Println("cap slice:", len(s2), cap(s2))
}

分析:

首先s1s2的长度和容量都为 0,这很好理解。比较切片与nil是否相等,实际上要检查slice结构中的array字段是否是空指针。显然s1 == nil返回trues2 == nil返回false。尽管s2长度为 0,但是make()为它分配了空间。所以,一般定义长度为 0 的切片使用var的形式

  1. 传值还是传引用?

下面代码的输出什么?

1
2
3
4
5
6
7
func main() {
  s1 := []uint32{1, 2, 3}
  s2 := append(s1, 4)

  fmt.Println(s1)
  fmt.Println(s2)
}

分析:

为什么append()函数要有返回值?因为我们将切片传递给append()时,其实传入的是runtime.slice结构。这个结构是按值传递的,所以函数内部对array/len/cap这几个字段的修改都不影响外面的切片结构。上面代码中,执行append()之后s1lencap保持不变,故输出为:

1
2
[1 2 3]
[1 2 3 4]

所以我们调用append()要写成s = append(s, elem)这种形式,将返回值赋值给原切片,从而覆写array/len/cap这几个字段的值。

初学者还可能会犯忽略append()返回值的错误:

1
append(s, elem)

这就更加大错特错了。添加的元素将会丢失,以为函数外切片的内部字段都没有变化。

我们可以看到,虽说切片是按引用传递的,但是实际上传递的是结构runtime.slice的值。只是对现有元素的修改会反应到函数外,因为底层数组空间是共用的。

  1. 切片的扩容策略

下面代码的输出是什么?

1
2
3
4
5
6
func main() {
  var s1 []uint32
  s1 = append(s1, 1, 2, 3)
  s2 := append(s1, 4)
  fmt.Println(&s1[0] == &s2[0])
}

这涉及到切片的扩容策略。扩容时,若:

  • 当前容量小于 1024,则将容量扩大为原来的 2 倍;
  • 当前容量大于等于 1024,则将容量逐次增加原来的 0.25 倍,直到满足所需容量。

我翻看了 Go1.16 版本runtime/slice.go中扩容相关的源码,在执行上面规则后还会根据切片元素的大小和计算机位数进行相应的调整。整个过程比较复杂,感兴趣可以自行去研究。

我们只需要知道一开始容量较小,扩大为 2 倍,降低后续因添加元素导致扩容的频次。容量扩张到一定程度时,再按照 2 倍来扩容会造成比较大的浪费。

上面例子中执行s1 = append(s1, 1, 2, 3)后,容量会扩大为 4。再执行s2 := append(s1, 4)由于有足够的空间,s2底层的数组不会改变。所以s1s2第一个元素的地址相同。

  1. 切片操作符可以切取字符串

切片操作符可以切取字符串,但是与切取切片和数组不同。切取字符串返回的是字符串,而非切片。因为字符串是不可变的,如果返回切片。而切片和字符串共享底层数据,就可以通过切片修改字符串了。

1
2
3
4
func main() {
  str := "hello, world"
  fmt.Println(str[:5])
}

输出 hello。

  1. 切片底层数据共享

下面代码的输出是什么?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func main() {
  array := [10]uint32{1, 2, 3, 4, 5}
  s1 := array[:5]

  s2 := s1[5:10]
  fmt.Println(s2)

  s1 = append(s1, 6)
  fmt.Println(s1)
  fmt.Println(s2)
}

分析:

首先注意到s2 := s1[5:10]上界 10 已经大于切片s1的长度了。要记住,使用切片操作符切取切片时,上界是切片的容量,而非长度。这时两个切片的底层结构有重叠,如下图:

这时输出s2为:

1
[0, 0, 0, 0, 0]

然后向切片s1中添加元素 6,这时结构如下图,其中切片s1s2共享元素 6:

这时输出的s1s2为:

1
2
[1, 2, 3, 4, 5, 6]
[6, 0, 0, 0, 0]

可以看到由于切片底层数据共享可能造成修改一个切片会导致其他切片也跟着修改。这有时会造成难以调试的 BUG。为了一定程度上缓解这个问题,Go 1.2 版本中提供了一个扩展切片操作符:[low:high:max],用来限制新切片的容量。使用这种方式产生的切片容量为max-low

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func main() {
  array := [10]uint32{1, 2, 3, 4, 5}
  s1 := array[:5:5]

  s2 := array[5:10:10]
  fmt.Println(s2)

  s1 = append(s1, 6)
  fmt.Println(s1)
  fmt.Println(s2)
}

执行s1 := array[:5:5]我们限定了s1的容量为 5,这时结构如下图所示:

执行s1 = append(s1, 6)时,发现没有空闲容量了(因为len == cap == 5),重新创建一个底层数组再执行添加。这时结构如下图,s1s2互不干扰:

总结

了解了切片的底层数据结构,知道了切片传递的是结构runtime.slice的值,我们就能解决 90% 以上的切片问题。再结合图形可以很直观的看到切片底层数据是如何操作的。

这个系列的名字是我仿造《你不知道的 JavaScript》起的😀。

参考

  1. 《Go 专家编程》,豆瓣链接:https://book.douban.com/subject/35144587/
  2. 你不知道的Go GitHub:https://github.com/darjun/you-dont-know-go

我的博客:https://darjun.github.io

欢迎关注我的微信公众号【GoUpUp】,共同学习,一起进步~