详解 Go 中的 slice - 官方文档翻译

《Arrays, slices (and strings): The mechanics of ‘append’ 》翻译,原文地址

相关资料:Go Slices:使用及内部特性

1. 介绍

编程语言最常见的特征之一就是数组。数组看起来简单,但在将它们添加到编程语言中时,必须回答很多问题,例如:

  • 固定大小还是可变大小?
  • 数组大小(size)是类型的一部分吗?
  • 多维数组什么样?
  • 空数组是否有意义?

这些问题的答案会决定数组仅仅是语言的一个特征或是语言设计中的核心部分。

在 Go 的早期开发中,花了大约一年的时间来寻找这些问题的答案,然后才找到了设计感觉。关键的一步是引入 slice,这些 slice 基于固定大小的数组构建,提供了灵活的、可扩展的数据结构。然而,刚接触 Go 的程序员经常会在 slice 上踩坑,也许是其他语言的经验使他们难以理解 slice。

在这篇文章中,我们将试图澄清混乱。我们将通过构建片断(pieces)来解释 append 内置函数的工作方式,以及它为什么如此工作。

2. 数组 array

数组是 Go 的重要组成部分,但像建筑物的基础一样,它们通常隐藏在更多可见组件之下。我们必须简要地谈论它们,然后再讨论 slice 更有趣、更强大和更突出的特点。

在 Go 程序中不常见数组,因为数组的大小是其类型的一部分,这限制了其表现力。

下面的声明

var buffer [256]byte

声明了包含 256 个字节的变量缓冲区。缓冲区的类型包括其大小,[256]byte。一个 512 字节的数组将是不同类型的 [512]byte

与数组关联的数据就是:元素数组。上面例子中的 buffer 在内存中看起来像这样:

buffer: byte byte byte ... 256 times ... byte byte byte

也就是说,变量保存 256 个字节的数据,没有别的。我们可以用熟悉的索引语法 buffer[0]buffer[1] 直到 buffer[255] 来访问它的元素。索引范围 0 到 255 覆盖了 256 个元素,试图用超出此范围的值索引 buffer 将导致程序崩溃。

有一个名为 len 的内置函数,它返回一个数组或 slice 的元素数量,还有一些其他数据类型的元素数量。对于数组,len 显然会返回数组元素的个数。在我们的例子中,len(buffer) 返回固定值 256。

数组有它们的用处 - 它们可以很好地表示转换矩阵(transformation matrix) - 但它们在 Go 中的最常见用途是为 slice 保存存储空间(hold storage for a slice)。

3. Slices: The slice header

slice 是重点,但要充分利用 slice,必须明确地知道它们是什么以及它们做什么。

slice 是个数据结构,用来描述与 slice 变量本身分开存储的数组的连续部分。slice 不是数组。一个 slice 描述了一个数组的一部分。

根据前一节中的 buffer 数组变量,我们可以通过对数组进行切片来创建一个描述元素 100 到 150(准确地说,包括 100 到 149)的 slice:

var slice []byte = buffer[100:150]

在这段代码中,我们使用了完整的变量声明。变量 slice 的类型是 []byte,读作“slice of bytes”,并且通过切分元素 100(含)至 150(不含)来从数组 buffer 中初始化元素。更常用的语法会删除由初始化表达式设置的类型:

var slice = buffer[100:150]

在函数内部,可以使用短声明格式,

slice := buffer[100:150]

这个 slice 变量究竟是什么? 这不是完整的故事,但是现在将 slice 想象成具有两个元素的小数据结构:长度和指向数组元素的指针。可以把它看作是在幕后这样构建的:

type sliceHeader struct {
    Length        int
    ZerothElement *byte
}

slice := sliceHeader{
    Length:        50,
    ZerothElement: &buffer[100],
}

当然,这只是一个例证。尽管这段代码说 sliceHeader 结构对程序员来说是不可见的,并且元素指针的类型取决于元素的类型,但是这给出了机制的一般概念。

