Go 博客

数组、切片(和字符串):append 的机制

Rob Pike
2013 年 9 月 26 日

引言

过程式编程语言最常见的特性之一是数组的概念。数组看起来很简单,但在将它们添加到语言中时,需要回答许多问题,例如:

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

这些问题的答案会影响数组是仅仅是语言的一个特性,还是其设计的核心部分。

在 Go 的早期开发中,花费了大约一年的时间来决定这些问题的答案,直到设计感觉对了。关键一步是引入了 切片(slices),它在固定大小的 数组(arrays) 之上构建,提供了一种灵活、可扩展的数据结构。然而,直到今天,Go 的新手程序员仍然经常在切片的工作方式上遇到困难,也许是因为其他语言的经验影响了他们的思维方式。

在这篇文章中,我们将尝试消除这种困惑。我们将通过逐步构建各个部分来解释内置函数 append 的工作原理以及它为何如此工作。

数组

数组是 Go 中的一个重要构建块,但就像建筑物的地基一样,它们通常隐藏在更显眼的组件之下。在我们转向更具趣味、强大且重要的切片概念之前,必须简要讨论它们。

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

声明

var buffer [256]byte

声明了变量 buffer,它存储 256 个字节。buffer 的类型包含其大小,即 [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 的内置函数,它返回数组、切片以及其他一些数据类型的元素数量。对于数组,len 返回什么显而易见。在我们的示例中,len(buffer) 返回固定值 256。

数组有它们的用武之地——例如,它们是变换矩阵的良好表示——但在 Go 中,它们最常见的用途是为切片提供存储空间。

切片:切片头

切片是关键所在,但要用好它们,必须确切理解它们是什么以及它们做什么。

切片是一种数据结构,它描述了存储在数组中的连续部分,并与切片变量本身分开。切片不是数组。切片 描述 数组的一部分。

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

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

在那个代码片段中,我们使用了完整的变量声明以明确表示。变量 slice 的类型是 []byte,读作“字节切片”,它是通过对名为 buffer 的数组进行切片(包含元素 100,不包含 150)来初始化的。更符合习惯用法的语法会省略类型,因为它由初始化表达式设置:

var slice = buffer[100:150]

在函数内部,我们可以使用短声明形式:

slice := buffer[100:150]

这个切片变量到底是什么?这并不是全部细节,但现在可以把切片看作一个包含两个元素的小数据结构:长度和指向数组中某个元素的指针。你可以想象它在底层是这样构建的:

type sliceHeader struct {
    Length        int
    ZerothElement *byte
}

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

当然,这只是一个示意。尽管这段代码片段如此说明,但 sliceHeader 结构对程序员是不可见的,并且元素指针的类型取决于元素的类型,但这给出了其工作机制的大致概念。

到目前为止,我们已经在数组上使用了切片操作,但我们也可以对切片进行切片,就像这样:

slice2 := slice[5:10]

和之前一样,这个操作创建了一个新的切片,在本例中包含原切片的元素 5 到 9(包含),这意味着包含原数组的元素 105 到 109。变量 slice2 对应的底层 sliceHeader 结构如下所示:

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

注意,这个头部仍然指向存储在 buffer 变量中的同一个底层数组。

我们还可以进行 重新切片(reslice),也就是说,对一个切片进行切片并将结果存储回原始切片结构中。在执行

slice = slice[5:10]

之后,变量 slicesliceHeader 结构看起来就像 slice2 变量的结构一样。你会经常看到重新切片的应用,例如用来截断一个切片。这条语句去掉了我们切片的第一个和最后一个元素:

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

[练习:写出在这个赋值之后,sliceHeader 结构是什么样的。]

你会经常听到经验丰富的 Go 程序员谈论“切片头”,因为那确实是存储在切片变量中的内容。例如,当你调用一个接收切片作为参数的函数时,比如 bytes.IndexRune,传递给函数的正是那个切片头。在这个调用中,

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

传递给 IndexRune 函数的 slice 参数,实际上是一个“切片头”。

切片头中还有一个数据项,我们将在下面讨论,但首先让我们看看切片头的存在在用切片编程时意味着什么。

将切片传递给函数

重要的是要理解,即使切片包含一个指针,它本身也是一个值。在底层,它是一个结构体值,包含了指针和长度。它 不是 一个指向结构体的指针。

这很重要。

当我们在上一个例子中调用 IndexRune 时,传递给它的是切片头的一个 副本。这种行为带来了重要的影响。

考虑这个简单的函数:

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

它的作用正如其名称所示,迭代遍历切片的索引(使用 for range 循环),并增加其元素的值。

试试看:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

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

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


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

(如果您想进一步探索,可以编辑和重新执行这些可运行的代码片段。)

即使切片 是按值传递的,但头中包含一个指向数组元素的指针,因此原始切片头和传递给函数的头的副本都描述的是同一个数组。因此,当函数返回时,可以通过原始切片变量看到被修改的元素。

传递给函数的参数确实是副本,如下例所示:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

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


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 变量中的长度不会因为函数调用而改变,因为函数接收到的是切片头部的副本,而不是原始头部。因此,如果我们想编写一个修改切片头部的函数,必须将其作为结果参数返回,就像我们在这里所做的那样。slice 变量本身没有改变,但返回的值具有新的长度,然后将其存储到 newSlice 中。

指向切片的指针:方法接收者

让函数修改切片头部的另一种方法是传递一个指向它的指针。这是我们上一个示例的变体,它实现了这一点:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

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


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

在那个例子中看起来有点笨拙,特别是处理额外的间接层(使用临时变量会有所帮助),但在一种常见情况下你会看到指向切片的指针。对于修改切片的方法,习惯上使用指针接收者。

假设我们想在切片上创建一个方法,使其在最后一个斜杠处截断。我们可以这样写:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "bytes"
    "fmt"
)


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

