Go的切片是什么?一些小细节和容易错的地方
最近用GO做算法题的时候,在使用切片的过程出现了一些错误,他这里跟Java中的列表有着明显的区别。
go
var res [][]int
for _, interval := range intervals {
...
lastResInterval := res[len(res)-1]
if interval[0] <= lastResInterval[1] {
lastResInterval[1] = int(math.Max(float64(interval[1]), float64(lastResInterval[1])))
} else {
res = append(res, interval)
}
}
这里代码其实是可以正常运行的,可能出错的地方是这里:
lastResInterval := res[len(res)-1]
这里只是复制了切片内容,相当于指向同一数组,但是一旦出现扩容等机制改变了底层数组,就会导致他们指向的不是同一个数组。
使得这里lastResInterval[1] = ...
赋值产生差错
下面重新理解一下切片是什么:
理解 Go 的切片 (Slice) 到底是什么
在很多语言中,数组或列表就是一个连续的内存块。但在 Go 中,切片本身并不是数据,而是一个描述数据的"小本本",我们称之为切片头(Slice Header)。
这个"小本本"上记录了三件事:
- 指针 (Pointer):指向一个底层数组 (Underlying Array) 的某个元素的内存地址。这个底层数组才是真正存储数据的地方。
- 长度 (Length):切片中当前包含的元素个数。
len()
函数看到的就是它。 - 容量 (Capacity):从指针指向的位置开始,到底层数组的末尾,总共能容纳多少个元素。
cap()
函数看到的就是它。
所以,请记住核心:切片是一个带有指针、长度和容量的结构体。它和真正的数据(底层数组)是分开的。
类比:书的目录
你可以把底层数组想象成一本很厚的书,里面的内容就是你的数据。
而切片就像这本书的一个目录条目,它告诉你:
- 指针 -> "从第 85 页开始看"
- 长度 -> "这个章节总共 10 页" (从 85 页到 94 页)
- 容量 -> "从 85 页到书的最后一页(比如 300 页)总共有 216 页的空间"
当你把一个切片赋值给另一个变量时,比如 b := a
,你做的不是复制整本书,你只是复制了这个目录条目。现在你有两个一模一样的目录条目,但它们都指向同一本书的同一页。
分析res := [][]int
现在我们把这个模型套用到你的 [][]int 类型的 res 变量上。
[][]int
是一个"切片的切片"。这意味着:
- res 是一个外层切片。
- 它的底层数组里存储的元素,不是 int,而是另一个切片头 (
[]int
)。
假设 res
是 [][]int{``{1, 5}, {8, 10}}
,它在内存中的结构是这样的:
// res (外层切片头)
// 指针 -----> [ (内层切片头A) , (内层切片头B) ] (res 的底层数组)
// 长度: 2 / \
// 容量: 2 / \
// / \
// 指针A -----> [ 1, 5 ] 指针B -----> [ 8, 10 ]
// 长度A: 2 长度B: 2
// 容量A: 2 容量B: 2
这是一个两层的结构。res 的底层数组里放的不是数字,而是另外两个"小本本"(内层切片头 A 和 B)。
剖析关键代码 lastResInterval := res[len(res)-1]
现在,我们来执行这行代码。假设 res 里已经有 {{1, 5}} 这个元素了。
- res[len(res)-1]:这会获取 res 底层数组中的最后一个元素。这个元素是什么?它不是 {1, 5} 这个数据本身,而是指向 {1, 5} 的那个内层切片头。
- lastResInterval := ...:这行代码执行了赋值操作。根据我们第一步的原理,赋值一个切片就是复制它的切片头。
所以,执行完这行代码后,内存状态变成了:
// res (外层切片头)
// 指针 -----> [ (内层切片头A) ] (res 的底层数组)
// |
// |
// 指针A ----------> [ 1, 5 ] (底层数据)
// lastResInterval (一个全新的切片头,是内层切片头A的副本)
// 指针A' ---------> [ 1, 5 ] (指向和上面完全相同的底层数据)
现在你有了两个切片头:
- 一个是 res 底层数组里的那个(我们称之为原始头)
- 另一个是 lastResInterval(我们称之为副本头)
重点来了:这两个切片头的指针,指向的是同一个底层数组 [1, 5]!
解开谜团:为什么修改可能会出错?
当你执行 lastResInterval[1] = 99 时:
- Go 通过 lastResInterval 这个副本头找到它指向的底层数组 [1, 5]。
- 然后修改了这个底层数组的第 1 个索引(0-based)的元素。
- 底层数组变成了 [1, 99]。
因为 res 里的那个原始头也指向这个同一个底层数组,所以当你回头检查 res 时,你会发现 res 也变成了 {{1, 99}}。
那为什么我说这是错误的、危险的?
因为这个"成功"只是一个巧合。它只在你原地修改元素时才有效。如果你的操作涉及到了 append,并且导致了底层数组的重新分配,灾难就来了。
看这个例子
go
// ... 假设 lastResInterval 指向 [1, 5],长度2,容量2
// 现在我们 append,容量不够了,Go 会创建一个新的、更大的底层数组
lastResInterval = append(lastResInterval, 100)
执行 append 后:
-
Go 发现 [1, 5] 这个底层数组容量不够。
-
Go 会创建一个全新的、更大的数组,比如容量为 4。
-
把 [1, 5, 100] 复制到这个新数组里。
-
lastResInterval 这个副本头的指针,现在会指向这个全新的数组! 它的长度和容量也会更新。
但是,res 里面的那个原始头呢?它毫不知情!它的指针仍然指向那个旧的、小的、内容是 [1, 5] 的底层数组!
从这一刻起,lastResInterval 和 res 内部的切片就分道扬镳了,它们指向了完全不同的内存地址。你对 lastResInterval 的任何后续修改都和 res 再无关系。
总结一下
-
核心原理:切片赋值(b := a)和函数传参,都是复制切片头,而不是复制底层的数据。这使得多个切片头可以指向并共享同一个底层数组。
-
原地修改的"假象":当你通过一个切片副本修改元素时(b[i] = x),你实际上是通过副本的指针修改了共享的底层数组,所以原始切片也能看到变化。
-
真正的危险 (append):一旦你对副本使用了 append 并导致底层数组扩容,副本的指针就会指向一个新的底层数组,从而与原始切片"失联"。
-
最佳实践:为了避免这种混乱和不可预知的行为,永远不要依赖于修改切片的副本来影响原始切片。始终通过最原始的变量来进行修改,这样代码的意图才清晰明确,且绝对安全。
错误的(脆弱的)
go
last := res[len(res)-1]
last[1] = 99 // 碰巧能行
last = append(last, 100) // 绝对不行
正确的(健壮的)
go
res[len(res)-1][1] = 99
res[len(res)-1] = append(res[len(res)-1], 100)