前言
切片(Slice)是Go语言中最常用的数据结构之一,它提供了动态数组的功能。然而,切片的底层实现和Java的ArrayList或Python的list有本质不同。理解切片的底层原理对于写出高效、正确的Go代码至关重要。本文将通过图解和代码示例,深入剖析切片的工作机制。
一、切片的本质
1.1 切片 = 切片头 + 底层数组
切片的底层数据结构包含三个字段:
type slice struct {
array unsafe.Pointer // 指向底层数组的指针
len int // 长度:实际元素个数
cap int // 容量:底层数组的总容量
}
这意味着切片本身只占用24字节(64位系统上,指针8字节 + 两个int各8字节)。
package main
import (
"fmt"
"unsafe"
)
func main() {
s := make([]int, 3, 10)
fmt.Printf("slice大小: %d bytes\n", unsafe.Sizeof(s))
fmt.Printf("长度: %d, 容量: %d\n", len(s), cap(s))
fmt.Printf("切片头地址: %p\n", s)
}
1.2 图解切片结构
切片结构:
┌─────────────────────────────────┐
│ slice struct │
├─────────────────────────────────┤
│ array ──────┐ │
│ len = 3 │ │
│ cap = 10 │ │
└─────────────┼──────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 底层数组(capacity = 10) │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ 0 │ 0 │ 0 │ │ │ │ │ ... │
├─────┴─────┴─────┼─────┴─────┴─────┴─────┤
▲ ▲
│ │
│ len=3 │
│ (可见区域) │
└────────────────┘
二、切片的创建方式
2.1 make 函数创建
// 方式1:指定长度和容量
s1 := make([]int, 5) // len=5, cap=5, 元素为零值
s2 := make([]int, 5, 10) // len=5, cap=10
// 方式2:仅指定容量
s3 := make([]int, 0, 10) // len=0, cap=10
// 方式3:字面量初始化
s4 := []int{1, 2, 3, 4, 5}
// 方式4:从数组或切片创建
arr := [5]int{1, 2, 3, 4, 5}
s5 := arr[1:4] // 切片:[2, 3, 4],len=3, cap=4
2.2 nil 切片 vs 空切片
var s1 []int // nil切片:array=nil, len=0, cap=0
s2 := []int{} // 空切片:array指向空数组, len=0, cap=0
s3 := make([]int, 0) // 空切片:效果同上
fmt.Printf("s1==nil: %t, s2==nil: %t, s3==nil: %t\n", s1 == nil, s2 == nil, s3 == nil)
fmt.Printf("s1 cap: %d, s2 cap: %d, s3 cap: %d\n", cap(s1), cap(s2), cap(s3))
// 输出:
// s1==nil: true, s2==nil: false, s3==nil: false
// s1 cap: 0, s2 cap: 0, s3 cap: 0
重要区别:
-
nil切片可以安全追加元素(会自动分配底层数组)
-
nil切片和空切片在功能上几乎等价,但nil切片更省内存
var s []int
s = append(s, 1) // 完全合法,s变为[1]
fmt.Println(s) // 输出: [1]
三、append 机制与扩容
3.1 append 基本用法
s := make([]int, 2, 4)
fmt.Printf("before: len=%d, cap=%d, %v\n", len(s), cap(s), s)
s = append(s, 1)
fmt.Printf("after append 1: len=%d, cap=%d, %v\n", len(s), cap(s), s)
s = append(s, 2, 3, 4) // 追加多个元素
fmt.Printf("after append 2,3,4: len=%d, cap=%d, %v\n", len(s), cap(s), s)
3.2 扩容规则(Go 1.18+新规则)
当容量不足时,Go会进行扩容:
// 扩容策略:
// 1. 如果旧容量小于256,新容量 = 旧容量 * 2
// 2. 如果旧容量 >= 256,新容量 = 旧容量 + (旧容量+3*256)/4
// 即 旧容量 * 1.25(四分之三的增长率)
// 3. 如果计算出的新容量小于所需容量,使用所需容量
// 4. 还会进行内存对齐
实际验证扩容:
func main() {
var s []int
// 逐步追加,观察容量变化
capacities := make([]int, 0, 20)
lengths := make([]int, 0, 20)
for i := 0; i < 20; i++ {
s = append(s, i)
capacities = append(capacities, cap(s))
lengths = append(lengths, len(s))
fmt.Printf("len=%2d, cap=%2d, %v\n", len(s), cap(s), s)
}
}
输出示例:
len= 1, cap= 1, [0]
len= 2, cap= 2, [0 1]
len= 3, cap= 4, [0 1 2] ← 扩容:1→2, 2→4
len= 4, cap= 4, [0 1 2 3]
len= 5, cap= 8, [0 1 2 3 4] ← 扩容:4→8
len= 8, cap= 8, [0 1 2 3 4 5 6 7]
len= 9, cap=16, [0 1 2 3 4 5 6 7 8] ← 扩容:8→16
...
3.3 扩容的本质
func main() {
s := []int{1, 2, 3}
fmt.Printf("s指针: %p, len=%d, cap=%d\n", s, len(s), cap(s))
s2 := append(s, 4)
fmt.Printf("s2指针: %p, len=%d, cap=%d, %v\n", s2, len(s2), cap(s2), s2)
// 注意:s和s2此时指向不同的底层数组!
s[0] = 100
fmt.Printf("修改s[0]=100后:\n")
fmt.Printf("s = %v\n", s)
fmt.Printf("s2 = %v\n", s2) // s2不受影响!
}
输出:
s指针: 0xc00000a0e0, len=3, cap=3
s2指针: 0xc00000a100, len=4, cap=6
修改s[0]=100后:
s = [100 2 3]
s2 = [1 2 3 4] ← s2不受影响,因为底层数组不同
四、切片拷贝与陷阱
4.1 copy 函数
func main() {
src := []int{1, 2, 3}
dst := make([]int, len(src))
n := copy(dst, src)
fmt.Printf("拷贝了 %d 个元素\n", n)
fmt.Printf("dst = %v\n", dst)
// 修改src不影响dst
src[0] = 100
fmt.Printf("修改src后: src=%v, dst=%v\n", src, dst)
}
4.2 切片的浅拷贝问题
陷阱:共享底层数组!
func main() {
s1 := []int{1, 2, 3, 4, 5}
s2 := s1 // 拷贝切片头,不是底层数组
s3 := make([]int, len(s1))
// 注意:s1, s2共享同一个底层数组!
fmt.Printf("s1指针: %p, s2指针: %p, s3指针: %p\n", s1, s2, s3)
s1[0] = 100 // 同时影响s1和s2!
fmt.Printf("s1 = %v\n", s1)
fmt.Printf("s2 = %v\n", s2) // s2也变了!
fmt.Printf("s3 = %v\n", s3) // s3不受影响
}
输出:
s1指针: 0xc00000a0e0, s2指针: 0xc00000a0e0, s3指针: 0xc00000a100
s1 = [100 2 3 4 5]
s2 = [100 2 3 4 5] ← s2也变了!
s3 = [0 0 0 0 0] ← s3不受影响
4.3 正确的深拷贝方式
func main() {
s1 := []int{1, 2, 3, 4, 5}
// 方式1:使用copy
s2 := make([]int, len(s1))
copy(s2, s1)
// 方式2:切片再切片
s3 := make([]int, len(s1))
s3 = append([]int(nil), s1...)
// 方式3:简洁写法
s4 := make([]int, len(s1))
for i := range s1 {
s4[i] = s1[i]
}
// 验证
s1[0] = 100
fmt.Printf("s1 = %v\n", s1)
fmt.Printf("s2 = %v (copy)\n", s2)
fmt.Printf("s3 = %v (append)\n", s3)
fmt.Printf("s4 = %v (for)\n", s4)
}
五、切片作为函数参数
5.1 切片是引用类型
在Go中,切片作为函数参数传递时,传递的是切片头的拷贝:
func modifySlice(s []int) {
s[0] = 100 // 修改底层数组元素
s = append(s, 4) // append可能触发扩容,产生新的底层数组
fmt.Printf("函数内: len=%d, %v\n", len(s), s)
}
func main() {
s := []int{1, 2, 3}
modifySlice(s)
fmt.Printf("函数外: len=%d, %v\n", len(s), s)
}
输出:
函数内: len=4, [100 2 3 4]
函数外: len=3, [100 2 3] ← 长度没变,但值被修改了!
图解:
调用前:
main: s ──────► [1, 2, 3]
↑
modifySlice: s' ───┘ (s'是s的拷贝,指向同一底层数组)
append后:
main: s ──────► [100, 2, 3] (未被影响)
↑
modifySlice: s' ───┘
│
└────────► [100, 2, 3, 4] (新的底层数组)
5.2 正确的修改方式
如果需要在函数内扩展切片,必须返回新切片:
func extendSlice(s []int) []int {
s = append(s, 4)
s[0] = 100
return s
}
func main() {
s := []int{1, 2, 3}
s = extendSlice(s)
fmt.Printf("新切片: len=%d, %v\n", len(s), s)
}
六、Filter/Map模式
6.1 过滤切片元素
func filter(slice []int, predicate func(int) bool) []int {
result := make([]int, 0)
for _, v := range slice {
if predicate(v) {
result = append(result, v)
}
}
return result
}
func main() {
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
// 过滤偶数
evens := filter(numbers, func(n int) bool {
return n%2 == 0
})
fmt.Printf("偶数: %v\n", evens)
// 过滤大于5的数
greaterThan5 := filter(numbers, func(n int) bool {
return n > 5
})
fmt.Printf("大于5: %v\n", greaterThan5)
}
6.2 切片元素转换
func mapSlice(slice []int, transform func(int) int) []int {
result := make([]int, len(slice))
for i, v := range slice {
result[i] = transform(v)
}
return result
}
func main() {
numbers := []int{1, 2, 3, 4, 5}
// 每个元素乘以2
doubled := mapSlice(numbers, func(n int) int {
return n * 2
})
fmt.Printf("翻倍: %v\n", doubled)
// 转换为字符串
strs := make([]string, len(numbers))
for i, n := range numbers {
strs[i] = fmt.Sprintf("num_%d", n)
}
fmt.Printf("字符串: %v\n", strs)
}
七、多维切片
7.1 切片数组(数组的切片)
func main() {
// 二维切片
matrix := [][]int{
{1, 2, 3},
{4, 5, 6},
{7, 8, 9},
}
fmt.Printf("matrix[1][2] = %d\n", matrix[1][2])
// 遍历
for i, row := range matrix {
for j, val := range row {
fmt.Printf("matrix[%d][%d] = %d\n", i, j, val)
}
}
}
7.2 动态二维切片
func main() {
rows, cols := 3, 4
// 创建3x4的二维切片
matrix := make([][]int, rows)
for i := range matrix {
matrix[i] = make([]int, cols)
}
// 填充数据
for i := 0; i < rows; i++ {
for j := 0; j < cols; j++ {
matrix[i][j] = i*cols + j + 1
}
}
fmt.Printf("3x4矩阵: %v\n", matrix)
}
八、常见面试题解析
题目1:nil切片和空切片的区别
var s1 []int // nil切片
s2 := []int{} // 空切片
s3 := make([]int, 0)
fmt.Println(s1 == nil) // true
fmt.Println(s2 == nil) // false
fmt.Println(s3 == nil) // false
// 功能上几乎等价,都可以append
s1 = append(s1, 1) // 正常工作
s2 = append(s2, 1) // 正常工作
题目2:切片的容量增长
s := make([]int, 1, 1) // len=1, cap=1
s = append(s, 2) // 扩容,cap变为2
s = append(s, 3) // 扩容,cap变为4
s = append(s, 4) // 不扩容,cap=4够用
s = append(s, 5) // 扩容,cap变为8
题目3:切片共享底层数组
a := []int{1, 2, 3, 4}
b := a[1:3] // b = [2, 3],len=2, cap=3(共享底层数组)
b = append(b, 5) // 不扩容,b变为[2, 3, 5]
fmt.Println(a) // [1, 2, 3, 5] ← a[3]被修改了!
fmt.Println(b) // [2, 3, 5]
总结
-
切片本质:切片是一个包含array指针、len、cap三个字段的结构体
-
append机制:容量不足时扩容,新底层数组与原数组无关
-
拷贝陷阱:切片拷贝只复制切片头,底层数组仍然共享
-
函数参数:切片按引用传递,但append可能产生新切片
-
扩容规则:旧容量的1.25倍(超过256时),并进行内存对齐
最佳实践:
-
函数内如果需要修改切片长度,必须返回新切片
-
避免对共享底层数组的切片进行部分append
-
使用copy或append创建独立副本
💡 下一篇文章我们将深入讲解Go语言Map的底层原理,敬请期待!