02.Golang 切片(slice)源码分析(二、append实现)
Golang 切片(slice)源码分析(二、append实现)
前言:
Golang 切片(slice)源码分析(一、定义与基础操作实现)
在前面的文章我们介绍了,切片的结构体与创建\扩容等基本操作实现,这篇文章我们一起来学习一下切片append的实现逻辑
注意当前go版本代码为1.23
定义
先来看看 append 函数的原型:
1 | |
append函数返回值是一个新的slice,Go编译器不允许调用了 append 函数后不使用返回值。
1 | |
所以上面的用法是错的,不能编译通过。
使用 append 可以向 slice 追加元素,实际上是往底层数组添加元素。但是底层数组的长度是固定的,如果索引 len-1 所指向的元素已经是底层数组的最后一个元素,就没法再添加了。
这时,slice 会迁移到新的内存位置,新底层数组的长度也会增加,这样就可以放置新增的元素。同时,为了应对未来可能再次发生的 append 操作,新的底层数组的长度,也就是新 slice 的容量是留了一定的 buffer 的。否则,每次添加元素的时候,都会发生迁移,成本太高。
编译过程
Go编译可分为四个阶段:词法与语法分析、类型检查与抽象语法树(AST)转换、中间代码生成和生成最后的机器码。
我们主要需要关注的是编译期第二和第三阶段的代码,分别是位于src/cmd/compile/internal/typecheck/typecheck.go下的类型检查逻辑
1 | |
位于src/cmd/compile/internal/gc/walk.go下的抽象语法树转换逻辑
1 | |
和位于src/cmd/compile/internal/gc/ssa.go下的中间代码生成逻辑
1 | |
其中,中间代码生成阶段的state.append方法,是我们重点关注的地方。入参 inplace 代表返回值是否覆盖原变量。如果为false,展开逻辑如下(注意:以下代码只是为了方便理解的伪代码,并不是 state.append 中实际的代码)。
1 | |
如果是true,例如 slice = append(slice, 1, 2, 3) 语句,那么返回值会覆盖原变量。展开方式逻辑如下
1 | |
不管 inpalce 是否为true,我们均会获取切片的数组指针、大小和容量,如果在追加元素后,切片新的大小大于原始容量,就会调用 runtime.growslice 对切片进行扩容,并将新的元素依次加入切片。
关于growslice的源码分析可参考Golang 切片(slice)源码分析(一、定义与基础操作实现)
下述为go1.23源码逻辑src/runtime/slice.go :
1 | |
问题
【引申1】
来看一个例子,来源于这里
1 | |
| 代码 | 切片对应状态 |
|---|---|
| s := []int{5} | s 只有一个元素,[5] |
| s = append(s, 7) | s 扩容,容量变为2,[5, 7] |
| s = append(s, 9) | s 扩容,容量变为4,[5, 7, 9]。注意,这时 s 长度是3,只有3个元素 |
| x := append(s, 11) | 由于 s 的底层数组仍然有空间,因此并不会扩容。这样,底层数组就变成了 [5, 7, 9, 11]。注意,此时 s = [5, 7, 9],容量为4;x = [5, 7, 9, 11],容量为4。这里 s 不变 |
| y := append(s, 12) | 这里还是在 s 元素的尾部追加元素,由于 s 的长度为3,容量为4,所以直接在底层数组索引为3的地方填上12。结果:s = [5, 7, 9],y = [5, 7, 9, 12],x = [5, 7, 9, 12],x,y 的长度均为4,容量也均为4 |
所以最后程序的执行结果是:
1 | |
这里要注意的是,append函数执行完后,返回的是一个全新的 slice,并且对传入的 slice 并不影响。
解释
- 切片在追加元素时,如果不超过其容量,会直接在原数组上修改。
【引申2】
关于 append,来源于 Golang Slice的扩容规则。
1 | |
运行结果是:
1 | |
如果按网上各种文章中总结的那样:小于原 slice 长度小于 1024 的时候,容量每次增加 1 倍。添加元素 4 的时候,容量变为4;添加元素 5 的时候不变;添加元素 6 的时候容量增加 1 倍,变成 8。
那上面代码的运行结果应该是是:
1 | |
这是错误的!我们来仔细看看,为什么会这样,再次搬出代码:
1 | |
这个函数的参数依次是 元素的类型,老的 slice,新 slice 最小求的容量。
例子中 s 原来只有 2 个元素,len 和 cap 都为 2,append 了三个元素后,长度变为 5,容量最小要变成 5,即调用 growslice 函数时,传入的第三个参数应该为 5。即 cap=5。而一方面,doublecap 是原 slice容量的 2 倍,等于 4。满足第一个 if 条件,所以 newcap 变成了 5。
接着调用了 roundupsize 函数,传入 40。(代码中ptrSize是指一个指针的大小,在64位机上是8)
我们再看内存对齐,搬出 roundupsize 函数的代码:
1 | |
很明显,我们最终将返回这个式子的结果:
1 | |
这是 Go 源码中有关内存分配的两个 slice。class_to_size通过 spanClass获取 span划分的 object大小。而 size_to_class8 表示通过 size 获取它的 spanClass。
1 | |
我们传进去的 size 等于 40。所以 (size+smallSizeDiv-1)/smallSizeDiv = 5;获取 size_to_class8 数组中索引为 5 的元素为 5;获取 class_to_size 中索引为 5 的元素为 48。
最终,新的 slice 的容量为 6:
1 | |
至于,上面的两个魔法数组的由来,就不展开了。
【引申3】 向一个nil的slice添加元素会发生什么?为什么?
其实 nil slice 或者 empty slice 都是可以通过调用 append 函数来获得底层数组的扩容。最终都是调用 mallocgc 来向 Go 的内存管理器申请到一块内存,然后再赋给原来的nil slice 或 empty slice,然后摇身一变,成为“真正”的 slice 了。
【引申4】两次append的data和slice内的数据是什么?
1 | |
流程如下:
初始状态
1 | |
这里定义了一个长度为10的整型数组data,所有元素初始化为0。
创建切片
1 | |
这里创建了一个切片slice,它引用data数组从索引5到7的元素。因此,slice的初始状态是[0, 0, 0]。
第一次追加元素
1 | |
这里向slice中追加一个元素9。由于slice的容量足够(data数组的容量是10,slice当前的长度是3,容量是5),这个追加操作不会导致新的数组分配。因此,slice变为[0, 0, 0, 9],同时data数组也会被更新为:
1 | |
第二次追加元素
1 | |
这里向slice中追加三个元素10, 11, 12。由于slice当前的长度是4,容量是5,追加三个元素会超出当前容量,因此Go会为slice分配一个新的数组来存储这些元素。
新的slice将是[0, 0, 0, 9, 10, 11, 12],而原来的data数组不会受到影响,保持不变:
1 | |
总结
- 第一次追加后:
slice = [0, 0, 0, 9]data = [0, 0, 0, 0, 0, 0, 0, 0, 9, 0]
- 第二次追加后:
slice = [0, 0, 0, 9, 10, 11, 12]data = [0, 0, 0, 0, 0, 0, 0, 0, 9, 0]
解释
- 切片在追加元素时,如果不超过其容量,会直接在原数组上修改。
- 如果追加元素导致超出容量,Go会分配一个新的数组,并将现有元素和新元素复制到新数组中,原数组保持不变。
- append存在对原数据影响的情况,使用时还是需要注意,如有必要,先copy原数据后再进行slice的操作。
如果是:
1 | |
【引申5】切片作为函数参数是值传递还是引用传递,取自Go 程序员面试笔试宝典
Go 语言的函数参数传递,只有值传递,没有引用传递。
当 slice 作为函数参数时,就是一个普通的结构体。其实很好理解:若直接传 slice,在调用者看来,实参 slice 并不会被函数中的操作改变;若传的是 slice 的指针,在调用者看来,是会被改变原 slice 的。
值得注意的是,不管传的是 slice 还是 slice 指针,如果改变了 slice 底层数组的数据,会反应到实参 slice 的底层数据。为什么能改变底层数组的数据?很好理解:底层数据在 slice 结构体里是一个指针,尽管 slice 结构体自身不会被改变,也就是说底层数据地址不会被改变。 但是通过指向底层数据的指针,可以改变切片的底层数据,没有问题。
通过 slice 的 array 字段就可以拿到数组的地址。在代码里,是直接通过类似 s[i]=10 这种操作改变 slice 底层数组元素值。
来看一个代码片段:
1 | |
运行一下,程序输出:
1 | |
果真改变了原始 slice 的底层数据。这里传递的是一个 slice 的副本,在 f 函数中,s 只是 main 函数中 s 的一个拷贝。在f 函数内部,对 s 的作用并不会改变外层 main 函数的 s。
要想真的改变外层 slice,只有将返回的新的 slice 赋值到原始 slice,或者向函数传递一个指向 slice 的指针。再来看一个例子:
1 | |
1 | |
myAppend 函数里,虽然改变了 s,但它只是一个值传递,并不会影响外层的 s,因此第一行打印出来的结果仍然是 [1 1 1]。
而 newS 是一个新的 slice,它是基于 s 得到的。因此它打印的是追加了一个 100 之后的结果: [1 1 1 100]。
最后,将 newS 赋值给了 s,s 这时才真正变成了一个新的slice。之后,再给 myAppendPtr 函数传入一个 s 指针,这回它真的被改变了:[1 1 1 100 100]。
参考链接
2.《Go学习笔记》