到目前为止,我们已经对数组使用了切片操作,但我们也可以对 slice 进行切片,如下所示:

slice2 := slice[5:10]

跟之前一样,这个操作会创建新的 slice,这个例子中用原先 slice 的元素 5 到 9,即原先数组的元素 105 到 109。slice2 变量的底层 sliceHeader 结构如下所示:

slice2 := sliceHeader{
    Length:        5,
    ZerothElement: &buffer[105],
}

注意,这个 header 仍指向存储在 buffer 变量中的相同底层数组。

我们也可以 reslice,也就是说将 slice 切片后将结果存回原始 slice 结构。

slice = slice[5:10]

此时 slice 变量的 sliceHeader 结构体跟前端的 slice2 类似。reslice 经常用到,例如截短 slice。下面语句去掉 slice 的第一个和最后一个元素:

slice = slice[1:len(slice)-1]

[练习:写出上面例子中对应的 sliceHeader 结构的外观。]

经常会听到有经验的 Go 程序员谈论“slice header”,因为这确实是存储在 slice 变量中的内容。例如,调用参数是 slice 的函数时,比如 bytes.IndexRune,这个 header 就是传递给函数的东西。在这次调用中,

slashPos := bytes.IndexRune(slice, '/')

传递给 IndexRune 函数的 slice 变量实际上是一个“slice header”。

slice header 中还有一个数据项,我们会在下面讨论,但首先让我们看看当使用 slice 进行编程时,slice header 的存在是什么意思。

4. 将 slice 传递给函数

虽然 slice 包含一个指针,但它本身是一个值,必须理解这一点。在表面下,它是一个保存指针和长度的结构体。它不是一个指向结构体的指针。

这很重要。

当我们在前面的例子中调用 IndexRune 时,它传递了 slice header 的副本。这种行为有重要的影响。

考虑这个简单的功能:

func AddOneToEachElement(slice []byte) {
    for i := range slice {
        slice[i]++
    }
}

函数名字的意思很明显,遍历 slice 的索引(使用 range 循环),递增其元素。

尝试这个函数:

func main() {
    slice := buffer[10:20]
    for i := 0; i < len(slice); i++ {
        slice[i] = byte(i)
    }
    fmt.Println("before", slice)
    AddOneToEachElement(slice)
    fmt.Println("after", slice)
}

尽管 slice header 是按值传递的,但 header 包含一个指向数组元素的指针,所以传递给该函数的原始 slice header 和 header 的副本都会描述相同的数组。因此,函数返回时,可以通过原始切片变量看到修改过的元素。

该函数的参数确实是一个副本,如下例所示:

func SubtractOneFromLength(slice []byte) []byte {
    slice = slice[0 : len(slice)-1]
    return slice
}

func main() {
    fmt.Println("Before: len(slice) =", len(slice))
    newSlice := SubtractOneFromLength(slice)
    fmt.Println("After:  len(slice) =", len(slice))
    fmt.Println("After:  len(newSlice) =", len(newSlice))
}

这里我们看到 slice 参数的内容可以被函数修改,但是它的 header 不能。存储在 slice 变量中的长度不会因调用函数而被修改,因为函数会传递 slice header 的副本,而不是原始值。因此,如果我们想编写一个修改 header 的函数,我们必须将其作为结果参数返回,就像我们在这里完成的一样。slice 变量不变,但返回值具有新的长度,然后将其存储在 newSlice 中。

5. 指向 slice 的指针:方法接收器

另一种让函数可以修改 slice header 的方法是将指针传递给函数。这是我们之前例子的一个变体:

func PtrSubtractOneFromLength(slicePtr *[]byte) {
    slice := *slicePtr
    *slicePtr = slice[0 : len(slice)-1]
}

func main() {
    fmt.Println("Before: len(slice) =", len(slice))
    PtrSubtractOneFromLength(&slice)
    fmt.Println("After:  len(slice) =", len(slice))
}

