跳到主要内容

数组与切片

问题

Go 中数组和切片有什么区别?切片的底层结构是什么?扩容机制如何工作?

答案

数组(Array)

数组在 Go 中是固定长度的值类型。长度是类型的一部分——[3]int[5]int 是不同类型:

var a [3]int = [3]int{1, 2, 3}
b := [...]int{1, 2, 3} // 编译器推断长度
var c [5]int // [0, 0, 0, 0, 0]

// 数组是值类型,赋值和传参都是拷贝
d := a // d 是 a 的副本
d[0] = 100
fmt.Println(a[0]) // 1(a 不受影响)
数组在实际开发中很少直接使用

因为长度固定、传参是拷贝(性能差),Go 开发中几乎都使用切片而非数组。数组的主要价值是作为切片的底层存储。

切片(Slice)——底层结构

切片是对底层数组的一个引用视图,由三个字段组成:

// runtime/slice.go 的简化版
type slice struct {
array unsafe.Pointer // 指向底层数组
len int // 当前元素个数
cap int // 底层数组容量
}

切片的创建方式

// 1. 从数组创建
arr := [5]int{10, 20, 30, 40, 50}
s1 := arr[1:4] // [20, 30, 40],len=3, cap=4

// 2. make 创建
s2 := make([]int, 3, 5) // len=3, cap=5,元素为 [0, 0, 0]

// 3. 字面量创建
s3 := []int{1, 2, 3} // len=3, cap=3

// 4. nil slice vs 空 slice
var s4 []int // nil slice:array=nil, len=0, cap=0
s5 := []int{} // 空 slice:array≠nil, len=0, cap=0
s6 := make([]int, 0) // 空 slice(同 s5)

切片扩容机制

append 时容量不够,Go 会创建更大的底层数组并拷贝数据。扩容策略在 Go 1.18 有重大变化

// Go 1.18 之前(runtime/slice.go growslice 函数简化)
if newCap < 1024 {
newCap = oldCap * 2 // 小于 1024 翻倍
} else {
newCap = oldCap + oldCap/4 // 大于等于 1024 增长 25%
}

// Go 1.18 之后:更平滑的增长曲线
if newCap < 256 {
newCap = oldCap * 2 // 小于 256 翻倍
} else {
// 平滑过渡,避免 1024 处的跳变
newCap += (newCap + 3*256) / 4
}
扩容后的实际容量

最终容量还会经过内存对齐处理。Go 的内存分配器按特定大小类(size class)分配,所以实际容量可能比计算值稍大。例如预期 cap=5 可能实际分配 cap=6(对齐到 48 字节 / 8 字节 per int64)。

扩容可视化

s := make([]int, 0)
for i := 0; i < 20; i++ {
s = append(s, i)
fmt.Printf("len=%d, cap=%d\n", len(s), cap(s))
}
// len=1, cap=1
// len=2, cap=2
// len=3, cap=4 ← 翻倍
// len=4, cap=4
// len=5, cap=8 ← 翻倍
// len=6, cap=8
// ...
// len=9, cap=16 ← 翻倍

切片共享底层数组——最易踩的坑

a := []int{1, 2, 3, 4, 5}
b := a[1:3] // b = [2, 3],与 a 共享底层数组

b[0] = 200 // 修改 b 会影响 a!
fmt.Println(a) // [1, 200, 3, 4, 5]

// append 到 b,如果 cap 够用,也会覆盖 a 的数据!
b = append(b, 999)
fmt.Println(a) // [1, 200, 3, 999, 5] ← a[3] 被覆盖了!

安全的做法——创建独立副本:

// 方法 1:copy
b := make([]int, len(a[1:3]))
copy(b, a[1:3])

// 方法 2:append 到新 slice
b := append([]int{}, a[1:3]...)

// 方法 3:完整切片表达式(限制 cap)
b := a[1:3:3] // len=2, cap=2,append 时一定会分配新数组
完整切片表达式 a[low:high:max]

第三个参数 max 限制新切片的 cap = max - low。这样 append 时容量一定不够,会强制分配新数组,避免覆盖原数组。这是防止共享底层数组导致 Bug 的最佳实践。

append 的行为详解

// append 返回值必须接收!(可能指向新数组)
s = append(s, elem)

// append 多个元素
s = append(s, 1, 2, 3)

