Go Slice 详解
一、Slice 是什么
Slice(切片)是 Go 中对数组的轻量级抽象 ,本质是一个引用类型的描述符,包含三个字段:
┌─────────────┬──────┬──────┐
│ ptr │ len │ cap │
│ *array │ int │ int │
└─────────────┴──────┴──────┘
- ptr --- 指向底层数组的指针
- len --- 当前元素个数(可读写的范围)
- cap --- 底层数组从 ptr 开始到末尾的容量
go
s := make([]int, 3, 5)
// ptr -> [0, 0, 0, _, _]
// len = 3, cap = 5
二、创建方式
go
// 1. 字面量
s1 := []int{1, 2, 3}
// 2. make
s2 := make([]int, 3) // len=3, cap=3, 零值
s3 := make([]int, 3, 10) // len=3, cap=10
// 3. 从数组/切片切取
arr := [5]int{0, 1, 2, 3, 4}
s4 := arr[1:3] // [1, 2], len=2, cap=4 (从index1到数组末尾)
// 4. new --- 不推荐,得到的是 *[]int,且 len/cap 都为 0
s5 := new([]int)
三、扩容机制(核心原理)
当 append 导致 len > cap 时,Go 会分配更大的底层数组并拷贝数据:
go
s := make([]int, 0, 2)
s = append(s, 1) // [1], cap=2
s = append(s, 2) // [1,2], cap=2 --- 刚好满
s = append(s, 3) // [1,2,3], cap=4 --- 触发扩容,ptr 已变!
扩容策略(Go 1.18+)
newcap = oldcap
如果 oldcap < 256:
newcap += newcap (翻倍)
如果 oldcap >= 256:
newcap += newcap * 3/4 (每次增长 25%)
// 再考虑元素大小和内存对齐做微调
关键点:扩容后 ptr 指向新数组,旧数组等待 GC。
四、常见操作
增删改查
go
// 追加
s = append(s, 4)
// 批量追加
s = append(s, []int{5, 6, 7}...)
// 插入到索引 i
s = append(s[:i], append([]int{x}, s[i:]...)...)
//插入可以看下面三种方式
// 方法 1:三行展开,逻辑清晰
func insert(s []int, i int, x int) []int {
tail := append([]int{x}, s[i:]...) // [x, 原i及之后的所有元素]
return append(s[:i], tail...) // [原i之前, x, 原i及之后]
}
// 方法 2:append + copy(Go Wiki 推荐,最清晰)
func insert(s []int, i int, x int) []int {
s = append(s, 0) // 先扩一个位置
copy(s[i+1:], s[i:]) // 把 i 及之后的内容整体右移一位
s[i] = x // 在 i 位置放入 x
return s
}
// 方法 3:Go 1.21+ slices 包
import "slices"
s = slices.Insert(s, i, x) // 直接用标准库
// 删除索引 i
s = append(s[:i], s[i+1:]...)
// 删除范围 [i, j)
s = append(s[:i], s[j:]...)
拷贝
go
dst := make([]int, len(src))
copy(dst, src) // 内置函数,处理了重叠内存
copy 返回实际拷贝的元素数 = min(len(dst), len(src))
如果 dst 比 src 短,只拷贝前面部分,不会 panic
copy 内部自动处理了重叠内存(类似 C 的 memmove,不是 memcpy),所以同一个 slice 内拷贝是安全的
为什么安全:memmove 会先判断重叠方向,必要时从后往前拷贝,避免数据还没读就被覆盖。
copy 是 Go 里做 slice 深拷贝的唯一内置方式,等价于 memmove(处理重叠),拷贝数量 = min(len(dst), len(src)),类型必须严格匹配。
和赋值的区别
a := s // 只拷贝 slice 头部(24 字节),共享底层数组
b := make([]int, len(s))
copy(b, s) // 深拷贝,独立的底层数组
// 截断(原地缩容,不改变 cap)
s = s[:n]
只是改了 len,cap 不变,底层数组不变
被截掉的部分还在内存中(cap 保留),不会立即释放
指针不动,只是 header 的 len 字段变小了
切片表达式
go
a := [5]int{0, 1, 2, 3, 4}
s1 := a[1:3] // [1, 2], len=2, cap=4
s2 := a[1:3:3] // [1, 2], len=2, cap=2 ← 3-index slice
3-index slice a[low:high:max] --- 显式限制 cap,防止通过 append 意外修改原数组后续元素。Go 1.2+ 支持。
五、经典陷阱
1. 共享底层数组
go
a := []int{1, 2, 3, 4, 5}
b := a[1:3] // b -> [2, 3]
b[0] = 99
fmt.Println(a) // [1, 99, 3, 4, 5] --- a 也变了!
2. append 未捕获返回值
go
s := []int{1, 2, 3}
append(s, 4) // ❌ 丢弃返回值,如果触发扩容则完全无效
s = append(s, 4) // ✅ 必须接收
3. 切片作函数参数 --- 修改可见
go
func modify(s []int) {
s[0] = 999 // 影响原切片
s = append(s, 4) // 如果扩容了,外部看不到
}
4. 大切片导致内存泄漏
go
// 从大切片取一小段,底层数组不会被回收
var big = make([]byte, 1<<30)
small := big[:100]
// 解决:手动拷贝
small := make([]byte, 100)
copy(small, big[:100])
六、Slice vs Array 对比
| Array | Slice | |
|---|---|---|
| 类型签名 | [3]int --- 长度是类型的一部分 |
[]int --- 长度不固定 |
| 值语义 | 拷贝整个数组 | 拷贝的是描述符(24字节) |
可否 == 比较 |
可以(同类型同长度) | 不可以 ,只能和 nil 比 |
| 零值 | 元素零值 | nil(len=0, cap=0, ptr=nil) |
| 长度 | 编译期确定 | 运行时动态 |
七、最佳实践
- 预知大小时用
make([]T, 0, n)--- 避免多次扩容 - 需要传值语义时用
copy,不要依赖切片共享 var s []intvss := []int{}--- 前者是nil,后者是空切片(非 nil),json.Marshal行为不同:nil→null,[]→[]- 字符串转
[]byte会拷贝,频繁操作用unsafe.String或strings.Builder - 3-index slice 在从数组切取子切片时优先使用,防止意外覆盖