Go 切片是一扇小窗口,背后的数组才真正存放数据。看清这扇窗口,len、cap、append,以及两个切片互相修改的 bug 就不再神秘。
切片是你在 Go 里用得最多的类型,也是最让人意外的类型。它看起来像列表,用起来也大多像列表,但它不是列表。
本文从数组讲起,因为切片就建立在数组之上。下面每个程序都在 Go 1.26 上跑过,输出直接从运行结果粘贴而来。
数组:大小固定,整体复制
数组的长度是类型的一部分。[3]int 和 [4]int 是不同的类型,而且都不能变长。
package main
import "fmt"
func main() {
a := [3]int{1, 2, 3}
b := a // copies all three values
b[0] = 99
fmt.Println(a, b)
}
输出:
[1 2 3] [99 2 3]
给数组赋值会复制每一个元素。b 是一组新的三个格子,所以改它不会影响 a。
这让数组的行为很好预测,也让它在日常代码里很少见,因为你几乎从来不能事先知道确切的大小。实际用的是切片。
切片是数组上的一扇窗口
package main
import "fmt"
func main() {
shelf := [6]string{"a", "b", "c", "d", "e", "f"}
s := shelf[1:4]
fmt.Println(s, len(s), cap(s))
s[0] = "B"
fmt.Println(shelf)
}
输出:
[b c d] 3 5
[a B c d e f]
shelf[1:4] 什么都没复制。它创建一个切片,看着已有数组的第 1、2、3 个元素。写 s[0] = "B" 改的是 shelf,因为格子只有一组。
用十岁孩子能懂的话说
想象一个长长的架子,上面是一排编了号的格子。这就是数组,东西真正放在这里。
切片是你举在架子前的一扇硬纸板窗口。窗口上写着三件事:
- 它在架子上从哪儿开始
- 它露出几个格子。这就是
len。 - 从它的起点到架子尽头一共有几个格子。这就是
cap。
你把手伸进窗口换掉一个玩具,架子上的玩具就变了。别人要是也拿着窗口对着同样的格子,也会看到新玩具。
准确的说法
切片值由三个字(word)组成:指向底层数组某个元素的指针、长度和容量。复制切片或把它传给函数,复制的是这三个字,从来不复制元素。
len(s) 是能用索引访问的元素个数。cap(s) 是从切片起点到底层数组末尾一共有多少个元素。在上面的例子里,s 从一个 6 元素数组的第 1 个元素开始,所以容量是 5。
这个比喻的局限:真正的窗口没法让架子变长。append 却能做到类似的事,下一节会讲怎么做到的。
append:有空位就用原来的架子,没有就换新架子
append 往切片里添加值。它是复用原来的数组还是分配新数组,决定了你的程序有没有 bug。先看动画,再读规则。
对长度为 3、容量为 4 的切片执行 append。第一次 append 放进了空着的格子,所以用的还是同一个数组。第二次找不到空位:Go 分配一个更大的数组,把值复制过去,加上 50,切片现在指向新数组。旧数组已经没有任何东西指向它,垃圾回收器会把它回收。
如果动画没有播放,下面用文字把这几步再说一遍:
s在一个能放四个值的数组里存了三个值。append(s, 40)找到一个空格子。它把 40 放进去,返回一个长度为 4、仍然基于同一个数组的切片。append(s, 50)找不到空格子。它分配一个新的、更大的数组。- 它把四个值复制过去,加上 50,返回一个基于新数组的切片。旧数组被留在原地。
所以你总是写 s = append(s, x)。append 可能返回基于另一个数组的切片,如果不保存结果,你手里还是那扇旧窗口。
会大多少?
Go 语言规范没有规定。这是运行时的实现细节,与其死记,不如亲眼看看:
package main
import "fmt"
func main() {
var s []int
prev := -1
for i := range 2000 {
s = append(s, i)
if cap(s) != prev {
fmt.Printf("len %4d cap %4d\n", len(s), cap(s))
prev = cap(s)
}
}
}
输出:
len 1 cap 4
len 5 cap 8
len 9 cap 16
len 17 cap 32
len 33 cap 64
len 65 cap 128
len 129 cap 256
len 257 cap 512
len 513 cap 848
len 849 cap 1280
len 1281 cap 1792
len 1793 cap 2560
有两点值得注意。第一次 append 就预留了四个位置,之后小切片按两倍增长。超过几百个元素后,增长变慢,这样大切片不会浪费大量内存。具体数字在不同 Go 版本之间可能会变。不变的是这条规则:只有旧数组满了,才会分配新数组。
如果大致知道会有多少个元素,就提前说明,省掉反复扩容:
package main
import "fmt"
func main() {
ids := make([]int, 0, 1000)
for i := range 1000 {
ids = append(ids, i)
}
fmt.Println(len(ids), cap(ids))
}
输出:
1000 1000
make([]int, 0, 1000) 得到长度 0、容量 1000 的切片。一千次 append 都放得下,什么都不用复制。
bug:两个切片互相修改
把共享数组和“有空位就用原来的架子”这条规则放在一起,就得到 Go 里最常见的切片 bug:
package main
import "fmt"
func main() {
base := make([]int, 3, 4)
a := append(base, 1)
b := append(base, 2)
fmt.Println(a, b)
}
输出:
[0 0 0 2] [0 0 0 2]
你大概以为会是 [0 0 0 1] [0 0 0 2]。实际发生的是这样的。base 有一个空格子。第一次 append 把 1 写了进去。第二次 append 也有空位,于是把 2 写进了同一个格子。a 和 b 是同一个数组上的两扇窗口,最后一次写入说了算。
没有崩溃,也没有警告。这就是这个 bug 能逃过代码评审的原因。
修复:限制容量
完整切片表达式 s[low:high:max] 同时设定容量。没有空余位置,append 就只能分配新数组:
package main
import "fmt"
func main() {
base := make([]int, 3, 4)
a := append(base[:3:3], 1)
b := append(base[:3:3], 2)
fmt.Println(a, b)
}
输出:
[0 0 0 1] [0 0 0 2]
base[:3:3] 的意思是“前三个元素,容量为 3”。现在每次 append 都发现窗口满了,所以各自得到一个新数组。
如果只是想要一份独立的副本,slices.Clone 表达得更直白。后面还会讲到它。
把切片传给函数
把切片传给 Go 函数,复制的是三个字的切片头,而这份副本指向的是同一个数组:
package main
import "fmt"
func setFirst(s []int) {
s[0] = 100
}
func addOne(s []int) {
s = append(s, 1)
}
func main() {
nums := []int{1, 2, 3}
setFirst(nums)
addOne(nums)
fmt.Println(nums)
}
输出:
[100 2 3]
setFirst 改了共享的数组,所以调用方看到了 100。addOne 做了 append,但只作用在它自己的那份切片头副本上。调用方的 nums 长度还是 3,所以多出来的元素根本看不到。
需要让切片变长的函数,要像 append 那样返回新切片:nums = addOne(nums)。
nil 切片和空切片
nil 切片和空切片长度都是 0,但它们不是同一个值:
package main
import (
"encoding/json"
"fmt"
)
func main() {
var nilSlice []int
empty := []int{}
fmt.Println(nilSlice == nil, empty == nil)
fmt.Println(len(nilSlice), len(empty))
a, _ := json.Marshal(nilSlice)
b, _ := json.Marshal(empty)
fmt.Println(string(a), string(b))
}
输出:
true false
0 0
null []
两者长度都是 0。都可以用 range 遍历,都可以 append。nil 切片完全可以当空切片用,所以 var s []int 是声明切片的常规写法。
区别出现在边界上。nil 切片编码成 JSON 是 null,空切片是 []。本系列后面构建 REST API 时这一点很重要。期望收到数组的客户端,遇到 null 可能会出错。
越界访问
在 Go 里,读取超出长度的位置会引发运行时 panic,而不是悄悄读出紧挨着的那块内存:
package main
import "fmt"
func main() {
s := []int{1, 2, 3}
fmt.Println(s[:cap(s)])
fmt.Println(s[3])
}
它输出第一行,然后停下:
[1 2 3]
panic: runtime error: index out of range [3] with length 3
索引按 len 检查,切片操作按 cap 检查。Go 从不悄悄读取末尾之外的内存。它会用 panic 终止程序,讲错误的那一部分会正式介绍 panic。
slices 包
从 Go 1.21 起,标准库有了 slices 包,以前要自己写循环的活儿,现在交给它就行:
package main
import (
"fmt"
"slices"
)
func main() {
scores := []int{42, 7, 19, 7}
clone := slices.Clone(scores)
slices.Sort(clone)
fmt.Println(scores, clone)
fmt.Println(slices.Contains(scores, 19), slices.Index(scores, 7))
fmt.Println(slices.Compact(clone))
}
输出:
[42 7 19 7] [7 7 19 42]
true 1
[7 19 42]
slices.Clone 创建一个基于新数组的切片,所以对副本排序不会动到 scores。slices.Compact 删除相邻的重复元素,所以要在 Sort 之后用。
要点
- 数组大小固定,大小是类型的一部分,赋值时会复制每个元素。
- 切片是数组上的一扇窗口:一个指针、
len和cap。复制切片复制的是窗口,不是值。 - 容量够时
append复用数组,不够时分配更大的数组。一定要写s = append(s, x)。 - 基于同一个数组的两个切片,可能通过
append互相覆盖。需要它们互不影响时,用s[low:high:max]或slices.Clone。 - 函数能修改切片的元素,但改不了它的长度。改为返回新切片。
- nil 切片是合格的空切片,但编码成 JSON 是
null。
切片并不装着你的值。它告诉你值在哪里。