// append 另一个切片
s = append(s, other...)

// append 的本质逻辑:
// if len(s) + len(elems) <= cap(s):
// 直接在底层数组追加,返回同一底层数组的新 slice header
// else:
// 分配新底层数组(扩容),拷贝数据,返回新 slice header
append 必须接收返回值
s := []int{1, 2, 3}
append(s, 4) // ❌ 返回值被丢弃!s 没有变化
s = append(s, 4) // ✅ 正确

copy 函数

src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)

// copy 复制 min(len(dst), len(src)) 个元素
n := copy(dst, src) // n=3, dst=[1, 2, 3]

// copy 不会自动扩容 dst
dst2 := make([]int, 0)
copy(dst2, src) // 拷贝 0 个元素!dst2 还是空的

切片作为函数参数

切片传参是值拷贝,但拷贝的是 slice header(24 字节),底层数组共享:

func modifySlice(s []int) {
s[0] = 100 // ✅ 能修改原切片的元素(共享底层数组)
s = append(s, 999) // ❌ 如果触发扩容,不影响原切片
}

a := []int{1, 2, 3}
modifySlice(a)
fmt.Println(a) // [100, 2, 3] ← 元素被修改,但没有 999

如果函数内部需要 append 并反映到调用方,传切片指针 *[]int

func appendToSlice(s *[]int, val int) {
*s = append(*s, val)
}

常见面试问题

Q1: nil slice 和空 slice 有什么区别?

答案

var s1 []int         // nil slice: array=nil, len=0, cap=0
s2 := []int{} // 空 slice: array≠nil (指向一个空数组), len=0, cap=0
s3 := make([]int, 0) // 空 slice(同 s2)
维度nil slice空 slice
== niltruefalse
len()00
cap()00
append()✅ 正常工作✅ 正常工作
JSON 序列化null[]
reflect.DeepEqual两者不相等

实际开发建议:如果函数返回空结果,用 nil 即可;如果需要 JSON 序列化为 [] 而非 null,用空 slice。

Q2: 切片扩容后地址会变吗?

答案

会变。扩容时 Go 分配新的更大的底层数组,将旧数据拷贝过去,旧数组等待 GC 回收。所以扩容后的切片与扩容前的切片不再共享底层数组。这就是为什么 append 必须接收返回值——新返回的 slice header 中的指针可能已经指向了新数组。

s := make([]int, 3, 3)
fmt.Printf("Before: %p\n", s) // 打印底层数组地址
s = append(s, 4) // 触发扩容
fmt.Printf("After: %p\n", s) // 地址已改变

Q3: 如何高效删除切片中的某个元素?

答案

// 方法 1:保持顺序(将后面元素前移)
// 时间 O(n)
func removeOrdered(s []int, i int) []int {
return append(s[:i], s[i+1:]...)
}

// 方法 2:不保持顺序(用最后一个元素覆盖)
// 时间 O(1)
func removeUnordered(s []int, i int) []int {
s[i] = s[len(s)-1]
return s[:len(s)-1]
}
警告

append(s[:i], s[i+1:]...) 会修改原切片的底层数组,如果原切片还在使用可能导致 Bug。安全起见可以先拷贝。

Q4: 切片的 lencap 分别表示什么?

答案

  • len:切片当前包含的元素个数
  • cap:从切片起始位置到底层数组末尾的元素个数(即不触发扩容的最大长度)
arr := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
s := arr[2:5] // [2, 3, 4]
// len(s) = 3(包含 3 个元素)
// cap(s) = 8(从 arr[2] 到 arr[9],共 8 个位置)

Q5: 为什么 for range 中修改值无效?

答案

for range 遍历切片时,循环变量是元素的副本,修改副本不影响原切片:

s := []int{1, 2, 3}
for _, v := range s {
v *= 2 // 修改的是副本,s 不变
}
// s 仍然是 [1, 2, 3]

// 正确做法:用索引
for i := range s {
s[i] *= 2
}
// s 现在是 [2, 4, 6]

Q6: 大量 append 操作如何优化?

答案

预分配容量,避免反复扩容和拷贝:

// ❌ 不好:可能触发多次扩容
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)
}

如果最终长度已知,直接用 make([]int, n) 然后用索引赋值更好:

s := make([]int, 10000)
for i := range s {
s[i] = i
}

相关链接