13 KiB
Go 语言切片详解:从基础到实践
在 Go 语言中,切片(Slice) 是对数组一个连续片段的引用。它是 Go 中最核心、最灵活的数据结构,几乎在所有场景下都取代了数组。理解切片是掌握 Go 语言的关键一步,它深刻影响着程序的性能和内存管理。
1. 切片基础概念
切片是一个封装了底层数组信息的结构体,它本身不存储任何数据,只是一个“视图”或“窗口”。
核心特征
- ✅ 类型一致性:所有元素必须是相同类型。
- ✅ 动态长度:长度可变,可以随时通过
append等操作增删元素。 - ✅ 引用类型:赋值和传参时,传递的是切片头的浅拷贝,但它们指向同一个底层数组。修改其中一个切片的元素会影响到另一个。
- ✅ 包含三个核心组件:
- 指针 (Pointer):指向底层数组中切片指定开始位置的内存地址。
- 长度 (Length):切片中元素的数量,
len()函数获取。 - 容量 (Capacity):从切片开始位置到底层数组末尾的元素数量,
cap()函数获取。
⚠️ 注意:切片是否拥有自己的底层数组是不确定的。它可能与其它切片共享,也可能在扩容后获得一个新的底层数组。
2. 切片声明与初始化
示例 1:基本声明与初始化
package main
import "fmt"
func main() {
// 方式一:使用字面量直接初始化
s1 := []int{1, 2, 3, 4, 5}
fmt.Printf("s1: %v, len: %d, cap: %d\n", s1, len(s1), cap(s1))
// s1: [1 2 3 4 5], len: 5, cap: 5
// 方式二:使用 make 函数创建,可以指定长度和容量
// make([]T, length, capacity)
s2 := make([]int, 5, 10)
fmt.Printf("s2: %v, len: %d, cap: %d\n", s2, len(s2), cap(s2))
// s2: [0 0 0 0 0], len: 5, cap: 10
// 方式三:声明一个 nil 切片
var s3 []int
fmt.Printf("s3: %v, len: %d, cap: %d\n", s3, len(s3), cap(s3))
fmt.Println("s3 is nil?", s3 == nil) // true
// s3: [], len: 0, cap: 0
// s3 is nil? true
}
✅ nil 切片 vs 空切片:
var s []int是 nil 切片,s := []int{}或s := make([]int, 0)是空切片。它们len和cap都为 0,但nil切片不指向任何底层数组。在实践中,append、len、cap和range对它们的操作效果完全一样。
示例 2:从数组或切片创建(切片表达式)
这是创建切片最常见的方式,array[low:high:max]。
low:开始索引(包含)high:结束索引(不包含)max:设置容量(可选),cap = max - low
package main
import "fmt"
func main() {
arr := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
// s[low:high] -> len=high-low, cap=cap(arr)-low
s1 := arr[2:5]
fmt.Printf("s1: %v, len: %d, cap: %d\n", s1, len(s1), cap(s1))
// s1: [2 3 4], len: 3, cap: 8
// 省略 low,默认为 0
s2 := arr[:5]
fmt.Printf("s2: %v, len: %d, cap: %d\n", s2, len(s2), cap(s2))
// s2: [0 1 2 3 4], len: 5, cap: 10
// 省略 high,默认为 len(arr)
s3 := arr[5:]
fmt.Printf("s3: %v, len: %d, cap: %d\n", s3, len(s3), cap(s3))
// s3: [5 6 7 8 9], len: 5, cap: 5
// s[low:high:max] -> len=high-low, cap=max-low
s4 := arr[2:5:7] // 从 arr[2] 开始,长度为3,容量限制到 arr[7]
fmt.Printf("s4: %v, len: %d, cap: %d\n", s4, len(s4), cap(s4))
// s4: [2 3 4], len: 3, cap: 5
}
3. 切片操作详解
示例 3:访问、修改和遍历
切片的访问和遍历方式与数组完全相同。
package main
import "fmt"
func main() {
s := []string{"Go", "Java", "Python", "Rust"}
// 访问
fmt.Println("第一个元素:", s[0]) // Go
// 修改
s[1] = "C++"
fmt.Println("修改后:", s) // [Go C++ Python Rust]
// 遍历(与数组的三种方式一致)
for i, v := range s {
fmt.Printf("索引 %d -> 值 %s\n", i, v)
}
}
⚠️ 越界访问
s[i]同样会导致panic。
示例 4:追加元素 append(核心操作)
append 是切片最重要的函数,用于向切片末尾添加元素。
package main
import "fmt"
func main() {
s := make([]int, 0, 3) // len=0, cap=3
fmt.Printf("初始 -> len: %d, cap: %d, ptr: %p\n", len(s), cap(s), s)
// 1. 容量足够,不发生扩容
s = append(s, 1)
s = append(s, 2)
fmt.Printf("追加2个后 -> len: %d, cap: %d, ptr: %p\n", len(s), cap(s), s)
// 2. 容量不足,发生扩容
// Go 会分配一个更大的新数组,将旧数据复制过去,再添加新元素
s = append(s, 3) // 容量刚好用完
s = append(s, 4) // 触发扩容
fmt.Printf("触发扩容后 -> len: %d, cap: %d, ptr: %p\n", len(s), cap(s), s)
// 扩容策略:通常是翻倍(当元素较少时)或乘以1.25(当元素较多时)
// 输出中可以看到 ptr 地址发生了变化
// 追加另一个切片(使用 ...)
s = append(s, []int{5, 6, 7}...)
fmt.Printf("追加切片后 -> len: %d, cap: %d\n", len(s), cap(s))
}
示例 5:复制切片 copy
copy(dst, src) 函数用于将 src 切片中的元素复制到 dst 切片。它只复制两者长度的最小值个元素。
package main
import "fmt"
func main() {
src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)
n := copy(dst, src)
fmt.Println("复制的元素个数:", n) // 3
fmt.Println("目标切片 dst:", dst) // [1 2 3]
fmt.Println("源切片 src:", src) // [1 2 3 4 5] - 不受影响
// copy 创建了独立的副本,修改 dst 不会影响 src
dst[0] = 99
fmt.Println("修改后 dst:", dst) // [99 2 3]
fmt.Println("修改后 src:", src) // [1 2 3 4 5]
}
✅
copy是避免切片共享底层数组副作用的有效手段。
4. 多维切片详解
多维切片是“切片的切片”,与多维数组不同,其内部的切片长度可以不一致。
package main
import "fmt"
func main() {
// 创建一个 "锯齿" 切片
matrix := [][]int{
{1, 2},
{3, 4, 5, 6},
{7},
}
fmt.Println("多维切片内容:", matrix)
matrix[0][1] = 99 // 修改元素
fmt.Println("修改后:", matrix)
// 遍历
for i := range matrix {
fmt.Println("Row", i, ":", matrix[i])
}
}
5. 切片作为引用类型的特性
示例 6:赋值和传参共享底层数组
package main
import "fmt"
func modifySlice(s []string) {
s[0] = "MODIFIED"
fmt.Println("函数内 s:", s)
}
func main() {
original := []string{"a", "b", "c"}
fmt.Println("原始切片:", original)
// 赋值是浅拷贝
refCopy := original
refCopy[1] = "CHANGED"
fmt.Println("赋值后 original:", original) // 受影响
fmt.Println("赋值后 refCopy:", refCopy)
// 函数传参也是浅拷贝
modifySlice(original)
fmt.Println("函数外 original:", original) // 再次受影响
}
输出: 原始切片: [a b c] 赋值后 original: [a CHANGED c] 赋值后 refCopy: [a CHANGED c] 函数内 s: [MODIFIED CHANGED c] 函数外 original: [MODIFIED CHANGED c]
6. 切片的常见陷阱与注意事项
❌ 陷阱 1:append 可能改变原有切片
当函数内部的 append 没有触发扩容时,修改会反映到外部;一旦触发扩容(返回了新的底层数组),修改就不会反映到外部。
package main
import "fmt"
func appendTrap(s []int) {
// 此时 s 的 len=3, cap=5。append 不会触发扩容
s = append(s, 100)
fmt.Println("函数内 s:", s) // [0 1 2 100]
}
func main() {
original := make([]int, 3, 5) // [0,0,0], len=3, cap=5
fmt.Println("调用前 original:", original) // [0 0 0]
appendTrap(original)
// original 的长度没变,但底层数组的数据被修改了!
fmt.Println("调用后 original:", original) // [0 0 0] - len 没变
// 查看底层数组的变化
fmt.Println("底层数组情况:", original[:cap(original)]) // [0 0 0 100 0]
}
✅ 正确做法:函数如果修改了切片(特别是通过 append),永远应该返回新的切片。
func appendSafe(s []int) []int {
return append(s, 100)
}
// 调用: original = appendSafe(original)
❌ 陷阱 2:子切片可能导致内存泄漏
如果你从一个非常大的切片中,只截取一小段并长期持有,那么这个大切片的底层数组将永远不会被垃圾回收(GC)。
func getFirstTwo(largeSlice []byte) []byte {
// 错误做法:返回的切片与原大切片共享底层数组
return largeSlice[:2]
// 即使 largeSlice 本身被回收,只要返回的切片还在,整个大数组就在内存中
}
// ✅ 正确做法:使用 copy 创建一个独立的小切片
func getFirstTwoSafe(largeSlice []byte) []byte {
smallSlice := make([]byte, 2)
copy(smallSlice, largeSlice)
return smallSlice
}
❌ 陷阱 3:不能作为 map 的 key
与可比较的数组不同,切片是不可比较类型(因为它包含指针,且内容可变),因此不能作为 map 的键。
package main
func main() {
// m := make(map[[]int]string) // 编译错误: invalid map key type []int
}
✅ 替代方案:可以将切片转换为字符串 string(slice) 作为 key,或者使用可比较的数组作为 key。
7. 最佳实践与性能建议
| 实践 | 说明 |
|---|---|
| ✅ 优先使用切片 | 除非你需要数组的特性(如 map key),否则总是使用切片。 |
| ✅ 预估容量 | 在创建切片时,如果能预估最终大小,使用 make([]T, 0, capacity) 来预分配容量,可极大减少 append 带来的扩容和复制开销。 |
✅ append 后重新赋值 |
任何调用 append 的地方,都应将结果赋值回原切片:s = append(s, …)。 |
✅ copy 避免副作用 |
当需要一个完全独立的切片,或防止内存泄漏时,使用 copy。 |
| ✅ 警惕子切片共享数据 | 修改子切片可能会意外改变父切片或其他子切片的数据。 |
8. 典型应用场景
📌 场景 1:动态集合
任何需要动态增删元素的列表场景,如读取文件行、处理 HTTP 请求参数、解析 JSON 数组等。
// 模拟从数据库读取不定数量的用户 ID
var userIDs []int
// for rows.Next() {
// var id int
// ...
// userIDs = append(userIDs, id)
// }
📌 场景 2:函数参数和返回值
作为函数参数传递可变数据集是切片最常见的用途,比传递数组指针更灵活、更安全。
func ProcessItems(items []string) []string {
// ... 对 items 进行处理
return items
}
📌 场景 3:缓冲区(Buffer)
在 I/O 操作中,切片被广泛用作读写缓冲区。
// import "os"
// f, _ := os.Open("file.txt")
// buf := make([]byte, 1024)
// n, _ := f.Read(buf)
// data := buf[:n] // data 是实际读取到的有效数据
9. 扩容
切片扩容策略
1. 计算 newLen = oldLen + 新增数量
2. 调用 nextslicecap(newLen, oldCap) → 得到 newcap
├─ if newLen > oldCap*2 → 直接返回 newLen
├─ if oldCap < 256 → 返回 oldCap * 2
└─ else → 循环 newcap += (newcap + 768) / 4,直到 ≥ newLen
3. 计算所需内存:capmem = newcap * elemSize
4. 调用 roundupsize → 对齐到 mspan size class
5. 调整 newcap 以匹配对齐后的内存
6. mallocgc 分配新内存
7. memmove 复制旧数据 [0, oldLen)
8. 返回新切片 {新地址, newLen, newcap}
扩容机制细节
1. 是否一定扩容?
不一定。扩容的条件是 len == cap。如果还有剩余容量,append 操作只是修改 len,不扩容。
s := make([]int, 5, 10) // len=5, cap=10
s = append(s, 1, 2, 3) // len=8, cap=10,未扩容
2. 扩容一定是新内存吗?
是的。 当触发扩容时,Go 运行时会:
- 分配一块全新的、更大的底层数组
- 将原数组数据复制到新数组
- 返回新的切片(指向新数组)
原有的切片引用失效,但如果有其他变量引用原底层数组,原数据不会立即被回收(除非无引用)。
a := []int{1, 2, 3}
b := a // b 和 a 共享底层数组
a = append(a, 4) // a 扩容 → 底层数组复制,a 和 b 不再共享
3. 内存对齐与类型影响
扩容计算还会考虑:
- 元素大小(如
int、struct{}) - 内存对齐规则
- 分配器策略(如
runtime.growSlice)
因此实际扩容大小可能略大于 1.25 * cap,以便按对齐要求分配内存块。
总结
| 关键点 | 说明 |
|---|---|
| 🧩 切片是数组的“视图” | 它是一个包含指针、长度和容量的结构 |
| 🔗 引用类型(行为上) | 赋值和传参共享底层数组,修改会相互影响 |
| 🚀 动态扩容 | append 是核心,当容量不足时会自动扩容(有性能开销) |
| ⚠️ 充满陷阱 | 需注意 append 的返回值、子切片内存泄漏和共享数据问题 |
| 🏆 Go主力数据结构 | 在日常开发中全面取代数组 |
✅ 推荐口诀: 切片是数组的窗,指针长度加容量。Append可能要搬家,函数返回新切片。共享内存要当心,拷贝数据保平安。