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

13 KiB
Raw Permalink Blame History

Go 语言切片详解:从基础到实践

在 Go 语言中,切片(Slice 是对数组一个连续片段的引用。它是 Go 中最核心、最灵活的数据结构,几乎在所有场景下都取代了数组。理解切片是掌握 Go 语言的关键一步,它深刻影响着程序的性能和内存管理。


1. 切片基础概念

切片是一个封装了底层数组信息的结构体,它本身不存储任何数据,只是一个“视图”或“窗口”。

核心特征

  • 类型一致性:所有元素必须是相同类型。
  • 动态长度:长度可变,可以随时通过 append 等操作增删元素。
  • 引用类型:赋值和传参时,传递的是切片头的浅拷贝,但它们指向同一个底层数组。修改其中一个切片的元素会影响到另一个。
  • 包含三个核心组件
    1. 指针 (Pointer):指向底层数组中切片指定开始位置的内存地址。
    2. 长度 (Length):切片中元素的数量,len() 函数获取。
    3. 容量 (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) 是空切片。它们 lencap 都为 0,但 nil 切片不指向任何底层数组。在实践中,appendlencaprange 对它们的操作效果完全一样。


示例 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. 切片的常见陷阱与注意事项

陷阱 1append 可能改变原有切片

当函数内部的 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. 内存对齐与类型影响

扩容计算还会考虑:

  • 元素大小(如 intstruct{}
  • 内存对齐规则
  • 分配器策略(如 runtime.growSlice

因此实际扩容大小可能略大于 1.25 * cap,以便按对齐要求分配内存块。


总结

关键点 说明
🧩 切片是数组的“视图” 它是一个包含指针、长度和容量的结构
🔗 引用类型(行为上) 赋值和传参共享底层数组,修改会相互影响
🚀 动态扩容 append 是核心,当容量不足时会自动扩容(有性能开销)
⚠️ 充满陷阱 需注意 append 的返回值、子切片内存泄漏和共享数据问题
🏆 Go主力数据结构 在日常开发中全面取代数组

推荐口诀: 切片是数组的窗,指针长度加容量。Append可能要搬家,函数返回新切片。共享内存要当心,拷贝数据保平安。