数组、切片与底层数组关系
本章解决什么: 用一组同类型数据表示列表,并理解为什么切片有时会共享同一份数据。
读前准备: 已会变量与基本类型。下标从本章开始介绍。先读第 1–6 节,掌握创建、长度、容量、追加和共享数据;第 7–11 节把这些规则应用到复制和函数调用,第 12 节的内存保留可之后回看。
下面的操作片段放在 main 内分别运行,并导入 fmt;函数声明放在 main 外。len、cap、make、append、copy 是 Go 内置函数,无需导入包。
直觉模型:数组是一整块固定长度的数据;切片是查看或操作其中一段连续数据的窗口。
数组赋值会复制元素;切片赋值、切片截取通常只复制“窗口信息”,因此可能共享同一底层数组。
1. 数组(array)
如果要存三个成绩,不必分别声明 score1、score2、score3,可以用数组把同类型的值放在一起。数组的长度固定,且长度属于类型的一部分。[3]int 就是“有三个整数位置的数组”:
var a [3]int
fmt.Println(a) // [0 0 0]常见初始化方式:
a := [3]int{10, 20, 30} // 明确指定长度
a2 := [...]int{10, 20, 30} // 由元素数量推断长度,仍然是数组
a3 := [5]int{
0: 10,
3: 40,
} // [10 0 0 40 0]数组下标从 0 开始:
a := [3]int{10, 20, 30}
a[1] = 99
fmt.Println(a) // [10 99 30]a[1] 表示第二个元素;len(a) 返回元素数量。有效下标是 0 到 len(a)-1,所以长度为 3 时不能读 a[3]。可在编译时确定的非法下标会报编译错误,否则运行时越界会 panic。下面切片的下标也遵守这个规则。
数组的复制语义
数组赋值会复制全部元素:
a := [3]int{1, 2, 3}
b := a
b[0] = 99
fmt.Println(a) // [1 2 3]
fmt.Println(b) // [99 2 3]因此 [3]int 和 [4]int 是不同类型,不能直接互相赋值。
这里的整数元素各自独立;若数组元素本身是切片或指针,复制元素仍可能共享其指向的数据,不能把数组赋值理解成递归深拷贝。
2. 切片(slice)的初始化
如果成绩的数量要随着输入增长,固定长度数组就不方便了。切片类型中没有长度:[]int、[]string 等。它的长度可以通过追加或重新截取改变。
2.1 切片字面量
nums := []int{10, 20, 30}
names := []string{"张三", "李四"}与数组对比:
[3]int{1, 2, 3} // 数组
[]int{1, 2, 3} // 切片2.2 nil 切片与空切片
nil 是切片未显式初始化时的零值;不是字符串 "nil"。长度为零不代表能访问第一个元素,所有空切片的 [0] 都越界。
var a []int // nil 切片
b := []int{} // 空切片
c := make([]int, 0) // 空切片
fmt.Println(len(a)) // 0
fmt.Println(a == nil) // true
fmt.Println(b == nil) // false它们都能安全遍历、读取长度和 append。一般没有元素时直接使用 nil 即可。切片之间不能用 a == b 比较,只能与 nil 比较;比较内容时需逐项比较,或在 Go 1.21 及以上使用 slices.Equal(需导入 slices,元素类型须可比较)。
扩展阅读:JSON 是交换数据的文本格式,标准库 encoding/json 默认把普通 nil 切片编码为 null,非 nil 空切片编码为 []。[]byte 有专门的编码规则;第一次学列表操作无需掌握它。
2.3 make
make 创建指定长度的切片。先区分两个数:len 是现在能访问几个元素,cap 是不更换底层数组时最多可扩展到多长。下面的 T 是任意元素类型的占位写法,实际代码要换成 int、string 等:
make([]T, 长度)
make([]T, 长度, 容量)nums := make([]int, 3)
fmt.Println(nums) // [0 0 0]
fmt.Println(len(nums)) // 3
fmt.Println(cap(nums)) // 3
scores := make([]int, 2, 5)
fmt.Println(scores) // [0 0]
fmt.Println(len(scores)) // 2
fmt.Println(cap(scores)) // 5重要: make([]int, 3) 创建的是已有 3 个零值元素的切片,不是“预留 3 个空位”。
make([]T, ...) 返回切片,并为它准备底层数组。长度和容量必须非负,且长度不能大于容量;make([]int, 3, 2) 不合法。声明数组变量则用 var a [N]T 等数组语法。
如果意图是预留空间后再追加,写:
nums := make([]int, 0, 3)
nums = append(nums, 10, 20, 30)
fmt.Println(nums) // [10 20 30]不要误写为:
nums := make([]int, 3)
nums = append(nums, 10, 20, 30)
// [0 0 0 10 20 30]3. append 的基础用法
append 在切片末尾追加元素;要接住它的返回值:
nums := []int{1, 2}
nums = append(nums, 3) // 追加一个元素
nums = append(nums, 4, 5) // 追加多个元素追加另一个切片的所有元素,要使用 ... 展开:
a := []int{1, 2}
b := []int{3, 4, 5}
a = append(a, b...)
fmt.Println(a) // [1 2 3 4 5]不能丢弃 append 的结果:
// append(nums, 3) // 编译错误:append 的返回值未使用原因是 append 可能只更新长度,也可能因容量不够而分配新的底层数组;返回值才是正确的、更新后的切片。
4. 切片的底层模型:起点、长度与容量
切片可近似理解为三项:
底层数组的起始位置 + 长度 len + 容量 caps := []int{10, 20, 30, 40}
fmt.Println(len(s)) // 4
fmt.Println(cap(s)) // 4len:当前切片中可以按下标访问的元素数量;cap:当前切片允许扩展到的最大长度,不等于当前元素个数。通常到达底层数组末尾,也可以被第 8 节的三下标切片主动限制。
容量还没用到的部分不能直接按下标访问,必须先增加长度:
s := make([]int, 2, 4)
// s[2] = 9 // panic:当前 len 只有 2,虽然 cap 是 4
s = s[:3]
s[2] = 9
fmt.Println(s) // [0 0 9]接下来的截取语法解释为什么 s[:3] 合法。
5. 截取切片:[low:high]
规则:包含 low,不包含 high。
arr := [4]int{10, 20, 30, 40}
s := arr[1:3]
fmt.Println(s) // [20 30]还可以省略边界:
nums := []int{10, 20, 30, 40}
fmt.Println(nums[:2]) // [10 20]
fmt.Println(nums[2:]) // [30 40]
fmt.Println(nums[:]) // [10 20 30 40]对切片 s,s[low:high] 要满足 0 <= low <= high <= cap(s);结果长度是 high-low,容量是 cap(s)-low。省略 high 时用 len(s),不是 cap(s)。对于数组,边界上限是数组长度。
截取出来的切片通常共享底层数组
arr := [4]int{10, 20, 30, 40}
s := arr[1:3]
s[0] = 99
fmt.Println(arr) // [10 99 30 40]
fmt.Println(s) // [99 30]s[0] 对应原数组的 arr[1]。切片不是元素副本,只是指向原数据一段区域的窗口。
第二遍:删除元素也会改变切片长度
学会截取后,可以把“被删元素前面的部分”和“后面的部分”拼接起来:
items := []string{"a", "b", "c"}
i := 1
items = append(items[:i], items[i+1:]...)
fmt.Println(items) // [a c]要求 0 <= i < len(items)。这个写法复用底层数组并移动后面的元素,共享该数组的其他切片也会看到变化;它不会自动清理缩短后尾部留下的引用。Go 1.21 起可导入 slices 使用 items = slices.Delete(items, i, i+1);Go 1.22 起该函数还会清零删掉后腾出的尾部,帮助释放不再需要的引用。两种写法都要保存返回的切片。
6. 切片赋值也通常共享数据
a := []int{1, 2, 3}
b := a
b[0] = 99
fmt.Println(a) // [99 2 3]
fmt.Println(b) // [99 2 3]b := a 复制的是切片描述信息,而不是底层元素。只要两者仍指向同一底层数组,通过下标修改元素就会互相可见。
7. 第二遍:append 与容量:何时共享、何时分离
7.1 容量足够:复用底层数组
base := make([]int, 2, 4)
base[0], base[1] = 10, 20
next := append(base, 30)
next[0] = 99
fmt.Println(base) // [99 20]
fmt.Println(next) // [99 20 30]此时 base 和 next 仍共享同一底层数组。
7.2 容量不足:分配新数组
base := []int{10, 20}
next := append(base, 30)
next[0] = 99
fmt.Println(base) // [10 20]
fmt.Println(next) // [99 20 30]当容量不够时,append 会分配更大的新底层数组,复制原元素,再追加。因此这里 next 与 base 的底层数组分离。新容量具体增长多少是实现选择,不要把“总是翻倍”当作语言保证。
实践原则:不要假定 append 一定复制或一定共享。是否扩容由容量决定。
8. 第二遍:子切片追加的陷阱与三下标切片
base := []int{1, 2, 3, 4}
sub := base[:2] // len=2, cap=4
sub = append(sub, 99)
fmt.Println(base) // [1 2 99 4]
fmt.Println(sub) // [1 2 99]因为 sub 还有剩余容量,追加的 99 直接写进了 base[2] 的位置,覆盖了原本的 3。
若不希望 sub 的 append 改动 base 后面的元素,可用三下标切片限制容量:
base := []int{1, 2, 3, 4}
sub := base[:2:2] // len=2, cap=2
sub = append(sub, 99)
fmt.Println(base) // [1 2 3 4]
fmt.Println(sub) // [1 2 99]语法:
base[low:high:max]其中 max 限制新切片的容量终点,结果 len=high-low、cap=max-low,要求 0 <= low <= high <= max <= cap(base)。上例中容量被限制为 2,因此再追加时必须创建新数组。注意,在执行 append 之前,sub[0] = x 仍会修改 base[0];限制容量本身并没有复制元素。
9. 第二遍:如何创建独立副本
需要确保修改副本不影响原切片时,必须显式复制元素。
make + copy
original := []int{1, 2, 3}
clone := make([]int, len(original))
copy(clone, original)
clone[0] = 99
fmt.Println(original) // [1 2 3]
fmt.Println(clone) // [99 2 3]copy 返回实际复制的元素数,即 min(len(dst), len(src));不会为目标切片增长长度。只预留容量无法复制进去:
dst := make([]int, 0, 3)
n := copy(dst, []int{1, 2, 3})
fmt.Println(n, dst) // 0 []需要先创建足够的长度,如前面的 make([]int, len(original))。copy 允许源、目标重叠;只有显式创建独立目标存储,才能达到隔离数据的目的。内置函数文档 说明了这一点。
append 克隆
clone := append([]int(nil), original...)以上都是浅拷贝。若元素本身仍是指针、map 或切片,内部数据仍可能共享,需要按层级继续复制。
10. 第二遍:切片作为函数参数
函数收到的是切片描述符的副本,但其底层数组通常仍与调用方共享。
改元素:影响调用方
func setFirst(s []int) {
s[0] = 99
}
nums := []int{1, 2, 3}
setFirst(nums)
fmt.Println(nums) // [99 2 3]追加元素:调用方长度不会自动更新
func addNumber(s []int) {
s = append(s, 4)
fmt.Println("函数内:", s) // [1 2 3 4]
}
nums := []int{1, 2, 3}
addNumber(nums)
fmt.Println("函数外:", nums) // [1 2 3]因为函数内更新的是自己的切片描述符副本。若函数需要让调用方获得追加后的切片,应返回它:
func addNumber(s []int) []int {
return append(s, 4)
}
nums = addNumber(nums)11. 第二遍:range 修改元素的注意点
for i, v := range nums 每轮给出一个下标 i 和对应的值 v;_ 用于忽略不需要的下标。先读一个列表:
nums := []int{10, 20, 30}
for i, v := range nums {
fmt.Println(i, v) // 依次是 0 10、1 20、2 30
}range 中的值变量是元素的副本:
nums := []int{10, 20, 30}
for _, v := range nums {
v *= 2 // 等价于 v = v * 2
}
fmt.Println(nums) // [10 20 30]要修改原切片,应通过下标:
for i := range nums {
nums[i] *= 2
}
fmt.Println(nums) // [20 40 60]这里 range 开始时确定遍历所用的切片和长度,但没有复制所有元素形成快照:某个尚未访问的元素若被修改,后续迭代可读到新值。追加不会增加本轮遍历次数。初学时避免在遍历中同时追加、删除或重排同一个切片。
12. 第二遍:小切片长期引用大数组的内存问题
data := make([]byte, 10_000_000)
small := data[:10]small 虽然只有 10 个字节,但仍引用大数组。只要 small 存活,大数组通常无法被垃圾回收。
若只需要保存这小段数据,复制出来:
small := append([]byte(nil), data[:10]...)13. 关键对照表
| 操作 | 是否复制元素 | 是否可能影响原数据 |
|---|---|---|
b := a,a 为数组 | 是(按元素赋值) | 直接元素独立,内部引用可能共享 |
b := a,a 为切片 | 否 | 是 |
s := arr[1:3] | 否 | 是 |
copy(dst, src) | 是 | 取决于 dst 是否独立;copy 不分配存储 |
clone := append([]T(nil), src...) | 是 | 后续通常不影响 |
append(s, x) | 取决于容量 | 容量够时可能影响共享数据 |
函数内 s[i] = x | 否 | 会影响调用方 |
函数内 s = append(s, x) | 取决于容量 | 调用方的长度不会自动更新 |
14. 工程实践清单
- 数组适合长度固定且长度有业务含义的场景;日常集合数据通常使用切片。
- 看到切片就默认它可能共享底层数据,除非明确做过复制。
- 使用
append时,始终接住返回值:s = append(s, x)。 - 预分配空间但还没有元素时,使用
make([]T, 0, n),不要写make([]T, n)。 - 函数负责扩展切片时,返回更新后的切片。
- 不希望调用方修改内部切片时,返回副本,而不是直接暴露原切片。
- 从大切片中留下少量长期数据时,复制小数据,避免无意保留大数组。
- 三下标切片用于隔离后续
append的容量;若要隔离元素修改,仍必须显式复制。
自测
s := make([]int, 0, 3) 能直接写 s[0] = 1 吗?copy(s, []int{1}) 又能复制几个元素?
答案:不能直接下标写入,因为长度为零;copy 也只能复制零个。可以使用 s = append(s, 1),或先增加长度再按下标写入。容量预留与实际长度是两件不同的事。
重新截取的边界参见 Go 切片表达式规范;slices.Delete 清理尾部引用的版本变化参见 Go 官方说明。