切片(Slice)
切片是 Go 语言中使用频率最高的数据结构。它建立在数组之上,提供了动态长度、自动扩容的能力,是处理序列数据的首选方式。
切片的本质
切片是对数组一个连续片段的引用。它本身并不存储数据,而是描述了底层数组的一段区间。切片由三个部分组成:指向底层数组的指针、长度(len)和容量(cap)。
// 底层数组
array := [5]int{10, 20, 30, 40, 50}
// 切片引用底层数组的一部分
slice := array[1:4] // 索引 1(包含)到 4(不包含)
fmt.Println(slice) // [20 30 40]
fmt.Println(len(slice)) // 3
fmt.Println(cap(slice)) // 4 —— 从 slice 的起始位置到底层数组末尾的长度💡 核心理解:切片不是数组。数组是值类型,长度固定;切片是引用类型,长度可变。切片可以看作是对底层数组的一个”窗口”,通过移动这个窗口来实现灵活的序列操作。
内存布局
一个切片在内存中由三个字段组成,共 24 字节(64 位系统):
┌──────────────┬──────────┬──────────┐
│ 指针 ptr │ 长度 len │ 容量 cap │
│ 8 字节 │ 8 字节 │ 8 字节 │
└──────┬───────┴──────────┴──────────┘
│
▼
┌───┬───┬───┬───┬───┐
│ 10│ 20│ 30│ 40│ 50│ ← 底层数组
└───┴───┴───┴───┴───┘
↑ ↑
ptr ptr+cap
│← len →│←── cap ──→│- ptr:指向底层数组中切片起始元素的指针
- len:切片当前包含的元素数量(
len(slice)) - cap:从切片起始位置到底层数组末尾的元素数量(
cap(slice))
arr := [5]int{10, 20, 30, 40, 50}
s := arr[1:4]
fmt.Printf("ptr: %p\n", &s[0]) // 指向 arr[1]
fmt.Printf("len: %d\n", len(s)) // 3
fmt.Printf("cap: %d\n", cap(s)) // 4(arr[1] 到 arr[4],共 4 个元素)创建切片
方式一:使用 make 函数
make 是创建切片最常用的方式,它会在内存中分配一个底层数组并返回切片引用。
// make([]T, length, capacity)
s1 := make([]int, 5) // 长度 5,容量 5
s2 := make([]int, 3, 10) // 长度 3,容量 10
s3 := make([]string, 0, 5) // 长度 0,容量 5(常用于 append 场景)
fmt.Println(s1) // [0 0 0 0 0]
fmt.Println(len(s1)) // 5
fmt.Println(cap(s1)) // 5
fmt.Println(s2) // [0 0 0]
fmt.Println(len(s2)) // 3
fmt.Println(cap(s2)) // 10
fmt.Println(s3) // []
fmt.Println(len(s3)) // 0
fmt.Println(cap(s3)) // 5⚠️ 注意:make([]int, 5) 创建的切片长度为 5,所有元素被初始化为零值 0。如果只是想预留空间给 append 使用,应使用 make([]int, 0, 5) 创建一个长度为 0、容量为 5 的切片。
方式二:使用字面量
// 完整声明
var s1 []int = []int{1, 2, 3, 4, 5}
// 短变量声明
s2 := []string{"Go", "is", "awesome"}
s3 := []float64{3.14, 2.72, 1.41}
// 空切片
s4 := []int{} // len: 0, cap: 0
var s5 []int // nil 切片,len: 0, cap: 0
fmt.Println(s1) // [1 2 3 4 5]
fmt.Println(s2) // [Go is awesome]- nil 切片:
var s []int,声明但未初始化,指针为nil,len 和 cap 均为 0。可以append,但 JSON 序列化时输出null。 - 空切片:
s := []int{}或make([]int, 0),指针非nil,len 和 cap 为 0。JSON 序列化时输出[]。
方式三:从数组切分
arr := [8]int{0, 1, 2, 3, 4, 5, 6, 7}
// 基本语法:arr[low:high],low 包含,high 不包含
s1 := arr[2:5] // [2 3 4],len=3, cap=6(从索引 2 到数组末尾)
s2 := arr[:4] // [0 1 2 3],从开头到索引 4(不包含)
s3 := arr[4:] // [4 5 6 7],从索引 4 到末尾
s4 := arr[:] // [0 1 2 3 4 5 6 7],引用整个数组
fmt.Println(s1) // [2 3 4]
fmt.Println(s2) // [0 1 2 3]
fmt.Println(s3) // [4 5 6 7]
fmt.Println(s4) // [0 1 2 3 4 5 6 7]
// 三索引切分:arr[low:high:max],max 控制容量
s5 := arr[2:5:5] // [2 3 4],len=3, cap=3
// 容量 = max - low = 5 - 2 = 3💡 三索引切分:arr[low:high:max] 是 Go 1.2 引入的语法,其中 max 用于限制切片的容量为 max - low。这在需要防止 append 操作覆盖底层数组后续元素的场景下非常有用。
len 与 cap
理解 len 和 cap 的区别是掌握切片的关键。
arr := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
s := arr[3:7]
fmt.Println("切片:", s) // [3 4 5 6]
fmt.Println("len:", len(s)) // 4 —— 切片当前包含的元素个数
fmt.Println("cap:", cap(s)) // 7 —— 从切片起始到底层数组末尾
// len 决定了可以访问的范围:s[0] 到 s[len(s)-1]
// cap 决定了可以 append 的空间:当前底层数组中还能追加多少元素- len:切片当前有多少个元素,即
s[i]中i的有效范围是[0, len(s))。访问超出len的索引会导致 panic。 - cap:从切片起始位置到底层数组末尾的元素总数。当
len == cap时再append,会触发扩容。
s := make([]int, 3, 5) // len=3, cap=5
fmt.Println(s) // [0 0 0]
// 在 cap 范围内 append,不触发扩容
s = append(s, 100) // len=4, cap=5,底层数组不变
s = append(s, 200) // len=5, cap=5,底层数组已满
// 超出 cap 范围 append,触发扩容
s = append(s, 300) // len=6, cap=?(新分配更大的底层数组)append 与扩容机制
基本用法
append 用于向切片追加元素,返回新的切片。它可能原地扩展,也可能分配新的底层数组。
s := []int{1, 2, 3}
// 追加单个元素
s = append(s, 4) // [1 2 3 4]
// 追加多个元素
s = append(s, 5, 6, 7) // [1 2 3 4 5 6 7]
// 追加另一个切片(使用 ... 展开)
t := []int{100, 200}
s = append(s, t...) // [1 2 3 4 5 6 7 100 200]⚠️ 必须接收 append 的返回值:append 可能返回一个指向新底层数组的切片。如果忽略返回值,可能会导致数据丢失或 bug。始终写 s = append(s, x),而不是 append(s, x)。
Go 1.18+ 扩容策略
当切片容量不足时,Go 会分配一个更大的底层数组并将元素复制过去。Go 1.18 之后的扩容策略如下:
// 简化版的 Go 1.18+ 扩容策略(伪代码)
func growslice(oldCap, newLen int) int {
newCap := oldCap
doubleCap := newCap + newCap
if newLen > doubleCap {
newCap = newLen
} else {
if oldCap < 256 {
newCap = doubleCap
} else {
// 每次增加约 25%(持续到 newCap >= newLen)
for newCap < newLen {
newCap += (newCap + 3*256) / 4
}
}
}
return newCap // 实际还涉及内存对齐
}- 当期望容量
newLen大于当前容量的 2 倍时,直接使用newLen作为新容量 - 当当前容量小于 256 时,新容量翻倍
- 当当前容量大于等于 256 时,每次大约增加 25%,直到满足所需容量
- 最终容量会进行内存对齐,实际分配可能略大于计算值
s := make([]int, 0)
fmt.Printf("len=%d, cap=%d\n", len(s), cap(s)) // len=0, cap=0
for i := 1; i <= 20; i++ {
s = append(s, i)
if i <= 1 || i == 256 || cap(s) != cap([]int{}) {
// 打印容量变化
}
}
// 典型的扩容序列(实际值可能因系统而异):
// cap: 0 → 1 → 2 → 4 → 8 → 16 → 32 → ...
// 小于 256 时每次翻倍
// 大于等于 256 时每次增长约 25%批量预分配
在实际开发中,如果已知元素数量,应预分配足够容量以避免多次扩容:
// ❌ 不推荐:多次扩容
var s []int
for i := 0; i < 10000; i++ {
s = append(s, i) // 可能触发十几次扩容和内存拷贝
}
// ✅ 推荐:预分配容量
s := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
s = append(s, i) // 零扩容
}切片截取操作
切片支持灵活的截取(Reslicing)操作,可以在已有切片上创建新的切片视图:
arr := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
s := arr[2:8] // [2 3 4 5 6 7]
// 对切片再截取
a := s[1:4] // [3 4 5]
b := s[:3] // [2 3 4]
c := s[3:] // [5 6 7]
d := s[2:5:6] // [4 5 6],len=3, cap=4
fmt.Println(a) // [3 4 5]
fmt.Println(b) // [2 3 4]
fmt.Println(c) // [5 6 7]
fmt.Println(d) // [4 5 6]
fmt.Println(cap(d)) // 4🔒 重要:截取操作共享底层数组。修改新切片的元素会影响原切片和底层数组中对应的元素。截取时,新的 cap 不能超过原切片的 cap。
original := []int{1, 2, 3, 4, 5, 6, 7, 8}
sub := original[2:5] // [3 4 5]
// 修改子切片会影响原切片
sub[0] = 300
fmt.Println(original) // [1 2 300 4 5 6 7 8]
fmt.Println(sub) // [300 4 5]删除元素
Go 没有内置的删除切片元素的函数,但可以通过截取组合实现:
// 删除指定索引的元素
func removeAt(s []int, index int) []int {
return append(s[:index], s[index+1:]...)
}
s := []int{1, 2, 3, 4, 5}
s = removeAt(s, 2) // 删除索引 2 的元素
fmt.Println(s) // [1 2 4 5]
// 删除第一个元素
s = s[1:]
// 删除最后一个元素
s = s[:len(s)-1]⚠️ 内存泄漏陷阱:使用 append(s[:index], s[index+1:]...) 删除元素时,由于 s[:index] 和 s[index+1:] 共享底层数组,被”删除”的元素实际上仍然在底层数组中,只是切片不再引用它。如果该元素是一个包含指针的对象(如结构体指针),则可能造成内存泄漏。解决方法是将其置为 nil。
// 防止内存泄漏的安全删除
func removeAtSafe(s []*int, index int) []*int {
// 先将待删除元素置为 nil
s[index] = nil
// 再截取
return append(s[:index], s[index+1:]...)
}切片拷贝
使用内置的 copy 函数可以在两个切片之间复制元素:
src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3) // 长度为 3
// copy 会复制 min(len(dst), len(src)) 个元素
n := copy(dst, src)
fmt.Println(dst) // [1 2 3]
fmt.Println(n) // 3 —— 实际复制的元素个数
// copy 是深拷贝,修改 dst 不影响 src
dst[0] = 999
fmt.Println(src) // [1 2 3 4 5] —— 未受影响// 常用场景:从大切片中提取子集
data := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
result := make([]int, 5)
copy(result, data[2:7])
fmt.Println(result) // [3 4 5 6 7]切片作为函数参数
切片是引用类型,传递给函数时复制的是切片头(指针、len、cap),而不是整个底层数组。因此函数内对切片元素的修改会影响原切片,但如果触发了扩容,则不会。
func modifySlice(s []int) {
s[0] = 999 // 修改底层数组元素,影响原切片 ✅
s = append(s, 100) // 如果没有触发扩容,追加的元素在原底层数组上可见
}
func main() {
s := make([]int, 3, 10)
s[0] = 1
modifySlice(s)
fmt.Println(s) // [999 0 0](s[0] 被修改,append 的结果可能丢失)
}func appendSlice(s []int) {
// 如果 append 触发扩容,函数内的 s 会指向新的底层数组
// 但外部的 s 仍然指向旧底层数组
s = append(s, 100, 200, 200, 200, 200, 200, 200, 200, 200, 200)
fmt.Println("函数内 len:", len(s), "cap:", cap(s))
}
func main() {
s := make([]int, 3, 5)
appendSlice(s)
fmt.Println("函数外 len:", len(s), "cap:", cap(s))
// 函数外 len: 3, cap: 5 —— 未受 append 影响
}💡 最佳实践:如果函数需要通过 append 修改切片并让调用者看到变化,应以切片指针作为参数,或将修改后的切片作为返回值传回。
// 方式一:返回新切片(推荐)
func addElement(s []int, elem int) []int {
return append(s, elem)
}
// 方式二:传入切片指针
func addElementPtr(s *[]int, elem int) {
*s = append(*s, elem)
}常见陷阱
陷阱一:循环中的切片追加变量引用
// ❌ 错误示例
var funcs []func()
for _, v := range []int{1, 2, 3} {
funcs = append(funcs, func() {
fmt.Println(v) // 闭包捕获的是变量 v 的地址
})
}
for _, f := range funcs {
f() // 输出:3, 3, 3
}
// ✅ 正确做法:在循环体内创建局部变量
var funcs []func()
for _, v := range []int{1, 2, 3} {
v := v // 创建新的局部变量
funcs = append(funcs, func() {
fmt.Println(v)
})
}
for _, f := range funcs {
f() // 输出:1, 2, 3
}陷阱二:容量泄漏
// 从大数组创建小切片后,小切片持有对大数组的引用
// 导致大数组无法被垃圾回收
func getFirstN(data []byte, n int) []byte {
return data[:n] // 危险:返回的切片引用整个 data 的底层数组
}
// ✅ 安全做法:拷贝数据到新切片
func getFirstNSafe(data []byte, n int) []byte {
result := make([]byte, n)
copy(result, data[:n])
return result // result 的底层数组大小正好是 n
}陷阱三:切片比较
切片不能直接使用 == 比较(只能与 nil 比较)。比较两个切片是否相等需要逐元素比较或使用 reflect.DeepEqual。
s1 := []int{1, 2, 3}
s2 := []int{1, 2, 3}
// s1 == s2 // 编译错误:slice can only be compared to nil
fmt.Println(s1 == nil) // false —— 切片只能与 nil 比较
// 正确的比较方式
import "reflect"
fmt.Println(reflect.DeepEqual(s1, s2)) // true
// 或手动比较
func equal(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}陷阱四:字符串与 []byte 的转换
s := "hello"
b := []byte(s) // 创建了底层数组的拷贝
b[0] = 'H'
fmt.Println(s) // "hello" —— 字符串不可变,不受影响
// Go 1.20+ 提供了 unsafe 方式避免拷贝(仅用于性能敏感场景)练习题
练习 1:切片基本操作
编写一个函数,接收一个 []int 切片,返回一个新切片,其中只包含原切片中的偶数元素。
package main
import "fmt"
func filterEven(nums []int) []int {
result := make([]int, 0, len(nums))
for _, v := range nums {
if v%2 == 0 {
result = append(result, v)
}
}
return result
}
func main() {
nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
evens := filterEven(nums)
fmt.Println(evens) // [2 4 6 8 10]
}练习 2:切片去重
编写一个函数,接收一个 []int 切片,返回一个去重后的新切片(保持原有顺序)。
package main
import "fmt"
func unique(nums []int) []int {
seen := make(map[int]bool)
result := make([]int, 0, len(nums))
for _, v := range nums {
if !seen[v] {
seen[v] = true
result = append(result, v)
}
}
return result
}
func main() {
nums := []int{3, 1, 4, 1, 5, 9, 2, 6, 5, 3}
result := unique(nums)
fmt.Println(result) // [3 1 4 5 9 2 6]
}练习 3:理解扩容
编写一个程序,展示切片从空切片开始,依次 append 元素时 len 和 cap 的变化情况。观察在什么位置发生扩容。
package main
import "fmt"
func main() {
var s []int
oldCap := 0
for i := 0; i < 20; i++ {
s = append(s, i)
if cap(s) != oldCap {
fmt.Printf("len=%2d, cap=%2d (扩容! 旧 cap=%2d)\n", len(s), cap(s), oldCap)
oldCap = cap(s)
}
}
// 典型输出(可能因 Go 版本和系统而异):
// len= 1, cap= 1 (扩容! 旧 cap= 0)
// len= 2, cap= 2 (扩容! 旧 cap= 1)
// len= 3, cap= 4 (扩容! 旧 cap= 2)
// len= 5, cap= 8 (扩容! 旧 cap= 4)
// len= 9, cap=16 (扩容! 旧 cap= 8)
// len=17, cap=32 (扩容! 旧 cap=16)
}