Files
notes/resource/go/Go 语言的切片(slice).md
T
2026-03-01 01:43:46 +08:00

466 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Go 语言切片详解:从基础到实践
在 Go 语言中,**切片(Slice)** 是对数组一个连续片段的引用。它是 Go 中最核心、最灵活的数据结构,几乎在所有场景下都取代了数组。理解切片是掌握 Go 语言的关键一步,它深刻影响着程序的性能和内存管理。
---
## 1. 切片基础概念
切片是一个**封装了底层数组信息的结构体**,它本身不存储任何数据,只是一个“视图”或“窗口”。
### 核心特征
-**类型一致性**:所有元素必须是相同类型。
-**动态长度**:长度可变,可以随时通过 `append` 等操作增删元素。
-**引用类型**:赋值和传参时,传递的是切片头的**浅拷贝**,但它们指向**同一个底层数组**。修改其中一个切片的元素会影响到另一个。
-**包含三个核心组件**
1. **指针 (Pointer)**:指向底层数组中切片指定开始位置的内存地址。
2. **长度 (Length)**:切片中元素的数量,`len()` 函数获取。
3. **容量 (Capacity)**:从切片开始位置到底层数组末尾的元素数量,`cap()` 函数获取。
> ⚠️ 注意:切片是否拥有自己的底层数组是不确定的。它可能与其它切片共享,也可能在扩容后获得一个新的底层数组。
---
## 2. 切片声明与初始化
### 示例 1:基本声明与初始化
```go
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`
```go
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:访问、修改和遍历
切片的访问和遍历方式与数组完全相同。
```go
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` 是切片最重要的函数,用于向切片末尾添加元素。
```go
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` 切片。它只复制**两者长度的最小值**个元素。
```go
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. 多维切片详解
多维切片是“切片的切片”,与多维数组不同,其内部的切片长度可以**不一致**。
```go
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:赋值和传参共享底层数组
```go
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` **没有**触发扩容时,修改会反映到外部;一旦**触发扩容**(返回了新的底层数组),修改就不会反映到外部。
```go
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`),**永远应该返回新的切片**。
```go
func appendSafe(s []int) []int {
return append(s, 100)
}
// 调用: original = appendSafe(original)
```
---
### ❌ 陷阱 2:子切片可能导致内存泄漏
如果你从一个非常大的切片中,只截取一小段并长期持有,那么这个大切片的底层数组将**永远不会被垃圾回收(GC)**。
```go
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` 的键。
```go
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 数组等。
```go
// 模拟从数据库读取不定数量的用户 ID
var userIDs []int
// for rows.Next() {
// var id int
// ...
// userIDs = append(userIDs, id)
// }
```
### 📌 场景 2:函数参数和返回值
作为函数参数传递可变数据集是切片最常见的用途,比传递数组指针更灵活、更安全。
```go
func ProcessItems(items []string) []string {
// ... 对 items 进行处理
return items
}
```
### 📌 场景 3:缓冲区(Buffer
在 I/O 操作中,切片被广泛用作读写缓冲区。
```go
// 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`,不扩容。
```go
s := make([]int, 5, 10) // len=5, cap=10
s = append(s, 1, 2, 3) // len=8, cap=10,未扩容
```
#### 2. 扩容一定是新内存吗?
**是的。** 当触发扩容时,Go 运行时会:
- 分配一块**全新的、更大的底层数组**
- 将原数组数据**复制**到新数组
- 返回新的切片(指向新数组)
原有的切片引用失效,但如果有其他变量引用原底层数组,原数据不会立即被回收(除非无引用)。
```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可能要搬家,函数返回新切片。共享内存要当心,拷贝数据保平安。