这个例子看起来很笨拙,特别是处理额外的间接级别(需要临时变量),但是可以看到指向 slice 的指针。使用接收指针的方法来修改 slice 很常见。

假设我们希望在 slice 上有一个方法,在最后一个斜杠处截断它。可以这样写:

type path []byte

func (p *path) TruncateAtFinalSlash() {
    i := bytes.LastIndex(*p, []byte("/"))
    if i >= 0 {
        *p = (*p)[0:i]
    }
}

func main() {
    pathName := path("/usr/bin/tso") // Conversion from string to path.
    pathName.TruncateAtFinalSlash()
    fmt.Printf("%s\n", pathName)
}

如果运行这个例子,你会发现它可以正常工作,更新了调用者中的 slice。

[练习:将接收器的类型改为一个值而不是指针并再次运行。解释发生了什么。]

另一方面,如果我们想写一个方法将路径中的 ASCII 字母变为大写(忽略非英文),该方法可能是一个值,因为值接收器仍将指向相同的基础数组(the method could be a value because the value receiver will still point to the same underlying array)。

type path []byte

func (p path) ToUpper() {
    for i, b := range p {
        if 'a' <= b && b <= 'z' {
            p[i] = b + 'A' - 'a'
        }
    }
}

func main() {
    pathName := path("/usr/bin/tso")
    pathName.ToUpper()
    fmt.Printf("%s\n", pathName)
}

这里 ToUpper 方法在 for range 结构中使用两个变量来获取 slice 的索引和元素。这种形式的循环避免了多次使用 p[i]

[练习:将 ToUpper 方法转换为使用指针接收器并查看其行为是否改变。]

[高级练习:转换 ToUpper 方法来处理 Unicode 字符,而不仅仅是 ASCII。]

6. Capacity

看看下面的函数,它通过一个元素来扩展它的 int 类型的 slice 参数:

func Extend(slice []int, element int) []int {
    n := len(slice)
    slice = slice[0 : n+1]
    slice[n] = element
    return slice
}

(为什么需要返回修改过的 slice?)现在运行它:

func main() {
    var iBuffer [10]int
    slice := iBuffer[0:0]
    for i := 0; i < 20; i++ {
        slice = Extend(slice, i)
        fmt.Println(slice)
    }
}

看看 slice 如何增长。。。并没有。

是时候谈谈 slice header 的第三个组成部分:capacity。除了数组指针和长度外,slice header 还存储其 capacity:

type sliceHeader struct {
    Length        int
    Capacity      int
    ZerothElement *byte
}

Capacity 字段记录底层数组实际具有的空间大小;它是 Length 可以达到的最大值。试图超越其 capacity 来增大 slice 将超出数组的限制并引发恐慌(trigger a panic)。

下面命令创建示例 slice:

slice := iBuffer[0:0]

header 类似下面这样:

slice := sliceHeader{
    Length:        0,
    Capacity:      10,
    ZerothElement: &iBuffer[0],
}

Capacity 字段等于底层数组的长度,减去 slice 的第一个元素(本例中为零)在数组中的索引。如果想查询一个 slice 的容量,请使用内置的 cap 函数:

if cap(slice) == len(slice) {
    fmt.Println("slice is full!")
}

7. Make

如果我们想要超越 slice 的容量该怎么办? 做不到! 根据定义,容量 capacity 是 slice 的最大限制。但是,可以通过分配新数组、复制数据过来并修改 slice 来描述新数组来获得相同的结果。

我们从分配开始。可以使用新的内置函数来分配更大的数组,然后对结果进行切片,但是用 make 这个内置函数更简单。它一步就可以实现分配新数组并创建 slice header 来描述这个新数组。make 函数有三个参数:slice 的类型,初始长度和容量(capacity,分配保存 slice 数据的数组的长度)。这个调用创建了一段长度为 10 的空间,另外有 5 个空的空间(15-10),正如运行时看到的那样:

    slice := make([]int, 10, 15)
    fmt.Printf("len: %d, cap: %d\n", len(slice), cap(slice))

