Go 博客
数组、切片(和字符串):'append' 的工作原理
引言
过程式编程语言中最常见的特性之一是数组的概念。数组似乎是简单的事物,但在向语言中添加数组时,必须回答许多问题,例如:
- 固定大小还是可变大小?
- 大小是类型的一部分吗?
- 多维数组是什么样的?
- 空数组有意义吗?
这些问题的答案会影响数组仅仅是语言的特性,还是其设计的核心部分。
在 Go 语言的早期开发中,花了一年左右的时间来决定这些问题的答案,然后设计才感觉恰当。关键一步是引入了 切片,它建立在固定大小的 数组 之上,提供了一种灵活、可扩展的数据结构。然而,直到今天,Go 的新手程序员仍然常常在理解切片的工作方式上 stumbling over,这可能是因为其他语言的经验影响了他们的思维。
在这篇文章中,我们将尝试澄清这些困惑。我们将通过构建各个部分来解释 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
数组变量,我们可以创建一个描述元素 100 到 150(精确地说,是 100 到 149,包括)的切片,方法是切片数组:
var slice []byte = buffer[100:150]
在此代码片段中,我们使用了完整的变量声明以明确。变量 slice
的类型是 []byte
,发音为“byte 切片”,并通过切片元素 100(含)到 150(不含)从名为 buffer
的数组初始化。更惯用的语法会省略类型,类型由初始化表达式确定:
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!")
}
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))
此代码片段将我们 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。
copy
当我们上一节将切片容量加倍时,我们编写了一个循环将旧数据复制到新切片。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
函数,它将切片扩展一个元素。然而,它是错误的,因为如果切片的容量太小,函数就会崩溃。(我们的 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 会处理这个,当然,所以您不必担心。在这两种转换之后,对字节切片底层数组的修改不会影响相应的字符串。
字符串这种类似切片的设计带来的一个重要结果是创建子字符串非常高效。所有需要做的就是创建一个两字字符串头。由于字符串是只读的,原始字符串和切片操作产生的字符串可以安全地共享同一个数组。
历史说明:字符串的最早实现总是进行分配,但当切片被添加到语言中时,它们为高效的字符串处理提供了模型。结果,一些基准测试看到了巨大的速度提升。
当然,字符串还有更多内容,并且有一篇单独的博客文章对此进行了更深入的介绍。
结论
要理解切片是如何工作的,理解它们的实现方式很有帮助。有一个小数据结构,即切片头,它是与切片变量关联的项,该头描述了单独分配的数组的一个段。当我们传递切片值时,会复制切片头,但它指向的数组始终是共享的。
一旦您理解了它们的工作原理,切片不仅易于使用,而且强大且富有表现力,尤其是在 copy
和 append
内置函数的帮助下。
更多阅读
您可以在网上找到很多关于 Go 中切片的信息。如前所述,“Slice Tricks” Wiki 页面有许多示例。Go 切片博客文章使用清晰的图表描述了内存布局细节。Russ Cox 的Go 数据结构文章讨论了切片以及 Go 的其他一些内部数据结构。
还有很多其他材料可用,但学习切片的最好方法是使用它们。
下一篇文章:Go 中的字符串、字节、rune 和字符
上一篇文章:第一个 Go 程序
博客索引