Go 博客

Go Slices:用法和内部机制

Andrew Gerrand
2011 年 1 月 5 日

引言

Go 的 slice 类型提供了一种方便高效的方法来处理带类型的数据序列。Slices 类似于其他语言中的数组,但有一些不寻常的属性。本文将探讨 slice 是什么以及如何使用它们。

数组

slice 类型是构建在 Go 的数组类型之上的抽象,因此要理解 slice,我们必须首先理解数组。

数组类型定义指定了长度和元素类型。例如,类型 [4]int 表示一个包含四个整数的数组。数组的大小是固定的;其长度是其类型的一部分([4]int[5]int 是不同且不兼容的类型)。数组可以像通常一样进行索引,因此表达式 s[n] 访问从零开始的第 n 个元素。

var a [4]int
a[0] = 1
i := a[0]
// i == 1

数组不需要显式初始化;数组的零值是一个随时可用的数组,其元素本身都是零值

// a[2] == 0, the zero value of the int type

[4]int 的内存表示就是四个整数值顺序排列

Go 的数组是值。一个数组变量表示整个数组;它不是指向数组第一个元素的指针(就像 C 中那样)。这意味着当你赋值或传递数组值时,你会复制其内容。(为了避免复制,你可以传递数组的 *指针*,但那样是指向数组的指针,而不是数组。)理解数组的一种方式是将其视为一种结构体,但字段是通过索引而不是名称来访问的:一个固定大小的复合值。

数组字面量可以这样指定

b := [2]string{"Penn", "Teller"}

或者,你可以让编译器为你计算数组元素

b := [...]string{"Penn", "Teller"}

在这两种情况下,b 的类型都是 [2]string

Slices

数组有其用途,但它们有点不灵活,所以在 Go 代码中不经常看到它们。然而,slices 随处可见。它们基于数组构建,提供了强大的功能和便利性。

slice 的类型规范是 []T,其中 T 是 slice 元素的类型。与数组类型不同,slice 类型没有指定长度。

slice 字面量的声明方式与数组字面量相同,只是省略了元素计数

letters := []string{"a", "b", "c", "d"}

slice 可以使用内置函数 make 创建,其签名是

func make([]T, len, cap) []T

其中 T 代表要创建的 slice 的元素类型。make 函数接受一个类型、一个长度和一个可选容量。调用时,make 分配一个数组并返回一个引用该数组的 slice。

var s []byte
s = make([]byte, 5, 5)
// s == []byte{0, 0, 0, 0, 0}

当容量参数省略时,它默认为指定的长度。这里是同一代码更简洁的版本

s := make([]byte, 5)

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

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

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

slice 的零值是 nil。对于 nil slice,lencap 函数都将返回 0。

slice 也可以通过“切分”(slicing)现有 slice 或数组来形成。切分是通过指定一个半开范围来实现的,该范围由两个索引和一个冒号分隔。例如,表达式 b[1:4] 创建一个包含 b 的元素 1 到 3 的 slice(结果 slice 的索引将是 0 到 2)。

b := []byte{'g', 'o', 'l', 'a', 'n', 'g'}
// b[1:4] == []byte{'o', 'l', 'a'}, sharing the same storage as b

slice 表达式的起始索引和结束索引是可选的;它们分别默认为零和 slice 的长度

// b[:2] == []byte{'g', 'o'}
// b[2:] == []byte{'l', 'a', 'n', 'g'}
// b[:] == b

这也是给定数组创建 slice 的语法

x := [3]string{"Лайка", "Белка", "Стрелка"}
s := x[:] // a slice referencing the storage of x

Slice 内部机制

slice 是一个数组段的描述符。它包含一个指向数组的指针、该段的长度和其容量(该段的最大长度)。

我们之前通过 make([]byte, 5) 创建的变量 s 的结构如下

长度是 slice 引用的元素数量。容量是底层数组中的元素数量(从 slice 指针引用的元素开始)。长度和容量之间的区别将在我们后续的例子中阐明。

当我们切分 s 时,观察 slice 数据结构的变化及其与底层数组的关系

s = s[2:4]

