
信息来源:
写 Go 的时候,slices (切片)是一个绕不开的角色。它看起来像数组,用起来像动态数组
对于Javaer,第一眼看到Go的slice,可能会下意识把它理解成:ArrayList,不就是一个动态扩容的数组?
对于一个Jser,这不就是数组.slice(start,end)吗?
对于pythoner来说,难不成可以用[start:end]?
上面说法都没错,我们需要清楚:slices 本身不是数组,它是对底层数组的一段描述。
为了深刻理解slices,我们需要搞懂三个问题
- slice 和数组到底有什么区别?
append为什么有时候会影响原数据,有时候不会?- 为什么从大 slice 截取一小段,可能仍然占着一大块内存?
array 数组
先看数组:
var a [3]int = [3]int{1, 2, 3}
这里的 [3]int 是数组类型,长度 3 是类型的一部分,编译器会在内存中开辟固定的sizeof(int) * 3的空间。
这和 Java、C#、JavaScript 的使用习惯不太一样。在这些语言里,我们通常把数组长度当作对象状态,而不是类型本身的一部分。Go 则更严格:数组长度写进类型里:
var a [3]int
var b [4]int
// a = b // 编译失败:类型不同
对于数组的赋值:
与 Java/C#/JS 中“数组变量通常保存引用”的直觉不同,Go 数组赋值是复制数组内容,不是复制一个引用。我们通常把数组长度当作对象状态,而不是类型本身的一部分。Go 则更严格:数组长度写进类型里
b := a // 自动类型断言,为b创建了新的内存空间,并将a的数组值逐个复制到了b中。
b[0] = 100
fmt.Println(a) // [1 2 3]
fmt.Println(b) // [100 2 3]
正如官方文档所说:
你可以把数组想象成一种结构体,但它的字段是索引式的,而不是命名式的:一个固定大小的复合值。
伪代码:
type array struct {
[1] T
[2] T
[3] T
}
slices的本体:不是数组,是数组的小纸条
Go的slices在声明上与array类似,但是不需要指定大小
s := []int{1, 2, 3}
slices的底层模型大概可以理解成这样:
type slice struct {
ptr *T
len int
cap int
}
本质上是创建了一个[3]int大小的底层数组
它有三个关键字段:
ptr:指向底层数组的某个位置len:当前能看到多少个元素cap:从当前位置开始,底层数组最多还能装多少
所以 slice 不是“真正的容器本体”,更像一张便签:“我从这个数组的这里开始,目前能看 3 个位置,最多能用 5 个位置。”
arr := [5]int{1, 2, 3, 4, 5}
s := arr[1:3] // 对数组切片
fmt.Println(s, len(s), cap(s)) // [2, 3] 2 4
// s指向arr[1]
// len计算为 sizeof( arr[1,3) ) 左闭右开
// cap为 len([2,3,4,5])
make:提前告诉后厨准备几个座位
s := make([]int, 3, 5)
fmt.Println(s2) // [0 0 0]
s3 := s2[:5]
fmt.Println(s3) // [0 0 0 0 0]
- 创建一个
[]int - 当前长度是
3 - 容量是
5
编译器会创建一个创建一个容量为 5 的底层数组,并初始化为0(对于Js酱,Go酱不玩这种“薛定谔的元素”,int 默认值就是 0,该在就在)。然后 s2 作为切片,直接访问 s2 只会输出前3个元素
append:能原地塞就原地塞,塞不下就搬家,返回值一定要接住
当塞不下时:
s := []int{1, 2, 3} // len = 3, cap = 3
s = append(s, 4)
s 的长度和容量一开始都是 3。再追加一个元素,原来的底层数组没地方了,Go 只能申请一个新数组,把旧数据搬过去,再把 4 放进去。
当塞得下时
s2 := make([]int, 3, 5) // len = 3, cap = 5
s2 = append(s2, 4)
此时原来的容量通常是 3,追加第 4 个元素时空间不够,Go 会分配一个新的底层数组,把旧元素复制过去,再追加新元素。
这就是为什么 append 的返回值必须接住:你不能假设它一定还指向原来的底层数组。
ps:Java的
ArrayList、C#的List<T>、JS酱的Array也会扩容,只是你平时不太看见。Go 把len和cap摆到台面上,就像厨师把后厨库存也贴到了菜单旁边。
至此slices的使用与内存细节介绍完毕,下面是实战注意事项 ~
slices实战
slice 当函数参数:传的是”结构体”副本,里面可能还是同一个仓库
slice 传入函数时,容易出现一个经典误解:既然 slice 里面有指针,那函数里 append 之后,外面是不是也会自动变?
先看代码:
func appendTest(s []int) {
fmt.Println("in appendTest before:", s, len(s), cap(s))
s = append(s, 4)
fmt.Println("in appendTest after:", s, len(s), cap(s))
}
func main() {
s := []int{1, 2, 3}
appendTest(s)
fmt.Println("in main:", s, len(s), cap(s))
}
输出是:
in appendTest before: [1 2 3] 3 3
in appendTest after: [1 2 3 4] 4 6
in main: [1 2 3] 3 3
函数里确实追加了 4,但外面的 s 还是 [1 2 3]。
原因是:slice 作为参数传递时,复制的是 slice 头部,也就是 ptr、len、cap 这三个字段。函数内部拿到的是一份新的 slice 描述。如果函数内部 append 触发扩容,内部的 slice 会指向新的底层数组,外面的 slice 仍然保持原样。
再看一个更容易迷惑人的例子:
func appendTest(s []int) {
s = append(s, 4)
fmt.Println("in appendTest:", s, len(s), cap(s))
}
func main() {
s := make([]int, 3, 4)
copy(s, []int{1, 2, 3})
appendTest(s)
fmt.Println("in main:", s, len(s), cap(s))
fmt.Println("in main extended:", s[:4])
}
输出是:
in appendTest: [1 2 3 4] 4 4
in main: [1 2 3] 3 4
in main extended: [1 2 3 4]
这段代码说明了两个事实:
- 函数内部的
append没有触发扩容,因为容量够用 4被写进了同一个底层数组- 但是外面的
s长度仍然是3
所以外面直接打印 s,只能看到 [1 2 3]。但如果写 s[:4],把长度扩展到容量范围内,就能看到第 4 个元素。
这个行为不是“Go 忽隐忽现”,而是 slice 模型导致的结果:底层数组可能共享,但 slice 的 len 字段是复制出来的,函数里改了自己的 len,不会自动改到外面的 len。
因此,涉及 append 的函数,通常应该返回新的 slice:
func appendTest(s []int) []int {
s = append(s, 4)
return s
}
func main() {
s := []int{1, 2, 3}
s = appendTest(s)
fmt.Println(s)
}
这和 Java 的 list.add(x)、C# 的 list.Add(x) 不一样。那些方法修改的是同一个列表对象;Go 的 append 返回的是新的 slice 描述,所以调用方要接收返回值。
共享底层数组:slices 的快乐,也是 slices 的坑
因为 slice 只是底层数组的视图,所以多个 slice 可能共享同一个数组。
a := []int{1, 2, 3, 4, 5}
b := a[:3]
c := a[2:]
这里 b 和 c 都不是凭空创造的新世界,它们背后仍然可能是同一个底层数组
c[0] = 99
fmt.Println(a) // [1 2 99 4 5]
fmt.Println(b) // [1 2 99]
为什么 Go 里经常用 copy 或 append([]T(nil), old...) 来明确复制。不是为了炫技,是为了跟底层数组断绝关系,避免“前任还住在你家地下室”。
- 如果你想明确复制数据,可以使用
copy:
src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)
copy(dst, src[:3])
dst[0] = 100
fmt.Println("src:", src)
fmt.Println("dst:", dst)
输出:
src: [1 2 3 4 5]
dst: [100 2 3]
- 也可以用这种写法,创建一个新的 slice,并把
src[:3]里的元素追加进去。
dst := append([]int(nil), src[:3]...)
内存泄漏:只想要 10 个元素,却留下 100 万个陪跑
slices 共享底层数组还有一个实际问题,从大 slice 截小 slice,可能保留整块内存
func leakFunc() []int {
big := make([]int, 1000000)
return big[:10]
}
func main() {
s := leakFunc()
fmt.Println(len(s), cap(s))
}
leakFunc 只返回了前 10 个元素:
return big[:10]
但是返回的 slice 仍然指向 big 的底层数组。只要返回值还被使用,底层那块包含 100 万个 int 的数组就不能被 GC 回收。
这不是传统意义上的“完全丢失引用导致无法释放”的内存泄漏,而是“只需要小数据,却保留了大数组”的内存保留问题。
更安全的写法是复制出真正需要的部分:
func safeFunc() []int {
big := make([]int, 1000000)
small := make([]int, 10)
copy(small, big[:10])
return small
}
也可以写成
func safeFunc() []int {
big := make([]int, 1000000)
return append([]int(nil), big[:10]...)
}
实际开发里,读取大文件时尤其要小心:
data, err := os.ReadFile("huge.txt")
if err != nil {
return nil, err
}
return data[:100], nil
如果返回的 data[:100] 被长期保存,那么整个 huge.txt 的内容都可能因为底层数组仍被引用而无法释放。
逃逸分析:数据到底住栈上还是堆上
Go 会通过逃逸分析决定变量放在栈上还是堆上,如果一个变量在函数结束后还可能被使用,那它就必须放到堆上,这就叫变量逃逸到堆。
比如:
func leakFunc() []int {
big := make([]int, 1000000)
return big[:10]
}
big 在函数内部创建,但函数返回的 slice 还引用着它的底层数组。所以函数结束后,这块数据仍然必须活着。编译器会根据逃逸分析,把相关数据安排到合适的位置。
一般写业务代码时,不需要手动决定变量放栈还是放堆。但理解这一点,可以帮助我们看懂为什么某些写法会带来额外分配,也能帮助我们解释 slice 截取后的内存保留问题。
小结:一份实用的 slices 心智模型
slices 是 Go 里最常用、也最值得认真理解的数据结构之一,其更接近一个明确的数据视图:它指向底层数组,并记录自己的长度和容量。
只要记住这个模型,很多问题就能解释清楚
type slice struct {
ptr *T
len int
cap int
}
想必最开始提出的问题也能迎刃而解了
- slices 和数组到底有什么区别?
数组是固定长度的值类型,长度是类型的一部分,比如[3]int和[5]int是不同类型。数组赋值或传参时会拷贝整个数组。
而 slices 是对底层数组的一段视图,本质可以看作是一个结构体,内部包含指向底层数组指针,当前长度,起始位置到底层数组末尾的长度
所以 slices 本身也是值类型,但它持有底层数组的引用。复制 slices 时,只是进行 浅拷贝。 - append为什么有时候会影响原数据,有时候不会?
关键看 append 时是否触发扩容。
如果 len < cap,说明底层数组还有剩余空间,append 会直接把新元素写到底层数组后面,所以其他共享同一个底层数组的 slice 可能会被影响。
如果 len == cap,容量不够,append 会申请一块新的底层数组,把旧数据拷贝过去,再追加新元素。此时新 slice 指向新的底层数组,就不会影响原来的数据。 - 为什么从大 slices 截取一小段,可能仍然占着一大块内存?
因为 slice 持有的是底层数组的引用。即使只截取其中很小一段,只要这个小 slice 还存在,GC 就认为整个底层数组仍然可达,因此不会回收那块大内存。
总结本文的所有注意事项:
- 数组是值,slice 是描述。
数组赋值会复制整个数组;slice 赋值会复制 slice 头部。这个头部里有指针、长度和容量。 len和cap是两个不同概念。len表示当前能访问多少元素;cap表示在不超过底层数组范围的情况下,最多能扩展到多少。append一定要接返回值。
s = append(s, x)
因为 append 可能修改长度,也可能换底层数组。
- 函数中修改元素和追加元素要分开看。
func changeFirst(s []int) {
s[0] = 100
}
这种修改底层数组,调用方通常能看到。
- 记得返回slices
func addOne(s []int) {
s = append(s, 1)
}
这种修改函数内部的 slice 信息,调用方不一定能看到。更推荐下面做法:
func addOne(s []int) []int {
return append(s, 1)
}
- 长期保存小 slice 时,要确认是否需要复制。
small := append([]int(nil), big[:10]...)
这能避免小 slice 持续引用大数组。

评论(0)
暂无评论