《Go 语言编程入门》3.1 数组、切片与扩容

本节让 TaskAPI 拥有真正的任务列表:先用数组与切片的语义差异(值类型 vs 描述符)立住概念,再拆解 len/cap 与 append 的扩容实测数据,重点讲清共享底层数组导致的数据污染与三索引切片的解法,最后用 slices 包给 []Task 排序、查找并组装出内存版存储。

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 与集合惯用法 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练