Go 博客
数组、切片(和字符串):'append' 的机制
介绍
过程式编程语言中最常见的特性之一是数组的概念。数组看似简单,但在将数组添加到语言中时,必须回答许多问题,例如
- 固定大小还是可变大小?
- 大小是类型的一部分吗?
- 多维数组是什么样的?
- 空数组有意义吗?
对这些问题的答案会影响数组仅仅是语言的特性还是其设计中的核心部分。
在 Go 的早期开发中,花了大约一年的时间才确定对这些问题的答案,直到设计感觉正确为止。关键步骤是引入了切片,它基于固定大小的数组来提供灵活、可扩展的数据结构。然而,时至今日, Go 的新手程序员经常会对切片的工作方式感到困惑,这可能是因为来自其他语言的经验影响了他们的思维方式。
在这篇文章中,我们将尝试消除这种困惑。我们将通过构建这些片段来解释内置函数 append
的工作原理,以及它为什么以这种方式工作。
数组
数组是 Go 中重要的构建块,但就像建筑的地基一样,它们通常隐藏在更可见的组件之下。在我们继续讨论切片这个更有趣、更强大、更突出的概念之前,我们必须简单地谈谈它们。
在 Go 程序中很少看到数组,因为数组的大小是其类型的一部分,这限制了它的表达能力。
声明
var buffer [256]byte
声明变量 buffer
,它包含 256 个字节。buffer
的类型包括其大小 [256]byte
。具有 512 个字节的数组将属于不同的类型 [512]byte
。
与数组关联的数据就是:一组元素。在内存中,我们的缓冲区示意如下
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
数组变量为例,我们可以创建一个切片来描述元素 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
变量中的相同底层数组。
我们也可以重新切片,也就是切片一个切片并将结果存储回原始的切片结构中。操作之后
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 字母。]
容量
看看下面的函数,该函数将它的 ints
切片参数扩展一个元素
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) } }
看看切片是如何增长的,直到……它不再增长。
现在该谈谈切片头中的第三个组件:它的容量。除了数组指针和长度之外,切片头还存储其容量
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!")
}
创建
如果我们想要将切片扩展到超出其容量的大小,会怎样?这做不到!根据定义,容量是增长的限制。但是可以通过分配一个新的数组,将数据复制到新数组,并修改切片来描述新数组,来实现等效的结果。
让我们从分配开始。我们可以使用内置函数 new
来分配一个更大的数组,然后将结果切片,但使用内置函数 make
更简单。它会分配一个新的数组并创建一个切片头来描述它,一次完成。make
函数接受三个参数:切片的类型、它的初始长度和它的容量,容量是 make
为保存切片数据而分配的数组的长度。此调用会创建一个长度为 10 的切片,有 5 个空间用于扩展 (15-10),你可以运行代码查看结果。
slice := make([]int, 10, 15) fmt.Printf("len: %d, cap: %d\n", len(slice), cap(slice))
此代码段将 int
切片的容量加倍,但保持长度不变。
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)
追加:一个示例
几节之前,我们编写了一个 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
示例完全相同,效率相当,但它适用于任何切片类型。
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)
花点时间详细考虑示例中的最后一个单行代码,以便了解切片的設計如何使这个简单的调用能够正确地工作。
在社区构建的 “切片技巧” Wiki 页面 上,还有许多关于 append
、copy
和其他切片使用方式的示例。
空
顺便说一下,有了我们新获得的知识,我们可以看到 nil
切片的表示形式。自然地,它是切片头的零值。
sliceHeader{
Length: 0,
Capacity: 0,
ZerothElement: nil,
}
或者简化为
sliceHeader{}
关键细节是元素指针也是 nil
。由
array[0:0]
创建的切片长度为零(甚至容量也可能为零),但它的指针不是 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 会处理这件事,所以你不必自己动手。在进行这两个转换中的任何一个之后,对字节切片底层数组的修改不会影响相应的字符串。
这种字符串的切片式设计的一个重要结果是,创建子字符串非常高效。只需要创建两个字的字符串头。由于字符串是只读的,因此原始字符串和切片操作产生的字符串可以安全地共享同一个数组。
历史记录:字符串的最早实现总是进行分配,但当切片被添加到语言中时,它们为高效的字符串处理提供了一个模型。因此,一些基准测试获得了巨大的速度提升。
当然,字符串还有很多内容,一篇 单独的博客文章 将更深入地介绍它们。
结论
要了解切片的工作原理,需要了解它们的实现方式。有一个小的数据结构,切片头,它与切片变量相关联,该头描述了单独分配的数组的一部分。当我们传递切片值时,头会被复制,但它所指向的数组始终是共享的。
一旦你了解了切片的工作原理,它们不仅易于使用,而且功能强大,表达能力强,尤其是在 copy
和 append
内置函数的帮助下。
更多阅读
关于 Go 中的切片,互联网上有很多资料。如前所述,“Slice Tricks” Wiki 页面 提供了许多示例。Go 切片 博客文章用清晰的图表描述了内存布局细节。Russ Cox 的 Go 数据结构 文章讨论了切片以及 Go 的其他一些内部数据结构。
还有更多资料可供参考,但学习切片的最佳方法是使用它们。
下一篇文章:Go 中的字符串、字节、符文和字符
上一篇文章:第一个 Go 程序
博客索引