这段代码将 int slice 的容量加倍,但保持其长度相同:

    slice := make([]int, 10, 15)
    fmt.Printf("len: %d, cap: %d\n", len(slice), cap(slice))
    newSlice := make([]int, len(slice), 2*cap(slice))
    for i := range slice {
        newSlice[i] = slice[i]
    }
    slice = newSlice
    fmt.Printf("len: %d, cap: %d\n", len(slice), cap(slice))

在运行此代码之后,slice 在需要再次重新分配之前有更多的可用空间。

创建 slice 时,长度和容量通常是相同的。内置的 make 有一个这种常见情况的简写。长度参数默认为容量,因此可以将它们都设置为相同的值。

gophers := make([]Gopher, 10)

gophers 这个 slice 的 length 和 capacity 都被设置为 10。

8. Copy

当我们在前一节中将 slice 的容量加倍时,我们编写了一个循环来将旧数据复制到新 slice 中。Go 有一个内置的 copy 函数可以简化这个操作。copy 的参数是两个 slice,将数据从右侧参数复制到左侧参数。示例:

    newSlice := make([]int, len(slice), 2*cap(slice))
    copy(newSlice, slice)

copy 函数非常智能。它只会复制它所能复制的,并且会留意两个参数的长度。换句话说,它复制的元素的数量是两个 slice 长度的最小值。这可以节省一些簿记(bookkeeping)。此外,copy 会返回一个整数值,即它复制的元素的数量,虽然一般用不到。

当源和目标重叠时,copy 函数也可以正常使用,这意味着它可以用于在单个 slice 中移动元素。以下是如何使用副本将值插入 slice 的中间。

// Insert inserts the value into the slice at the specified index,
// which must be in range.
// The slice must have room for the new element.
func Insert(slice []int, index, value int) []int {
    // Grow the slice by one element.
    slice = slice[0 : len(slice)+1]
    // Use copy to move the upper part of the slice out of the way and open a hole.
    copy(slice[index+1:], slice[index:])
    // Store the new value.
    slice[index] = value
    // Return the result.
    return slice
}

这个函数中需要注意几件事。首先,它必须返回更新的片段,因为它的长度已经改变。其次,可以使用简写。表达式:

slice[i:]

意思和下面的表达式一样:

slice[i:len(slice)]

另外,虽然我们还没有使用这个技巧,但我们也可以忽略 slice 表达式的第一个元素,它默认为零。因此:

slice[:]

只是意味着 slice 本身,这在切片数组时非常有用。这个表达式是描述“表示数组中所有元素的 slice”的最简方式:

array[:]

现在,运行 Insert 函数。

    slice := make([]int, 10, 20) // Note capacity > length: room to add element.
    for i := range slice {
        slice[i] = i
    }
    fmt.Println(slice)
    slice = Insert(slice, 5, 99)
    fmt.Println(slice)

9. 附加示例

回过头来,我们编写了一个 Extend 函数,可以通过元素扩展 slice。不过,这很麻烦,因为如果 slice 的容量太小,该函数就会崩溃。(上面的 insert 函数示例有同样的问题。)现在我们已经有了解决这个问题的部分,所以让我们为整型 slice 写一个健壮的 Extend 实现。

func Extend(slice []int, element int) []int {
    n := len(slice)
    if n == cap(slice) {
        // Slice is full; must grow.
        // We double its size and add 1, so if the size is zero we still grow.
        newSlice := make([]int, len(slice), 2*len(slice)+1)
        copy(newSlice, slice)
        slice = newSlice
    }
    slice = slice[0 : n+1]
    slice[n] = element
    return slice
}

在这种情况下,返回 slice 是特别重要的,因为当它重新分配结果 slice 时会描述一个完全不同的数组。下面的代码片段演示 slice 填满时会发生的情况:

    slice := make([]int, 0, 5)
    for i := 0; i < 10; i++ {
        slice = Extend(slice, i)
        fmt.Printf("len=%d cap=%d slice=%v\n", len(slice), cap(slice), slice)
        fmt.Println("address of 0th element:", &slice[0])
    }

