一个例子看懂切片
切片是数组之上一个可以增长的视图。你可以用字面量或 make 创建切片,用 append 让它增长,用 len 读取它的大小。
[]string 的方括号里没有长度。这就是它与 [4]string 这种大小固定的数组类型的区别。
切片到底是什么
切片值是一个很小的头部,包含三个字段:
- 一个指向底层数组中某个元素的指针
- 长度:切片能看到多少个元素
- 容量:从这个指针开始到底层数组末尾一共有多少个元素
复制切片(赋值、传给函数)时只复制这个头部。两个副本指向的是同一批元素。几乎所有关于切片的意外,都来自忘记了这一点。
make:长度与容量
make([]T, len, cap) 分配一个底层数组,并返回基于它的切片。容量是可选的,默认等于长度。
一个常见的混淆:先 make([]int, 5),再调用五次 append,得到的是十个元素,其中前五个都是零。打算追加元素时用 make([]int, 0, 5),打算按索引赋值时用 make([]int, 5)。
在知道最终大小时预先分配容量,可以避免反复扩容。这是一种优化,而不是必须的。
append 与扩容
append 在当前长度之后添加元素。如果容量足够,就直接在原地写入。否则,它会分配一个更大的数组,把旧元素复制过去,然后返回一个指向新数组的切片。
切片较小时,容量大约翻倍增长;超过 256 个元素后,增长的倍数会变小(逐渐趋向 1.25 倍)。具体数字是运行时的实现细节,在不同的 Go 版本之间有过变化,所以永远不要编写依赖它们的代码。
因为 append 可能返回另一个数组,你必须使用它的返回结果:
append(s, 4) // compile error: append(s, 4) (value of type []int) is not used
s = append(s, 4) // correct
追加一整个切片要用展开语法:s = append(s, other...)。
切片操作共享底层数组
s[low:high] 在同一个数组上创建一个新的切片头部,覆盖从 low 到 high 的索引,不包括 high。两个边界都可以省略。不会复制任何元素。
输出:
[10 99 30 40 50]
3 4
正是这种共享让切片操作的开销很小。它也是最著名的切片 bug 的根源。
append 别名 bug
当一个子切片还有剩余容量时,向它追加元素会写进父切片的数组,覆盖父切片仍在使用的元素。
输出:
[1 2 100 4 5]
[1 2 100]
[cmd test] [cmd test]
a 和 b 共享 "cmd" 后面的那个数组位置,所以第二次追加覆盖了第一次。代码看起来没问题,而且只要容量恰好是满的就能正常工作,所以这个 bug 只是时不时才出现。
两种修复方法:
- 完整切片表达式
s[low:high:max]把容量限制为max-low。没有剩余容量时,下一次append就必须分配新数组。first := base[:2:2]能让第一个例子变得安全。 - 显式复制:当一个切片的生命周期会超出本次调用,或者会被独立地追加元素时,使用
slices.Clone(prefix)或append([]string(nil), prefix...)。
一个相关的陷阱:一个巨大数组上的小切片,会让整个数组在垃圾回收器眼中一直存活。如果你读取了一个 100 MB 的文件,却保留了 data[:10],这 100 MB 就会一直留在内存里。把要保留的部分复制出来。
copy
内置的 copy(dst, src) 复制 min(len(dst), len(src)) 个元素,并返回这个数量。它永远不会让 dst 增长。
copy 能正确处理源和目标重叠的情况,所以 copy(s[1:], s) 可以把元素向右移动,而不会把数据弄乱。
slices 包
从 Go 1.21 起,标准库的 slices 包为那些过去需要手写的操作提供了泛型辅助函数。
| 函数 | 作用 |
|---|---|
Contains、Index | 查找一个值(ContainsFunc、IndexFunc 接收判断函数) |
Sort、SortFunc、SortStableFunc | 原地排序 |
BinarySearch | 在已排序的切片中查找 |
Insert、Delete、DeleteFunc | 插入或删除,返回新的切片 |
Compact | 去掉连续的重复项 |
Equal、Compare | 逐个元素比较 |
Clone、Reverse、Max、Min | 顾名思义 |
Collect、Sorted、Values、All | 配合迭代器使用(Go 1.23) |
Delete 和 Insert 返回的切片必须赋值回去,和 append 一样。排序有专门的页面:排序。
按索引循环时删除元素会跳过一些元素。应改用 slices.DeleteFunc:
s = slices.DeleteFunc(s, func(n int) bool { return n%2 == 0 }) // drop evens
二维切片
切片的切片构成一个网格,各行的长度可以不同。每一行都必须单独分配。
忘记内层的 make,每一行都会是 nil,grid[1][2] = 7 会引发 panic:index out of range [2] with length 0。
nil 切片与空切片
对于 len、cap、range 和 append,nil 切片的表现和空切片一样,所以优先用 var s []T 作为零状态。只有当两者的区别有影响时才初始化为 []T{},这主要是 JSON 输出(null 与 [])。判断是否为空用 len(s) == 0,而不是 s == nil。
切片与函数
接收切片的函数可以修改切片中的元素,调用方能看到这些修改。但它无法改变调用方切片的长度,因为它拿到的是头部的副本。会追加元素的函数必须返回新的切片:
func addAll(s []int, vals ...int) []int {
return append(s, vals...)
}
这就是 append、slices.Delete 和 slices.Insert 都返回切片的原因。
常见错误
- 忽略
append的返回结果。 完全丢弃结果是无法编译的,但如果把append(s, x)赋给了另一个变量,而你继续使用的是原来那个,就是逻辑 bug。 - 从同一个基础切片做两次 append。 它们可能共享存储空间。先复制基础切片,或者使用完整切片表达式。
- 先
make([]T, n)再append。 会在前面留下 n 个零。 - 索引越界。
s[len(s)]会引发 panic。最后一个元素是s[len(s)-1]。 - 用
==比较切片。 只有s == nil能通过编译。应使用slices.Equal。
常见问题
Go 中切片的长度和容量有什么区别?
len(s) 是切片当前包含的元素个数。cap(s) 是从切片的第一个元素开始,底层数组能容纳的元素个数。有剩余容量时,append 会直接写入剩余的空间;没有时,就分配一个新的、更大的数组。
Go 中如何向切片追加元素?
调用内置的 append,并把结果赋值回去:s = append(s, x)。它可以一次添加多个值(append(s, 1, 2, 3)),也可以添加另一个切片(append(s, other...))。一定要使用返回值,因为 append 可能返回一个指向新数组的切片。
Go 中如何从切片中删除元素?
使用 slices.Delete(s, i, i+1)(Go 1.21),它会把后面的元素往前移,并返回变短后的切片。从 Go 1.22 起,它还会把空出来的尾部位置清零。如果不在乎顺序,就和最后一个元素交换,然后截短:s[i] = s[len(s)-1]; s = s[:len(s)-1]。
Go 中如何检查切片是否包含某个值?
使用标准库 slices 包中的 slices.Contains(s, v)(Go 1.21)。slices.Index(s, v) 返回位置或 -1,slices.ContainsFunc 接收一个判断函数。如果要在大量数据上反复查找,改用 map。
Go 中 nil 切片和空切片有什么区别?
var s []int 是 nil;s := []int{} 是空的,但不是 nil。两者长度都为 0,都能正常使用 len、range 和 append。区别体现在 s == nil 判断上,以及 encoding/json 中:nil 切片被编码为 null,空切片被编码为 []。