boxmoe_header_banner_img

Hello! 欢迎来到我的博客!

加载中

文章导读

Go slices – 有关slices我能告诉你的一切


avatar
xiaoifei 2026年5月3日 20

信息来源:

写 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 把 lencap 摆到台面上,就像厨师把后厨库存也贴到了菜单旁边。

至此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 头部,也就是 ptrlencap 这三个字段。函数内部拿到的是一份新的 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:]

这里 bc 都不是凭空创造的新世界,它们背后仍然可能是同一个底层数组

c[0] = 99
fmt.Println(a) // [1 2 99 4 5]
fmt.Println(b) // [1 2 99]

为什么 Go 里经常用 copyappend([]T(nil), old...) 来明确复制。不是为了炫技,是为了跟底层数组断绝关系,避免“前任还住在你家地下室”。

  1. 如果你想明确复制数据,可以使用 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]
  1. 也可以用这种写法,创建一个新的 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
}

想必最开始提出的问题也能迎刃而解了

  1. slices 和数组到底有什么区别?
    数组是固定长度的值类型,长度是类型的一部分,比如 [3]int[5]int 是不同类型。数组赋值或传参时会拷贝整个数组。
    而 slices 是对底层数组的一段视图,本质可以看作是一个结构体,内部包含指向底层数组指针,当前长度,起始位置到底层数组末尾的长度
    所以 slices 本身也是值类型,但它持有底层数组的引用。复制 slices 时,只是进行 浅拷贝。
  2. append为什么有时候会影响原数据,有时候不会?
    关键看 append 时是否触发扩容。
    如果 len < cap,说明底层数组还有剩余空间,append 会直接把新元素写到底层数组后面,所以其他共享同一个底层数组的 slice 可能会被影响。
    如果 len == cap,容量不够,append 会申请一块新的底层数组,把旧数据拷贝过去,再追加新元素。此时新 slice 指向新的底层数组,就不会影响原来的数据。
  3. 为什么从大 slices 截取一小段,可能仍然占着一大块内存?
    因为 slice 持有的是底层数组的引用。即使只截取其中很小一段,只要这个小 slice 还存在,GC 就认为整个底层数组仍然可达,因此不会回收那块大内存。

总结本文的所有注意事项:

  1. 数组是值,slice 是描述。
    数组赋值会复制整个数组;slice 赋值会复制 slice 头部。这个头部里有指针、长度和容量。
  2. lencap 是两个不同概念。
    len 表示当前能访问多少元素;cap 表示在不超过底层数组范围的情况下,最多能扩展到多少。
  3. append 一定要接返回值。
s = append(s, x)

因为 append 可能修改长度,也可能换底层数组。

  1. 函数中修改元素和追加元素要分开看。
func changeFirst(s []int) {
    s[0] = 100
}

这种修改底层数组,调用方通常能看到。

  1. 记得返回slices
func addOne(s []int) {
    s = append(s, 1)
}

这种修改函数内部的 slice 信息,调用方不一定能看到。更推荐下面做法:

func addOne(s []int) []int {
    return append(s, 1)
}
  1. 长期保存小 slice 时,要确认是否需要复制。
small := append([]int(nil), big[:10]...)

这能避免小 slice 持续引用大数组。

Go


评论(0)

查看评论列表

暂无评论


发表评论

表情 颜文字
插入代码