Menu

Go 切片 slice:append、make、len 与 cap、共享陷阱

切片是 Go 中最常用的列表类型。学习 make、append 与扩容、len 与 cap 的区别、切片操作如何共享底层数组(以及由此引发的 bug)、copy、slices 包、二维切片,以及 nil 切片与空切片。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

一个例子看懂切片

切片是数组之上一个可以增长的视图。你可以用字面量或 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 只是时不时才出现。

两种修复方法:

  1. 完整切片表达式 s[low:high:max] 把容量限制为 max-low。没有剩余容量时,下一次 append 就必须分配新数组。first := base[:2: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,空切片被编码为 []。

Coddy 编程语言插图

和 Coddy 一起学编程

开始