本文翻译自《https://go.dev/blog/slices-intro》。
Andrew Gerrand
2011年1月5日
介绍
Go的切片(slice)类型提供了一种方便有效的方法来处理有类型的数据的序列。切片类似于其他语言中的数组,但具有一些不同寻常的属性。本文将介绍切片是什么以及它们的使用方法。
数组
切片类型是建立在Go的数组类型之上的抽象,因此要理解切片我们必须首先理解数组。
数组类型通过指定长度和元素类型来定义。例如,类型[4]int
表示一个包含四个整数的数组。数组的大小是固定的;它的长度是其类型的一部分(例如[4]int
和[5]int
是不同的、不兼容的类型)。数组可以用通常的方式索引,所以表达式s[n]
访问第n个元素,从0开始。
var a [4]int
a[0] = 1
i := a[0]
// i == 1
数组不需要显式初始化;数组的零值是其所有元素本身为零值的现成(ready-to-use)数组:
// a[2] == 0,是int类型的零值
[4]int
的内存表示形式仅为顺序排列的四个整数值:
Go语言的数组是值类型的。数组变量表示整个数组;它不是指向第一个数组元素的指针(C语言中的情况)。这意味着当你分配或传递数组值时,你将拷贝其所有内容(为了避免拷贝,你可以用一个指针指向数组,这是指向数组的指针,而不是数组本身)。可以将数组视为一种结构体,它具有索引字段而非命名字段,它是一种固定大小的复合值。
数组字面量可以这样指定:
b := [2]string{"Penn", "Teller"}
或者,你也可以让编译器为你计数数组元素的个数:
b := [...]string{"Penn", "Teller"}
这两种情况b
的类型都是[2]string
。
切片
数组有它的空间,但有点不灵活,所以你不会在Go代码中经常看到数组。不过,切片无处不在。它们以数组为基础,提供强大的功能和便利。
切片的类型是[]T
,其中T
是切片里的元素的类型。与数组类型不同,切片类型没有指定长度。
切片字面量的声明就像数组字面量一样,只是你省略了元素计数:
letters := []string{"a", "b", "c", "d"}
可以使用内置函数make
创建切片,该函数的签名如下:
func make([]T, len, cap) []T
其中T
代表要创建的切片的元素的类型。make
函数接收一个类型T
、一个长度len
和一个可选的容量cap
。调用时,make
分配一个数组并返回一个引用该数组的切片。
var s []byte
s = make([]byte, 5, 5)
// s == []byte{0, 0, 0, 0, 0}
当省略cap
参数时,它默认等于指定的长度len
。这是同一代码的更简洁的版本:
s := make([]byte, 5)
可以使用内置的len
和cap
函数检查切片的长度和容量。
len(s) == 5
cap(s) == 5
接下来的两节讨论长度和容量之间的关系。
切片的零值为nil
。对于nil
切片,len
和cap
函数都将返回0。
切片也可以通过“切片”现有切片或数组来形成。切片是通过指定一个半开放区间来完成的,其中两个索引由冒号分隔。例如,表达式b[1:4]
创建一个包含b的下标从1到3的元素的切片(所得切片的索引将是0到2)。
b := []byte{'g', 'o', 'l', 'a', 'n', 'g'}
// b[1:4] == []byte{'o', 'l', 'a'}, sharing the same storage as b
切片表达式的开始和结束索引是可选的;它们分别默认为0和切片的长度:
// b[:2] == []byte{'g', 'o'}
// b[2:] == []byte{'l', 'a', 'n', 'g'}
// b[:] == b
这也是在给定一个数组的情况下,创建一个指向它的切片的语法:
x := [3]string{"Лайка", "Белка", "Стрелка"}
s := x[:] // 一个指向数组x的切片
切片内部
一个切片是一个数组片段的描述符。它由指向数组的指针、片段的长度及其容量(片段的最大长度)组成。
之前由make([]byte, 5)
创建的变量s
的结构如下:
长度是切片引用的数组元素的个数。容量是底层数组中的元素的个数(从切片指针指向的元素开始数)。在接下来的几个示例中,长度和容量之间的区别将变得更加清晰。
当我们对s
进行切片时,观察切片数据结构的变化及其与底层数组之间的关系:
s = s[2:4]
切片不会复制底层数组的数据。它将创建一个指向原始数组的新切片值。这使得切片操作与处理数组索引一样高效。因此,修改新切片值的元素(而不是新切片值本身)会修改原始切片的元素:
d := []byte{'r', 'o', 'a', 'd'}
e := d[2:]
// e == []byte{'a', 'd'}
e[1] = 'm'
// e == []byte{'a', 'm'}
// d == []byte{'r', 'o', 'a', 'm'}
之前我们将s
切成比其容量短的长度。 我们可以通过再次切片来增加s
的容量:
s = s[:cap(s)]
切片不能超出其容量。尝试这样做会导致运行时panic
,就像索引超出切片或数组边界时一样。同样,不能将切片重新切片到0以下来访问数组中较早的元素。
增长切片的元素(copy
和append
函数)
为了增加切片的容量,必须创建一个新的、更大的切片,并将原始切片的内容复制到其中。这项技术是其他语言的动态数组在幕后的实现方式。下一个示例通过创建一个新的切片t
,将s
的内容复制到t
,然后将t
赋值给s
,从而使s
的容量加倍:
t := make([]byte, len(s), (cap(s)+1)*2) // +1是为了防止cap(s) == 0的情况
for i := range s {
t[i] = s[i]
}
s = t
内置的copy
函数使这种常见的循环操作变得更容易。顾名思义,copy
将数据从源切片复制到目标切片。它返回复制的元素个数。
func copy(dst, src []T) int
copy
函数支持在不同长度的切片之间进行复制(长度较短的那个切片复制或被复制完毕就不再继续)。此外,copy
可以处理共享同一底层数组的源切片和目标切片,正确地处理元素部分重叠的切片。
使用copy
函数,我们可以简化上面的代码片段:
t := make([]byte, len(s), (cap(s)+1)*2)
copy(t, s)
s = t
一个常见的操作是将数据追加到切片的末尾。此函数将字节元素附加到字节切片,必要时会增大切片的容量,返回更新后的切片:
func AppendByte(slice []byte, data ...byte) []byte {
m := len(slice)
n := m + len(data)
if n > cap(slice) { // 如果有必要,重新分配一个底层数组
// 考虑到未来的数据增长,在此处加倍底层数组的容量。
newSlice := make([]byte, (n+1)*2)
copy(newSlice, slice)
slice = newSlice
}
slice = slice[0:n]
copy(slice[m:n], data)
return slice
}
可以这样使用AppendByte
:
p := []byte{2, 3, 5}
p = AppendByte(p, 7, 11, 13)
// p == []byte{2, 3, 5, 7, 11, 13}
像AppendByte
这样的函数很有用,因为它们提供了对切片增长方式的完全控制。 根据程序的特性,可能需要分配更小或更大的块,或者对重新分配的大小设置上限。
但是大多数程序不需要完全控制,因此Go提供了一个适合大多数用途的内置append
函数,签名如下:
func append(s []T, x ...T) []T
append
函数将元素x
附加到切片s的末尾,并在需要更大容量时扩大切片。
a := make([]int, 1)
// a == []int{0}
a = append(a, 1, 2, 3)
// a == []int{0, 1, 2, 3}
要将一个切片附加到另一个切片,请使用...
将第二个参数展开为列表。
a := []string{"John", "Paul"}
b := []string{"George", "Ringo", "Pete"}
a = append(a, b...) // 等价于"append(a, b[0], b[1], b[2])"
// a == []string{"John", "Paul", "George", "Ringo", "Pete"}
由于切片的零值(nil)就像一个零长度切片,你可以声明一个切片变量,然后在循环中附加到它:
// Filter函数返回一个新切片,它只包含s切片中那些使fn函数返回true的元素
func Filter(s []int, fn func(int) bool) []int {
var p []int // == nil
for _, v := range s {
if fn(v) {
p = append(p, v)
}
}
return p
}
一个可能的“陷阱”
如前所述,重新切片不会复制底层数组。整个数组将保存在内存中,直到不再被引用为止。有时这会导致程序只需要一小部分数据,但将所有数据保存在内存中。
例如,FindDigits
函数将一个文件加载到内存中,并在其中搜索第一组连续数字,将它们作为一个切片返回。
var digitRegexp = regexp.MustCompile("[0-9]+")
func FindDigits(filename string) []byte {
b, _ := ioutil.ReadFile(filename)
return digitRegexp.Find(b)
}
此代码的行为确实满足要求,但返回的[]byte
指向包含整个文件的数组。由于切片引用了原始数组,只要切片一直存在,垃圾收集器就无法释放数组;为了文件的几个有用字节,就将文件的全部内容保存在内存中。
要解决此问题,可以在返回之前将感兴趣的数据复制到新切片:
func CopyDigits(filename string) []byte {
b, _ := ioutil.ReadFile(filename)
b = digitRegexp.Find(b)
c := make([]byte, len(b))
copy(c, b)
return c
}
可以使用append
函数简化上述函数的代码。这留给读者作为练习。
进一步阅读
Effective Go包含对切片和数组的深入处理,Go语言规范定义了切片及其相关的辅助函数。