array, slice and append in Go
Arrays, slices (and strings): The mechanics of ‘append’ - go.dev
当我们新学习编程语言中的数组时,我们往往需要考虑以下问题:
- 可变长度还是固定长度?
- 长度是数组数据结构的一部分吗?
- 多维数组是怎样的?
- 空数组有啥含义吗?
将以上问题的答案决定了,对于这门编程语言,数组仅仅是一个功能还是语言的核心设计。
在 Golang 中,开发团队在固定长度的 array 的基础上,设计了可变长、可拓展的数据结构 slice,这两者共同组成了 Golang 中的数组。
Array
Array (也就是数组)是 Go 语言重要的组成元素,Go 中很多的语言特性都是依托于它实现的。在学习更强大、灵活的 slice 之前,我们必须先对 array 有所了解。
正如前面所说,Go 中的数组 array 是固定长度的,array 的长度是它类型的一部分,这也是在 Go 项目中很少看到数组出现的原因。
例如 var buffer [256]byte
声明了一个长度为 256 的 byte 数组 buffer
;如果有一个数组长度为 [512]byte
,那么他们会是两个不同类型的数组。
在内存中,数组是占用了一串连续内存空间的元素组成的,内存中 buffer
数组会是下图这样的:
如图,数组 buffer
仅由 256 个内存中连续分布的元素组成,通过 Go 的内置函数 len
我们可以获取数组的元素个数,即 len(buffer)==256
。和大多数语言中的数组一样,我们可以通过索引,形如 buffer[0]
、buffer[2]
,对它进行随机访问;如果我们使用超过它长度的索引进行访问,程序就会 panic 然后 crash。
func main() {
var buffer [256]byte
buffer[1] = 'a'
fmt.Printf("%c\n", buffer[0]) // 输出空字符 " "
fmt.Printf("%c\n", buffer[1]) // 输出 "a"
fmt.Printf("%c\n", buffer[256]) // 数组越界,panic
}
Slice
A slice is not an array. A slice describes a piece of an array.
slice (切片)引用了某个 array 中一段连续的内存,例如下面,我们用 demo
这个切片引用了之前的 buffer
数组中索引 100 到 149 的五十个元素(切片的语法是左闭右开的):
// 三种等价的声明方式
var demo []byte = buffer[100:150]
var demo = buffer[100:150]
demo := buffer[100:150]
简单理解 slice 的底层结构
我们可以将 slice 简单理解为由两个元素组成的数据结构:
- 切片的长度
- 指向数组特定元素的指针
也就是可以这样认为:
type sliceHeader struct {
Length int
ZerothElement *byte
}
demo := sliceHeader{
Length: 50,
ZerothElement: &buffer[100],
}
上面的数据结构只是一个示例,并不是 Go 中 slice 的真正实现方式。这里主要是通过它来理解 slice 的机制。
如上代码所示,demo
的指针指向了 buffer
数组索引为 100 的元素,也就是 demo[0]
和 buffer[100]
等价:
buffer[100] = 'b'
fmt.Printf("%c\n", demo[0]) // 输出 "b"
除了将 array 切片成 slice,我们也可以对 slice 进行切片:
demo2 := demo[5:10]
和对 array 进行切片一样,demo2
引用了 demo
索引 5~9 的 5 个元素,也就是实际引用了 buffer
索引 205~209 的 5 个元素(demo2 => demo => buffer) 。因此,demo2
底层的 sliceHeader
实际是这样的:
demo2 := sliceHeader{
Length: 5,
ZerothElement: &buffer[105],
}
我们也可以对 slice 自身进行切片,英文叫 reslice:
demo = demo[5:10]
reslice 会将切片后的结果存储到原切片,也就是此时 demo
和 demo2
的 sliceHeader
都是一样的。
理解 slice 是值传递而不是指针传递的
Golang 中很多的函数都是以 slice 作为参数的,而 Golang 永远是值传递的,因此实际上传入的是 sliceHeader
这个结构体的复制。
不过比较特殊的是,sliceHeader
是一个包含指针的结构体,我们需要仔细理解这句话:「slice 是值传递的,传递的是一个包含指向数组特定元素指针的结构体拷贝」。
看下面这个函数:
func AddOneToEachElement(slice []byte) {
for i := range slice {
slice[i]++
}
}
AddOneToEachElement
函数遍历传入的 slice 并令其元素全部自增一。
func main() {
slice := buffer[10:20]
for i := 0; i < len(slice); i++ {
slice[i] = byte(i)
}
fmt.Println("before", slice) // before [0 1 2 3 4 5 6 7 8 9]
AddOneToEachElement(slice)
fmt.Println("after", slice) // after [1 2 3 4 5 6 7 8 9 10]
}
通过上面的例子,我们可以确认:因为 sliceHeader
包含指向数组元素的指针,它们指向相同的底层数组;因此即使 sliceHeader
是值传递的,我们在 AddOneToEachElement
函数中对切片元素的修改,也会影响到传入的源 slice。
下面的例子则证明了 slice
确实是值传递的:
func SubtractOneFromLength(slice []byte) []byte {
slice = slice[0 : len(slice)-1]
return slice
}
func main() {
fmt.Println("Before: len(slice) =", len(slice)) // Before: len(slice) = 50
newSlice := SubtractOneFromLength(slice)
fmt.Println("After: len(slice) =", len(slice)) // After: len(slice) = 50
fmt.Println("After: len(newSlice) =", len(newSlice)) // After: len(slice) = 49
}
上面代码中,我们在 SubtractOneFromLength
函数中修改了 slice 的长度 Length
,可以看到源 slice 并没有被影响,这就是因为 slice 是值传递的,SubtractOneFromLength
中修改的是 sliceHeader 中的长度字段 Length
。
下面的代码中,我们显式传递了 slice 的指针,可以看到即使是长度的变化,也会影响到源 slice:
func PtrSubtractOneFromLength(slicePtr *[]byte) {
slice := *slicePtr
*slicePtr = slice[0 : len(slice)-1]
}
func main() {
fmt.Println("Before: len(slice) =", len(slice)) // Before: len(slice) = 50
PtrSubtractOneFromLength(&slice)
fmt.Println("After: len(slice) =", len(slice)) // After: len(slice) = 49
}
又或者,我们将 slice 作为方法的 Receiver 也可以实现这一目的:
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) // /usr/bin
}
Capacity:slice 的「容量」
我们首先看看下面的例子,这里我们通过 Extend
方法扩充 int 类型的 slice:
func Extend(slice []int, element int) []int {
n := len(slice)
slice = slice[0 : n+1]
slice[n] = element
// 注意这里我们要将 slice 返回
return slice
}
func main() {
var iBuffer [10]int
slice := iBuffer[1:1]
for i := 1; i < 20; i++ {
slice = Extend(slice, i)
fmt.Println(i, len(slice), cap(slice))
}
}
运行上面的代码,结果如下:
1 1 9
2 2 9
3 3 9
4 4 9
5 5 9
6 6 9
7 7 9
8 8 9
9 9 9
panic: runtime error: slice bounds out of range [:10] with capacity 9
goroutine 1 [running]:
main.Extend(...)
/tmp/sandbox1646997272/prog.go:16
main.main()
/tmp/sandbox1646997272/prog.go:27 +0xde
Program exited: status 2.
可以看到,在 main 函数的 i=10 的循环中,程序 panic 了。这是因为 sliceHeader 其实有三个我们需要关心的元素,除了前面提到的指针、长度 Length
外,还有一个容量 Capacity
:
type sliceHeader struct {
Length int
Capacity int
ZerothElement *byte
}
容量 Capacity
记录了 slice 底层数组的具体空间最大容量限制:也就是从 ZerothElement
指针算起、最远能访问底层数组的长度,在上例子中应该是 9。
即,当例子中,我们通过 slice := iBuffer[0:0]
创建 slice 时,它的 sliceHeader 是下面这样的:
slice := sliceHeader{
Length: 0,
Capacity: 10,
ZerothElement: &iBuffer[0],
}
Capacity
是底层数组 iBuffer
的长度(10)减去 slice 指针指向元素所在索引(1)得到的,也就是 10-1=9。
而在 i=10 的循环中,我们通过 Extend
函数,将 slice 增长至超过它的容量、也就是超过底层数组 iBuffer
的最大索引,因此在访问的时候触发了切片越界的 panic。
代码里我们可以看到,通过 len
函数可以获取一个 slice 的长度,cap
则可以获取一个 slice 的容量;当 slice 的长度和容量相等时,这个 slice 就是一个「满的 slice」了:
if cap(slice) == len(slice) {
fmt.Println("slice is full!")
}
Slice 的相关函数
前面提到了 slice 的一些基本属性,Golang 中还内置了一些操纵 slice 的函数。
Make
前面提到,slice 的增长必须要在 capacity 的限制内;不过我们可以通过分配一个新数组、拷贝原有数组的数据,然后让 slice 将这个新数组作为其底层数组,间接实现 slice 的增长。
通过 Golang 的内置函数 new
我们可以分配新的数组,然后进行上述操作。不过另一个内置函数 make
会是一个更简单的选择。
make
函数会先分配一个匿名数组,然后创建并返回一个指向这个匿名数组的 slice,它共有 3 个参数:
- slice 的类型
- slice 的初始长度
- slice 的容量(也就是匿名数组的长度)
创建的 slice 指向匿名数组的第一个元素,因此容量和匿名数组的长度相等。下面我们便创建了一个长度为 10,容量为 15 的 slice:
slice := make([]int, 10, 15)
fmt.Printf("len: %d, cap: %d\n", len(slice), cap(slice)) // len: 10, cap: 15
在使用时,也可以忽略第三个容量参数,这样 make
会默认将第二个参数(长度)作为 slice 的容量:
gophers := make([]Gopher, 10)
上面代码我们便创建了一个长度、容量都是 10 的 slice。
相比于形如 nums[0:10]
这样明确引用数组,实践中更多是通过 make
来声明 slice,这样我们就可以直接操纵灵活的 slice 而无需先自己声明 array 了。
copy
在前面的例子中,可以看到由于 sliceHeader 传递了一个指向底层数组的指针,我们对指针指向的数组元素进行修改时会被原 slice 所感知,因此有时候需要对 slice 进行深拷贝(也就是新建一个 slice,将原有 slice 中的元素复制到新 slice 中)。
Golang 提供了内置函数 copy
来实现这一功能,下例中我们新建了一个容量为两倍的 newSlice
:
newSlice := make([]int, len(slice), 2*cap(slice))
copy(newSlice, slice)
copy
函数复制的元素个数为 min[len(src), len(dst)]
,例如下面的例子:
func len_0() {
src := []int{1, 2, 3}
dst := make([]int, 0, 1) // len(dst) = 0
fmt.Println(dst) // []
// len(dst)==0, won't copy item
copy(dst, src)
fmt.Println(dst) // []
}
func len_1() {
src := []int{1, 2, 3}
dst := make([]int, 1) // len(dst) = 1
fmt.Println(dst) // [0]
// len(dst)==1, copy src[0] to dst[0]
copy(dst, src)
fmt.Println(dst) // [1]
}
copy
函数允许源 slice 和目标 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
}
代码中我们通过 copy(slice[index+1:], slice[index:])
将 slice 中索引 index 及之后的元素复制到 slice 的 index+1 索引位置(会覆盖 index+1 开始的元素),然后在 index 位置插入新的元素。下面则是使用的一个例子:
slice := make([]int, 5, 10) // Note capacity > length: room to add element.
for i := range slice {
slice[i] = i
}
fmt.Println(slice) // [0 1 2 3 4]
slice = Insert(slice, 4, 99) // [0 1 2 3 99 4]
fmt.Println(slice)
append
在前面谈论 campacity 时,我们用 Extend
方法举例;这里我们通过前面讲到的 copy
实现一个具有鲁棒性的 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
}
如上所示,我们在尝试 Extend
时,先判断当前 slice 的长度是否已经达到了最大容量,如果是的话,我们通过 copy
方法将它的容量扩充为 2*length+1
,然后在进行实际的 Extend
操作。
一般而言,如果能提前知道 slice 的最大容量,最好在初始化时、也就是调用 make
时显式指定,这样可以避免后续 append