Go切片(slice):用法和内部结构

本文翻译自《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)

可以使用内置的lencap函数检查切片的长度和容量。

len(s) == 5
cap(s) == 5

接下来的两节讨论长度和容量之间的关系。

切片的零值为nil。对于nil切片,lencap函数都将返回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以下来访问数组中较早的元素。

增长切片的元素(copyappend函数)

为了增加切片的容量,必须创建一个新的、更大的切片,并将原始切片的内容复制到其中。这项技术是其他语言的动态数组在幕后的实现方式。下一个示例通过创建一个新的切片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语言规范定义了切片及其相关的辅助函数。