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学习笔记》