当初始大小为 5 的数组填满时,请注意重新分配。当分配新数组时,第零个元素的 capacity 和地址都会更改。

以强大的 Extend 功能为指导,我们可以编写更好的函数,让我们可以通过多个元素扩展 slice。为此,我们使用 Go 的功能在调用函数时将函数参数列表转换为 slice。也就是使用 Go 的可变参数的函数。

调用函数 Append。对于第一个版本,我们可以反复调用 Extend,以便可变函数的机制清晰。Append 的定义如下:

func Append(slice []int, items ...int) []int

说的是,Append 接受一个 slice 参数 ,接着是零个或多个 int 参数。正如你所看到的那样,这些参数恰恰是关于 Append 的实现的一部分。

// Append appends the items to the slice.
// First version: just loop calling Extend.
func Append(slice []int, items ...int) []int {
    for _, item := range items {
        slice = Extend(slice, item)
    }
    return slice
}

注意 for range 循环遍历 items 参数的元素,它暗含了 [] int 类型。另请注意,使用空白标识符 _ 忽略循环中的索引,在这种情况下不需要该索引。

试试这个:

    slice := []int{0, 1, 2, 3, 4}
    fmt.Println(slice)
    slice = Append(slice, 5, 6, 7, 8)
    fmt.Println(slice)

在这个例子中,在声明的同时初始化 slice,由花括号中的一系列符合 slice 类型的元素来初始化:

    slice := []int{0, 1, 2, 3, 4}

Append函数还有一个有意思的特点。我们不仅可以追加元素,还可以通过在调用时使用 ... 符号将 slice “爆炸”为参数来附加整个第二个 slice:

    slice1 := []int{0, 1, 2, 3, 4}
    slice2 := []int{55, 66, 77}
    fmt.Println(slice1)
    slice1 = Append(slice1, slice2...) // The '...' is essential!
    fmt.Println(slice1)

当然,我们可以通过分配不超过一次的方式使 Append 效率更高,建立在 Extend 内部(we can make Append more efficient by allocating no more than once, building on the innards of Extend):

// Append appends the elements to the slice.
// Efficient version.
func Append(slice []int, elements ...int) []int {
    n := len(slice)
    total := len(slice) + len(elements)
    if total > cap(slice) {
        // Reallocate. Grow to 1.5 times the new size, so we can still grow.
        newSize := total*3/2 + 1
        newSlice := make([]int, total, newSize)
        copy(newSlice, slice)
        slice = newSlice
    }
    slice = slice[:total]
    copy(slice[n:], elements)
    return slice
}

在这里,请注意我们如何两次使用副本,一次将 slice 数据移动到新分配的内存,然后将附加项目复制到旧数据的末尾。

尝试一下; 行为与之前一样:

    slice1 := []int{0, 1, 2, 3, 4}
    slice2 := []int{55, 66, 77}
    fmt.Println(slice1)
    slice1 = Append(slice1, slice2...) // The '...' is essential!
    fmt.Println(slice1)

10. 内建函数 Append

所以我们有了追加内置函数设计的动机。这正是 Append 示例所做的,具有相同的效率,但它适用于任何 slice 类型。

Go 的一个弱点是任何通用类型的操作都必须由运行时提供。以后可能会改变,但现在,为了使 slice 更容易工作,Go 提供了内置的通用 Append 函数。它适用于任何 slice 类型,并且表现和 int 类型的 slice 一样。

请记住,由于 slice header 始终通过 append 调用进行更新,因此需要在调用后保存返回的 slice。事实上,编译器不会让你在不保存结果的情况下调用 append