如果您运行这个示例,会看到它工作正常,更新了调用者中的切片。

[练习:将接收者的类型从指针改为值,然后再次运行。解释发生了什么。]

另一方面,如果我们想为 path 编写一个方法,将其中的 ASCII 字母转为大写(局部地忽略非英文字符),该方法可以使用值接收者,因为值接收者仍然会指向同一个底层数组。


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)


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 结构中使用了两个变量来捕获索引和切片元素。这种循环形式避免了在循环体中多次书写 p[i]

[练习:将 ToUpper 方法转换为使用指针接收者,看看其行为是否发生变化。]

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

容量

看看下面这个将整型切片参数扩展一个元素的函数:

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

(为什么它需要返回修改后的切片?)现在运行它:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

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


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

看看切片是如何增长的,直到……它不再增长。

是时候讨论切片头的第三个组成部分了:它的 容量(capacity)。除了数组指针和长度之外,切片头还存储其容量:

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

Capacity 字段记录了底层数组实际拥有的空间大小;它是 Length 可以达到的最大值。试图将切片扩展到超出其容量,将会超出数组的界限并触发 panic。

在通过以下方式创建我们的示例切片后:

slice := iBuffer[0:0]

它的头部看起来像这样:

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

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

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

make

如果我们想将切片扩展到超出其容量怎么办?你做不到!根据定义,容量是增长的极限。但你可以通过分配一个新数组、复制数据,并修改切片来描述这个新数组来实现等效结果。

让我们从分配开始。我们可以使用内置函数 new 来分配一个更大的数组,然后对结果进行切片,但更简单的方法是使用内置函数 make。它会一次性分配一个新数组并创建一个切片头来描述它。make 函数接受三个参数:切片的类型、其初始长度和容量,容量即 make 为存储切片数据而分配的数组的长度。这个调用创建了一个长度为 10、还有空间容纳 5 个额外元素(15-10)的切片,运行它你就可以看到:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

