Go Slices 切片:基础使用和内部实现

未匹配的标注

本文为官方 Go Blog 的中文翻译,详见 翻译说明

Andrew Gerrand
2011 年 01 月 05 日

介绍

Go 的 slice 类型提供了一种方便有效的方式来处理数据序列。slice 类似于其他语言中的数组,但具有一些不同寻常的特性。本文将研究什么是切片以及如何使用切片。

数组

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

数组类型定义时要指定长度和元素类型。例如 [4]int 表示长度为 4 的整形数组。数组的大小是固定的,其长度是其类型的一部分([4]int 和 [5]int 是不同的,不兼容的类型)。数组可以用常规方式建立索引,因此 s[n] 可以访问第 n 个元素(从 0 开始)。

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

数组不需要显示初始化。数组的零值是一个现成的数组,其元素本身为零:

// a[2] == 0,int 类型的零值

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

Go 的数组是值。数组变量表示的是整个数组,而不是指向第一个数组元素的指针(就像 C 语言的数组变量,就是指向第一个元素的指针)。这意味着当你分配或者传递一个数组值时,你将复制它的内容。(为了避免这种复制,你可以传递一个数组的 pointer,但是这就是一个指向数组的指针了,而不是一个数组。)考虑数组的一种方式是将它看做一种使用索引字段而不是命名字段的结构体:大小固定的复合值。

可以通过如下方式初始化数组:

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)

切片的长度和容量可以用内置函数 len 和 cap 来获取。

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

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

切片的零值是 nil。nil 切片的长度和容量都是 0 。

一个切片可以从已存在的切片或者数组创建。用通过冒号分割的两个索引指定的半开范围可以形成切片。比如,b[1:4] 创建了一个包含 b 的 1 至 3 元素的切片(所得切片的索引范围是 0 到 2 )。

b := []byte{'g', 'o', 'l', 'a', 'n', 'g'}
// b[1:4] == []byte{'o', 'l', 'a'},与 b 共享存储空间

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

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

这也是在给定数组的情况下创建切片的语法:

x := [3]string{"Лайка", "Белка", "Стрелка"}
s := x[:] // 指向 x 存储空间的切片

切片内部

切片是数组段的描述符。它由一个指向数组的指针,段的长度及其容量(段的最大长度)组成。

变量 s,由 make([]byte, 5)先前创建,其结构如下:

长度是切片所引用的元素数。容量是基础数组中元素的数量(从切片指针所指的元素开始)。在接下来的几个示例中,将明确长度和容量之间的区别。

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

切片不能超出其容量。尝试这样做会导致运行时恐慌,就像在切片或数组的边界之外进行索引时一样。同样,无法将切片重新切片为零以下以访问数组中的早期元素。

增长切片 (copy和append函数)

要增加切片的容量,必须创建一个更大的新切片,并将原始切片的内容复制到其中。这种技术是来自其他语言的动态数组实现在后台工作的方式。下一个示例通过制作一个新切片t,将s的内容复制到t中,然后将s的容量增加一倍将切片值t分配给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

常见的操作是将数据附加到切片的末尾。这个函数可以附加 Byte 元素到 Byte 切片,有必要的话会增长切片,最后返回更新后的切片:

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)就像零长度切片一样,所以可以声明一个切片变量,然后在循环中附加元素:

// 过滤器返回一个新的切片,它只保留 s 中满足 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 可以构造该函数的更简洁版本,这留给读者自己做练习。

延伸阅读

Effective Go 中有对 slices 和 arrays 的深入讲解,Go 语言规范 中有 slices 的定义,以及 slice 长度和容量的 关系,还有 使用帮助 和 函数讲解

本文章首发在 LearnKu.com 网站上。

本译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。

原文地址:https://learnku.com/docs/go-blog/go-slic...

译文地址:https://learnku.com/docs/go-blog/go-slic...

上一篇 下一篇
Summer
贡献者:4
讨论数量: 0
发起讨论 只看当前版本


暂无话题~