Go 博客
数组、切片(和字符串):append
的机制
引言
过程式编程语言最常见的特性之一是数组的概念。数组看起来很简单,但在将它们添加到语言中时,需要回答许多问题,例如:
- 固定大小还是可变大小?
- 大小是否是类型的一部分?
- 多维数组是什么样的?
- 空数组是否有意义?
这些问题的答案会影响数组是仅仅是语言的一个特性,还是其设计的核心部分。
在 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]
之后,变量 slice
的 sliceHeader
结构看起来就像 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
循环),并增加其元素的值。
试试看:
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) }
(如果您想进一步探索,可以编辑和重新执行这些可运行的代码片段。)
即使切片 头 是按值传递的,但头中包含一个指向数组元素的指针,因此原始切片头和传递给函数的头的副本都描述的是同一个数组。因此,当函数返回时,可以通过原始切片变量看到被修改的元素。
传递给函数的参数确实是副本,如下例所示:
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
中。
指向切片的指针:方法接收者
让函数修改切片头部的另一种方法是传递一个指向它的指针。这是我们上一个示例的变体,它实现了这一点:
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)) }
在那个例子中看起来有点笨拙,特别是处理额外的间接层(使用临时变量会有所帮助),但在一种常见情况下你会看到指向切片的指针。对于修改切片的方法,习惯上使用指针接收者。
假设我们想在切片上创建一个方法,使其在最后一个斜杠处截断。我们可以这样写:
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 字母转为大写(局部地忽略非英文字符),该方法可以使用值接收者,因为值接收者仍然会指向同一个底层数组。
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 }
(为什么它需要返回修改后的切片?)现在运行它:
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)的切片,运行它你就可以看到:
slice := make([]int, 10, 15) fmt.Printf("len: %d, cap: %d\n", len(slice), cap(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))
运行这段代码后,切片在需要再次重新分配之前有更大的增长空间。
创建切片时,长度和容量通常是相同的。内置函数 make
为这种常见情况提供了一个简写。长度参数默认为容量,因此可以省略它来将两者设置为相同的值。在执行以下代码后:
gophers := make([]Gopher, 10)
gophers
切片的长度和容量都设置为 10。
复制
在上一节中将切片容量翻倍时,我们编写了一个循环将旧数据复制到新切片中。Go 提供了一个内置函数 copy
来简化这个操作。它接受两个切片作为参数,并将数据从右边的参数复制到左边的参数。下面是将我们的示例改写为使用 copy
的代码:
newSlice := make([]int, len(slice), 2*cap(slice)) copy(newSlice, 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
函数吧。
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 }
在这种情况下,返回切片尤其重要,因为当它重新分配时,结果切片描述的是一个完全不同的数组。这是一个小代码片段,演示了切片填满时发生的情况:
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
。还要注意使用空白标识符 _
来舍弃循环中的索引,在本例中我们不需要它。
试试看:
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
函数之所以有趣,还有另一个原因。我们不仅可以追加元素,还可以通过在调用时使用 ...
符号将整个第二个切片“展开”成参数来追加它:
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
:一次是将切片数据移动到新分配的内存中,另一次是将要追加的项复制到旧数据的末尾。
试试看;行为和之前一样:
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。
这里有一些与打印语句穿插在一起的单行代码。尝试它们、编辑它们并探索:
// 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 页面上有更多关于 append
、copy
和其他使用切片方法的示例。
零值(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)的字符串头部。由于字符串是只读的,原始字符串和切片操作产生的字符串可以安全地共享同一个数组。
历史注记:最早的字符串实现总是进行内存分配,但当切片被添加到语言中时,它们提供了一种高效的字符串处理模型。因此,一些基准测试显示了巨大的速度提升。
当然,关于字符串还有很多内容,另一篇博客文章更深入地介绍了它们。
结论
要理解切片如何工作,有助于理解它们的实现方式。有一个小的数据结构,即切片头,它与切片变量相关联,并且这个头描述了一个单独分配的数组中的一个部分。当我们传递切片值时,切片头会被复制,但它指向的数组始终是共享的。
一旦你理解了它们的工作原理,切片不仅变得易于使用,而且功能强大且富有表现力,尤其是在内置函数 copy
和 append
的帮助下。
更多阅读
在互联网上有很多关于 Go 切片的资料。正如前面提到的,“切片技巧”(Slice Tricks)Wiki 页面包含许多示例。Go 切片这篇博客文章通过清晰的图表描述了内存布局细节。Russ Cox 的 Go 数据结构文章讨论了切片以及 Go 的其他一些内部数据结构。
还有很多其他资料可用,但学习切片的最好方法是使用它们。
下一篇文章:Go 中的字符串、字节、rune 和字符
上一篇文章:第一个 Go 程序
博客索引