func main() {

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

这段代码将我们的整型切片容量翻倍,但长度保持不变:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

func main() {

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

运行这段代码后,切片在需要再次重新分配之前有更大的增长空间。

创建切片时,长度和容量通常是相同的。内置函数 make 为这种常见情况提供了一个简写。长度参数默认为容量,因此可以省略它来将两者设置为相同的值。在执行以下代码后:

gophers := make([]Gopher, 10)

gophers 切片的长度和容量都设置为 10。

复制

在上一节中将切片容量翻倍时,我们编写了一个循环将旧数据复制到新切片中。Go 提供了一个内置函数 copy 来简化这个操作。它接受两个切片作为参数,并将数据从右边的参数复制到左边的参数。下面是将我们的示例改写为使用 copy 的代码:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

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

    newSlice := make([]int, len(slice), 2*cap(slice))
    copy(newSlice, slice)
   slice = newSlice
    fmt.Printf("len: %d, cap: %d\n", len(slice), cap(slice))
}

copy 函数很智能。它只会复制它能复制的部分,同时考虑两个参数的长度。换句话说,它复制的元素数量是两个切片长度的最小值。这可以省去一些簿记工作。此外,copy 返回一个整数值,表示它复制的元素数量,尽管并不总是需要检查这个返回值。

当源切片和目标切片重叠时,copy 函数也能正确处理,这意味着它可以用来在同一个切片中移动元素。下面是如何使用 copy 将一个值插入到切片的中间:

// 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[:]

仅仅表示切片本身,这在对数组进行切片时很有用。这个表达式是表示“一个描述数组所有元素的切片”的最短方式:

array[:]

既然障碍已清除,让我们运行 Insert 函数吧。


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

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

func main() {

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

append:一个例子

前面几节中,我们编写了一个 Extend 函数,它将切片扩展一个元素。然而,它有 bug,因为如果切片的容量太小,函数就会崩溃。(我们的 Insert 示例也有同样的问题。)现在我们已经具备了解决这个问题的条件,所以让我们编写一个健壮的整型切片 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
}

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


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

// Extend extends the slice by adding the element to the end.
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
}

func main() {

    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 的初始数组被填满时发生的重新分配。新数组分配时,容量和零号元素的地址都会改变。

以健壮的 Extend 函数为指导,我们可以编写一个更好的函数,允许我们通过添加多个元素来扩展切片。为此,我们利用 Go 在函数调用时将函数参数列表转换为切片的能力。也就是说,我们使用 Go 的可变参数函数特性。

让我们将函数命名为 Append。对于第一个版本,我们可以简单地重复调用 Extend,这样可变参数函数的机制就很清楚了。Append 的函数签名如下:

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

这意味着 Append 接受一个参数,即一个切片,后面跟着零个或多个 int 参数。就 Append 的实现而言,这些参数就是一个 int 切片,正如你所看到的:

// 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。还要注意使用空白标识符 _ 来舍弃循环中的索引,在本例中我们不需要它。

试试看:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

// Extend extends the slice by adding the element to the end.
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
}

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

func main() {

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

这个例子中的另一个新技术是,我们通过编写复合字面量来初始化切片,复合字面量由切片类型后跟大括号中的元素组成:

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

Append 函数之所以有趣,还有另一个原因。我们不仅可以追加元素,还可以通过在调用时使用 ... 符号将整个第二个切片“展开”成参数来追加它:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

// Extend extends the slice by adding the element to the end.
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
}

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

