3.1 数组、切片与扩容
上一章的 tasks 是个临时变量,add 靠返回新切片来传递状态。现在要把它坐实成 TaskAPI 的核心存储。Go 里承载有序集合的只有两样东西:数组和切片。数组用得少,切片则无处不在——标准库的每个角落都有它。理解切片的关键,是理解它不是一个「动态数组」,而是「指向底层数组的一个描述符」,这个心智模型决定了很多反直觉行为的解释。
本节把 TaskAPI 推进到「内存版任务列表成型」:用
[]Task承载所有任务,add通过append追加,list遍历输出,并用slices包的SortFunc、ContainsFunc、IndexFunc完成排序与查找。存储不再是散落的临时变量,而是一份可增可查的切片。
3.1.1 数组:长度是类型的一部分
数组的长度写在类型里,所以 [3]int 和 [4]int 是两个不同的类型,不能互相赋值:
var a [3]int // 零值 [0 0 0]
b := [3]int{1, 2, 3} // 指定长度
c := [...]int{1, 2, 3} // 长度由元素个数推断,也是 [3]int
数组是值类型,赋值和传参都会整体拷贝:
a := [3]int{1, 2, 3}
b := a
b[0] = 99
fmt.Println("数组 a:", a, "b:", b)
数组 a: [1 2 3] b: [99 2 3]
改 b 完全不影响 a。这一点和 C 的数组退化成指针正好相反,也是 Go 数组很少被直接使用的原因——拷贝成本随长度增长,传一个大数组进函数会白白复制一份。需要共享或需要动态长度时,用切片。
3.1.2 切片:底层数组的描述符
切片是一个三要素结构:指向底层数组的指针、长度 len、容量 cap。
| 字段 | 含义 | 取值方式 |
|---|---|---|
| 指针 | 指向底层数组的第一个可用元素 | 不直接暴露 |
len | 当前元素个数,可索引范围 [0, len) | len(s) |
cap | 从指针位置到底层数组末尾的容量 | cap(s) |
因为切片是描述符(值类型,但内部含指针),赋值切片不会拷贝底层数组,两个变量共享同一份数据:
s := []int{1, 2, 3}
sl := s
sl[0] = 99
fmt.Println("切片 s:", s, "sl:", sl)
切片 s: [99 2 3] sl: [99 2 3]
这就是切片与数组最本质的区别:数组拷贝的是数据,切片拷贝的是描述符。
3.1.3 make、字面量与 nil 切片
创建切片有几种方式:
s1 := []int{1, 2, 3} // 字面量:len=3 cap=3
s2 := make([]int, 3) // make:len=3 cap=3,元素为零值
s3 := make([]int, 0, 8) // make:len=0 cap=8,预留容量
var s4 []int // nil 切片
s5 := []int{} // 空切片,非 nil
nil 切片和空切片在行为上几乎一样——len、cap 都是 0,都能 append,都能 range。一个直接可观察的区别是 s4 == nil 为真而 s5 == nil 为假;序列化也可能区分二者,后文会说明:
var nilSlice []int
emptySlice := []int{}
fmt.Printf("nil=%v len=%d cap=%d isNil=%v\n", nilSlice, len(nilSlice), cap(nilSlice), nilSlice == nil)
fmt.Printf("empty=%v len=%d cap=%d isNil=%v\n", emptySlice, len(emptySlice), cap(emptySlice), emptySlice == nil)
fmt.Println("append to nil:", append(nilSlice, 1))
nil=[] len=0 cap=0 isNil=true
empty=[] len=0 cap=0 isNil=false
append to nil: [1]
需要序列化时(比如 JSON 输出),使用 encoding/json 的默认行为时,nil 切片会变成 null 而空切片是 [];encoding/json/v2 默认把 nil 切片编码为 [],应明确使用的包与选项,这是少数必须区分二者的场景。判断切片是否为空,一律用 len(s) == 0,不要用 s == nil。
3.1.4 append 与扩容
append 在容量够时直接写入底层数组、返回更新后的描述符;容量不够时分配一块更大的数组、拷贝数据、再返回新描述符:
capSlice := make([]int, 0)
prev := cap(capSlice)
for i := 0; i < 12; i++ {
capSlice = append(capSlice, i)
if cap(capSlice) != prev {
fmt.Printf("len=%2d cap %d -> %d\n", len(capSlice), prev, cap(capSlice))
prev = cap(capSlice)
}
}
len= 1 cap 0 -> 4
len= 5 cap 4 -> 8
len= 9 cap 8 -> 16
实测可以清楚看到「容量翻倍」的节奏:0 → 4 → 8 → 16。需要强调的是,扩容策略是运行时实现细节,不是语言规范——不同 Go 版本、不同元素大小下的增长曲线都可能不同。你能依赖的只有两件事:append 一定返回(可能全新的)切片,且返回值必须接住。
s = append(s, x) // 正确
append(s, x) // 编译通过但毫无意义,结果被丢弃
正因为扩容会重新分配,必须用 s = append(s, ...) 的形式。忘了接返回值是新手最常见的切片 bug。
如果提前知道大概数量,用 make([]Task, 0, n) 预留容量能省掉多次拷贝:
tasks := make([]Task, 0, 16)
3.1.5 共享底层数组的坑
这是切片最容易出问题的地方。两个切片可能指向同一个底层数组,改动会互相影响:
base := []int{0, 1, 2, 3, 4, 5}
left := base[1:3] // len=2 cap=5,与 base 共享
left = append(left, 100) // cap 够用,直接写进 base 的底层数组
fmt.Println("base 被污染:", base)
base 被污染: [0 1 2 100 4 5]
left 的容量是从索引 1 到数组末尾(共 5),追加时不需要扩容,于是 100 直接覆盖了 base[3]。这类 bug 非常隐蔽:函数内部对一个切片做 append,却悄悄改掉了调用方持有的另一个切片。
解法是三索引切片 s[low:high:max],第三个索引显式限制容量:
right := base[1:3:3] // len=2 cap=2,追加必然触发扩容
right = append(right, 200)
fmt.Println("base 未被污染:", base, "right:", right)
base 未被污染: [0 1 2 100 4 5] right: [1 2 200]
cap 被卡在 2 之后,append 只能另开一块内存,base 从此安全。当你把一个子切片交给外部代码时,用三索引切片明确「这块就这么多」,是防御性编程的常规手段。
3.1.6 copy 与切片表达式
需要真正独立的一份数据时,用 copy:
src := []int{1, 2, 3}
dst := make([]int, len(src))
n := copy(dst, src)
fmt.Println("copy:", n, dst)
copy: 3 [1 2 3]
copy 返回实际复制的元素个数,取两者长度的较小值。目标切片必须先有足够长度——copy 不会替你扩容,这也是为什么上面写了 make([]int, len(src))。
切片表达式 s[low:high] 的规则要记牢:
| 表达式 | 结果 |
|---|---|
s[1:3] | 索引 1、2,len=2,cap = cap(s)-1 |
s[:2] | 等价 s[0:2] |
s[2:] | 从 2 到末尾 |
s[:] | 与 s 同 |
s[1:3:4] | 三索引,len=2,cap=3 |
省略的 low 默认 0,省略的 high 默认 len(s)。注意 high 的上界是 cap(s) 而不是 len(s)——s[0:cap(s)] 合法,能「看到」尚未使用的容量部分,这也是很多切片 bug 的来源。
3.1.7 用切片实现任务列表
把 slices 包(Go 1.21 起的标准库)接进来,排序与查找都能一行搞定:
package main
import (
"fmt"
"slices"
)
type Task struct {
ID int64
Title string
Done bool
}
func main() {
tasks := []Task{
{ID: 3, Title: "写第三章"},
{ID: 1, Title: "写第一章", Done: true},
{ID: 2, Title: "写第二章"},
}
slices.SortFunc(tasks, func(a, b Task) int {
return int(a.ID - b.ID)
})
fmt.Println("排序后:", tasks)
fmt.Println("含 ID=2:", slices.ContainsFunc(tasks, func(t Task) bool {
return t.ID == 2
}))
fmt.Println("ID=3 的下标:", slices.IndexFunc(tasks, func(t Task) bool {
return t.ID == 3
}))
fmt.Println("最大 ID:", slices.MaxFunc(tasks, func(a, b Task) int {
return int(a.ID - b.ID)
}).ID)
}
排序后: [{1 写第一章 true} {2 写第二章 false} {3 写第三章 false}]
含 ID=2: true
ID=3 的下标: 2
最大 ID: 3
SortFunc 的第二个参数是比较函数,返回负数表示 a < b、正数表示 a > b、0 表示相等。这里用 int(a.ID - b.ID) 是惯用写法,但对 int64 要小心溢出——更严谨的写法是:
slices.SortFunc(tasks, func(a, b Task) int {
return cmp.Compare(a.ID, b.ID)
})
cmp.Compare 会正确处理各种边界,是推荐做法。这几个函数共同的特点是把「比较逻辑」当参数传进去,切片本身不需要实现任何接口——第 9 章讲泛型时会看到它们背后的类型参数设计。
小结
- 数组长度是类型的一部分,
[3]int与[4]int不同型;数组是值类型,赋值即整体拷贝。 - 切片是「指向底层数组的指针 +
len+cap」的描述符,赋值只拷贝描述符,两个切片共享数据。 nil切片与空切片行为几乎一致,判空一律用len(s) == 0;只有序列化时二者才有可见差别。append在容量不足时重新分配并返回新切片,必须接住返回值;扩容曲线是实现细节,不可依赖。- 子切片与母切片共享底层数组,
append可能污染母切片;用三索引切片s[l:h:m]限制容量可避免。 copy返回实际复制个数,目标切片必须预先有足够长度。- 切片表达式的
high上界是cap(s)而非len(s)。 slices.SortFunc/ContainsFunc/IndexFunc用比较函数做排序查找,比较建议用cmp.Compare避免溢出。
有了有序列表,还缺一个「按 ID 直接命中」的能力——现在查一个任务得遍历整个切片。下一节 3.2 map 与集合惯用法
会加上 map[int64]Task 索引,把 O(n) 的查找降到 O(1)。想回看 Task 字段与函数是怎么定义的,见 2.3 函数、多返回值与 defer
。
阅读导航:上一节:2.3 函数、多返回值与 defer · 下一节:3.2 map 与集合惯用法 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。