切片(Slices)的用法和内部机制

221 阅读9分钟

翻译原文:Go Slices: usage and internals - The Go Programming Language

本文由 Chat GPT和本人进行翻译(我是复制文字的那个LOL)

安德鲁·格兰德

2011年1月5日

引言

Go语言的切片类型提供了一种方便高效的方式来处理带有类型数据的序列。切片类似于其他语言中的数组,但具有一些不寻常的特性。本文将介绍切片的概念及其用法。

数组

切片类型是建立在Go语言数组类型之上的一种抽象,因此要理解切片,我们首先要了解数组。

数组类型定义指定了长度和元素类型。例如,类型[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 的内存表示只是将四个整数值按顺序排列:

image.png

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 函数接受一个类型、一个长度和一个可选的容量参数。当调用 make 函数时,它会分配一个数组并返回一个引用该数组的切片。

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

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

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

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

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

这也是使用数组创建切片的语法:

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

切片的内部

切片是数组段的描述符。它由指向数组的指针、段的长度和容量(段的最大长度)组成。

image.png

我们之前使用 make([]byte, 5) 创建的变量 s 的结构如下所示:

image.png

长度(length)是切片所引用的元素数量。容量(capacity)是底层数组中的元素数量(从切片指针所指向的元素开始计算)。在接下来的示例中,我们将清楚地看到长度和容量之间的区别。

在对 s 进行切片时,请观察切片数据结构的变化以及它们与底层数组的关系:

s = s[2:4]

image.png

切片操作不会复制切片的数据。它创建一个新的切片值,该值指向原始数组。这使得切片操作和操作数组索引一样高效。因此,对重新切片的元素进行修改(而不是切片本身)会修改原始切片的元素:

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)]

image.png

切片不能超出其容量进行扩展。尝试这样做会导致运行时恐慌(panic),就像在切片或数组的边界之外进行索引操作时一样。同样地,切片也不能通过将其重新切片到负数来访问数组中的早期元素。

增加切片的容量(the copy and append functions)

要增加切片的容量,必须创建一个新的、更大的切片,并将原始切片的内容复制到其中。这种技术是其他语言中动态数组实现背后的工作原理。下面的示例通过创建一个新的切片 t,将 s 的内容复制到 t 中,然后将切片值 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 函数将数据从源切片复制到目标切片。它返回复制的元素数量。

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) { // 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 这样的函数非常有用,因为它们可以完全控制切片的增长方式。根据程序的特性,可能希望以较小或较大的块进行分配,或者对重新分配的大小设定一个上限。

但是大多数程序并不需要完全控制,因此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...) // equivalent to "append(a, b[0], b[1], b[2])"
// a == []string{"John", "Paul", "George", "Ringo", "Pete"}

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

// 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
}

一个可能的“坑”

如前所述,对切片进行重新切片并不会复制底层数组。完整的数组将一直保存在内存中,直到没有任何引用指向它。偶尔,这可能会导致程序在只需要其中一小部分数据时占用全部内存。

例如,下面的 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 构建更简洁的版本的这个函数。这留给读者作为一个练习。

完。