切分不会复制 slice 的数据。它创建一个新的 slice 值,指向原始数组。这使得 slice 操作与操作数组索引一样高效。因此,修改 re-slice 的*元素*(而不是 slice 本身)会修改原始 slice 的元素

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[:cap(s)]

slice 的长度不能超出其容量。尝试这样做将导致运行时 panic,就像索引超出 slice 或数组的边界一样。类似地,slice 不能重新切分到小于零的索引以访问数组中较早的元素。

增长 slices (copy 和 append 函数)

要增加 slice 的容量,必须创建一个新的、更大的 slice,并将原始 slice 的内容复制到其中。这种技术是其他语言中动态数组实现在幕后的工作方式。下面的例子通过创建一个新的 slice t,将 s 的内容复制到 t,然后将 slice 值 t 赋给 s 来将 s 的容量加倍。

t := make([]byte, len(s), (cap(s)+1)*2) // +1 in case cap(s) == 0
for i := range s {
        t[i] = s[i]
}
s = t

内置的 copy 函数简化了这种常见操作的循环部分。顾名思义,copy 将数据从源 slice 复制到目标 slice。它返回复制的元素数量。

func copy(dst, src []T) int

copy 函数支持在不同长度的 slice 之间复制(它只会复制较短 slice 的元素数量)。此外,copy 可以处理共享相同底层数组的源 slice 和目标 slice,并正确处理重叠的 slice。

使用 copy,我们可以简化上面的代码片段

t := make([]byte, len(s), (cap(s)+1)*2)
copy(t, s)
s = t

一种常见的操作是将数据追加到 slice 的末尾。这个函数将字节元素追加到一个字节 slice 中,如果需要则增长 slice,并返回更新后的 slice 值。

func AppendByte(slice []byte, data ...byte) []byte {
    m := len(slice)
    n := m + len(data)
    if n > cap(slice) { // if necessary, reallocate
        // allocate double what's needed, for future growth.
        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 这样的函数很有用,因为它们提供了对 slice 增长方式的完全控制。根据程序的特点,可能需要以较小或较大的块分配内存,或者对重新分配的大小设置上限。

但大多数程序不需要完全控制,因此 Go 提供了一个适用于大多数情况的内置 append 函数;它的签名是

func append(s []T, x ...T) []T

append 函数将元素 x 追加到 slice s 的末尾,如果需要更大的容量,则增长 slice。

a := make([]int, 1)
// a == []int{0}
a = append(a, 1, 2, 3)
// a == []int{0, 1, 2, 3}

要将一个 slice 追加到另一个 slice,使用 ... 将第二个参数展开为参数列表。

a := []string{"John", "Paul"}
b := []string{"George", "Ringo", "Pete"}
a = append(a, b...) // equivalent to "append(a, b[0], b[1], b[2])"
// a == []string{"John", "Paul", "George", "Ringo", "Pete"}

由于 slice 的零值 (nil) 行为类似于零长度 slice,你可以声明一个 slice 变量,然后在循环中向其追加元素

// Filter returns a new slice holding only
// the elements of s that satisfy fn()
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
}

一个可能的“陷阱”

如前所述,re-slice 一个 slice 并不会复制底层数组。完整的数组将保留在内存中,直到不再被引用。有时这会导致程序在只需要其中一小部分数据时却将所有数据都保留在内存中。

例如,这个 FindDigits 函数将文件加载到内存中,搜索其中的第一组连续数字,并将它们作为新的 slice 返回。

var digitRegexp = regexp.MustCompile("[0-9]+")

func FindDigits(filename string) []byte {
    b, _ := ioutil.ReadFile(filename)
    return digitRegexp.Find(b)
}

这段代码按宣传的那样工作,但返回的 []byte 指向包含整个文件的数组。由于 slice 引用了原始数组,只要 slice 存在,垃圾回收器就无法释放数组;文件中少量有用的字节导致整个内容都保留在内存中。

为了解决这个问题,可以在返回有趣的数据之前将其复制到一个新的 slice 中

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 Goslicesarrays 进行了深入探讨,并且 Go 语言规范 定义了 slices 以及它们的相关辅助函数

下一篇文章:JSON 和 Go
上一篇文章:Go:一年前的今天
博客索引