func main() {

    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 函数的内部实现:

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

在这里,注意我们如何使用了两次 copy:一次是将切片数据移动到新分配的内存中,另一次是将要追加的项复制到旧数据的末尾。

试试看;行为和之前一样:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

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

func main() {

    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:内置函数

至此,我们明白了内置函数 append 的设计动机。它所做的事情与我们的 Append 示例完全相同,效率也相当,但它可以用于任何切片类型。

Go 的一个不足之处在于,任何泛型类型的操作都必须由运行时提供。将来这可能会改变,但目前,为了使切片操作更简便,Go 提供了一个内置的泛型 append 函数。它的工作方式与我们的 int 切片版本相同,但适用于 任何 切片类型。

请记住,由于切片头总是通过调用 append 来更新,因此在调用后需要保存返回的切片。实际上,如果您不保存结果,编译器甚至不会允许您调用 append。

这里有一些与打印语句穿插在一起的单行代码。尝试它们、编辑它们并探索:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

func main() {

    // 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 Tricks)Wiki 页面上有更多关于 appendcopy 和其他使用切片方法的示例。

零值(Nil)

顺便说一句,凭借我们新获得的知识,我们可以看看 nil 切片的表示形式。自然地,它是切片头的零值:

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

或者简单地说:

sliceHeader{}

关键细节在于元素指针也是 nil。通过以下方式创建的切片:

array[0:0]

其长度为零(甚至容量也可能为零),但它的指针不是 nil,因此它不是一个 nil 切片。

应该清楚的是,空切片可以增长(假设其容量非零),但 nil 切片没有用于存放值的数组,因此永远无法增长到容纳哪怕一个元素。

话虽如此,nil 切片在功能上等同于零长度切片,尽管它不指向任何东西。它的长度为零,并且可以通过分配内存进行追加操作。例如,看看上面通过向 nil 切片追加来复制切片的那行代码。

字符串

现在简要讨论一下 Go 中的字符串,结合切片的概念。

字符串实际上非常简单:它们只是字节的只读切片,并获得了一些额外的语言语法支持。

因为它们是只读的,所以不需要容量(你无法扩展它们),但在其他方面,在大多数用途上,你可以将它们视为只读的字节切片。

首先,我们可以通过索引访问单个字节:

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

我们可以对字符串进行切片以获取子字符串:

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

现在应该很清楚,当我们对字符串进行切片时,幕后发生了什么。

我们也可以拿一个普通的字节切片,并通过简单的转换从中创建一个字符串:

str := string(slice)

也可以反过来:

slice := []byte(usr)

字符串底层的数组是隐藏的;除了通过字符串本身,没有其他方式可以访问其内容。这意味着当我们进行这两种转换中的任何一种时,都必须复制数组。当然,Go 会处理好这一点,所以您不必担心。在这些转换中的任何一种之后,对字节切片底层数组的修改不会影响相应的字符串。

这种类切片设计的字符串的一个重要结果是,创建子字符串的效率非常高。只需要创建一个双字(two-word)的字符串头部。由于字符串是只读的,原始字符串和切片操作产生的字符串可以安全地共享同一个数组。

历史注记:最早的字符串实现总是进行内存分配,但当切片被添加到语言中时,它们提供了一种高效的字符串处理模型。因此,一些基准测试显示了巨大的速度提升。

当然,关于字符串还有很多内容,另一篇博客文章更深入地介绍了它们。

结论

要理解切片如何工作,有助于理解它们的实现方式。有一个小的数据结构,即切片头,它与切片变量相关联,并且这个头描述了一个单独分配的数组中的一个部分。当我们传递切片值时,切片头会被复制,但它指向的数组始终是共享的。

一旦你理解了它们的工作原理,切片不仅变得易于使用,而且功能强大且富有表现力,尤其是在内置函数 copyappend 的帮助下。

更多阅读

在互联网上有很多关于 Go 切片的资料。正如前面提到的,“切片技巧”(Slice Tricks)Wiki 页面包含许多示例。Go 切片这篇博客文章通过清晰的图表描述了内存布局细节。Russ Cox 的 Go 数据结构文章讨论了切片以及 Go 的其他一些内部数据结构。

还有很多其他资料可用,但学习切片的最好方法是使用它们。

下一篇文章:Go 中的字符串、字节、rune 和字符
上一篇文章:第一个 Go 程序
博客索引