关于go slice的原理和注意事项,看这一篇就足够了。

先说结论:

  • slice是一个结构体类型,里面包含3个字段:指向数组的array指针,长度len和容量cap。给slice赋值是对slice里的指针,长度和容量3个字段分别赋值。
  • :分割操作符的结果是一个新切片,新slice结构体里的array指针指向原数组或者原slice的底层数组,新切片的长度是:右边的数值减去左边的数值,新切片的容量是原切片的容量减去:左边的数值。
  • :分割操作符右边的数值上限有2种情况:
    • 如果分割的是数组,那上限是是被分割的数组的长度。
    • 如果分割的是切片,那上限是被分割的切片的容量。注意,这个和下标操作不一样,如果使用下标索引访问切片,下标索引的最大值是(切片的长度-1),而不是切片的容量。
  • 对于append操作和copy操作,要清楚背后的执行逻辑。
  • 打印slice时,是根据slice的长度来打印的

面试题

最近Go 101的作者发布了11道Go面试题,非常有趣,打算写一个系列对每道题做详细解析。欢迎大家关注。

sliceslice
  • A: [0 1 2 3] [0 2 3 3 3]
  • B: [0 2 3 3] [0 2 3 3 3]
  • C: [0 1 2 3] [0 2 3 2 3]
  • D: [0 2 3 3] [0 2 3 2 3]

大家可以在评论区留下你们的答案。这道题有几个考点:

sliceslice:sliceslicesliceappendslice

解析

我们先逐个解答上面的问题。

slice的底层数据结构

talk is cheap, show me the codeslice
Pointersrc/unsafe/unsafe.go
slice
  • array: 是指针,指向一个数组,切片的数据实际都存储在这个数组里。
  • len: 切片的长度。
  • cap: 切片的容量,表示切片当前最多可以存储多少个元素,如果超过了现有容量会自动扩容。
slicesliceslicesliceslice
:
:
:slice:sliceslicearrayslice::::
  • 如果分割的是数组,那上限是是被分割的数组的长度。
  • 如果分割的是切片,那上限是被分割的切片的容量。注意,这个和下标操作不一样,如果使用下标索引访问切片,下标索引的最大值是(切片的长度-1),而不是切片的容量。

一图胜千言,我们通过下面的示例来讲解下切片分割的机制。

sliceptrarraylencap
s := make([]byte, 5, 5)s
ss2 := s[2:4]s2
s2ss2len(s2)s2 := s[2:4]ss2cap(s2)s2s2[0]s2[1]s2[0:3]
ss3 := s2[:cap(s2)]s3
s3s2ss2s3len(s3)s3 := s2[:cap(s2)]s2s3cap(s3)s3
:

正是因为他们指向同一块内存空间,所以对原数组或者原切片的修改会影响分割后的新切片的值,反之亦然。

append机制

要了解append的机制,直接看源码说明。

  • append函数返回的是一个切片,append在原切片的末尾添加新元素,这个末尾是切片长度的末尾,不是切片容量的末尾。
    func test() {
    a := make([]int, 0, 4)
    b := append(a, 1) // b=[1], a指向的底层数组的首元素为1,但是a的长度和容量不变
    c := append(a, 2) // a的长度还是0,c=[2], a指向的底层数组的首元素变为2
    fmt.Println(a, b, c) // [] [2] [2]
    }
  • 如果原切片的容量足以包含新增加的元素,那append函数返回的切片结构里3个字段的值是:
    • array指针字段的值不变,和原切片的array指针的值相同,也就是append是在原切片的底层数组返回的切片还是指向原切片的底层数组
    • len长度字段的值做相应增加,增加了N个元素,长度就增加N
    • cap容量不变


  • 如果原切片的容量不够存储append新增加的元素,Go会先分配一块容量更大的新内存,然后把原切片里的所有元素拷贝过来,最后在新的内存里添加新元素。append函数返回的切片结构里的3个字段的值是:
    • array指针字段的值变了,不再指向原切片的底层数组了,会指向一块新的内存空间
    • len长度字段的值做相应增加,增加了N个元素,长度就增加N
    • cap容量会增加


注意:append不会改变原切片的值,原切片的长度和容量都不变,除非把append的返回值赋值给原切片。

那么问题来了,新切片的容量是按照什么规则计算得出来的呢?

slice扩容机制

sliceGo
1024210241.25

这里明确告诉大家,这个结论是错误的。

slicesrc/runtime/slice.gogrowslice

Go 1.18的扩容实现代码如下,et是切片里的元素类型,old是原切片,cap等于原切片的长度+append新增的元素个数。

newcap是扩容后的容量,先根据原切片的长度、容量和要添加的元素个数确定newcap大小,最后再对newcap做内存对齐得到最后的newcap。


答案

我们回到本文最开始的题目,逐行解析每行代码的执行结果。

加餐:copy机制

copysrc/builtin/builtin.go
copysrcmin(len(dst), len(src))dst
min(len(dst), len(src))len(dst)copy
copy

总结

对于slice,时刻想着对slice做了修改后,slice里的3个字段:指针,长度,容量是怎么变的。
slicearraylencapslice:slicearrayslice:::appendcopyslicesliceslicemapchannel

开源地址

文章和示例代码开源地址在GitHub: https://github.com/jincheng9/go-tutorial

公众号:coding进阶

欢迎大家关注。

思考题

留下2道思考题,欢迎大家在评论区留下你们的答案。想确认答案的也可以在我的wx公号发送消息 slice 获取答案和原因。

  • 题目1:
    package main

    import "fmt"

    func main() {
    a := []int{1, 2}
    b := append(a, 3)

    c := append(b, 4)
    d := append(b, 5)

    fmt.Println(a, b, c[3], d[3])
    }
  • 题目2
    package main

    import "fmt"

    func main() {
    s := []int{1, 2}
    s = append(s, 4, 5, 6)
    fmt.Println(len(s), cap(s))
    }

References