以下是一些与打印报告混杂在一起的测试。尝试一下:

    // Create a couple of starter slices.
    slice := []int{1, 2, 3}
    slice2 := []int{55, 66, 77}
    fmt.Println("Start slice: ", slice)
    fmt.Println("Start slice2:", slice2)

    // Add an item to a slice.
    slice = append(slice, 4)
    fmt.Println("Add one item:", slice)

    // Add one slice to another.
    slice = append(slice, slice2...)
    fmt.Println("Add one slice:", slice)

    // Make a copy of a slice (of int).
    slice3 := append([]int(nil), slice...)
    fmt.Println("Copy a slice:", slice3)

    // Copy a slice to the end of itself.
    fmt.Println("Before append to self:", slice)
    slice = append(slice, slice...)
    fmt.Println("After append to self:", slice)

值得花点时间详细思考该示例的最后一行,以了解 slice 的设计如何使这个简单的调用能够正常工作。

在社区构建的 “切片技巧”Wiki页面 上,还有更多 appendcopy 和其他方式使用切片的示例。

11. Nil

顺便说一下,用我们新发现的知识,我们可以看到一个 nil slice 表示什么。当然,它是 slice header 的零值(it is the zero value of the slice header):

sliceHeader{
    Length:        0,
    Capacity:      0,
    ZerothElement: nil,
}

或者仅仅是:

sliceHeader{}

关键的细节是元素指针也是零。下面创建的切片:

array[0:0]

长度是 0,可能 capacity 也是 0,但是指针不是 nil。因此这不是 nil slice。

应该清楚,空 slice 可以增长(假设它具有非零容量),但是 nil slice 没有数组来放入值,并且永远不会增长以容纳一个元素。

也就是说,一个 nil slice 在功能上等价于一个长度为零的 slice,即使它没有指向任何东西。它的长度为零,可以被其他 slice 附加到后面。作为一个例子,看一下上面的一行代码,通过附加到一个 nil slice 来复制一个 slice。

12. Strings

现在简要介绍一下 slice 环境中的 Go 字符串。

字符串实际上非常简单:它们只是字节的只读 slice,并且有一点额外的语言上的语法支持。

因为它们是只读的,所以不需要 capacity(你不能增长它们),但是除此之外,对于大多数用途,可以像只读的字节 slice 一样对待。

对于初学者,我们可以将它们编入索引以访问各个字节:

slash := "/usr/ken"[0] // yields the byte value '/'.

可以切分一个字符串来获取一个子字符串:

usr := "/usr/ken"[0:4] // yields the string "/usr"

现在应该很明显的是,当我们分割一个字符串时,幕后发生了什么。

也可以将字节 slice 转换为字符串:

str := string(slice)

相反,字符串转字节 slice:

slice := []byte(usr)

隐藏在字符串下的数组是不可见的,除非通过字符串,否则无法访问其内容。这意味着当我们进行这些转换时,必须创建一个数组副本。Go 当然会处理这个问题,所以你不必这样做。在这些转换之后,修改字节 slice 下的数组不会影响相应的字符串。

字符串设计成跟 slice 类似的重要结果是创建子字符串非常有效。所有需要做的是创建一个双字串标头(two-word string header)。由于字符串是只读的,原始字符串和切片操作产生的字符串可以安全地共享相同的数组。

历史遗留问题:在早期实现中,字符串始终分配,但是当添加了 slice 特性时,提供了一个高效的字符串处理模型。其中一些基准测试结果显示巨大的加速。

当然,关于字符串还有更多信息,另外一篇 博客文章 将更深入地介绍它们。

13. 总结

一定要搞清楚 slice 是如何工作的,这有助于理解 slice 的实现方式。有一个小的数据结构,slice header,即与 slice 变量关联的条目,并且该 header 描述了独立分配的数组的一部分。当我们传递 slice 值时,header 被复制,但它指向的数组始终是共享的。

一旦掌握 slice 的工作方式,你会发现 slice 不仅易于使用,而且功能强大且具有表现力,特别是在 copyappend 内置功能的帮助下。

有更多的资料可以参考,但了解 slice 的最佳方法是练习。

你可能感兴趣的:(Go/Golang)