02.Golang 切片(slice)源码分析(二、append实现)

Golang 切片(slice)源码分析(二、append实现)

前言:

Golang 切片(slice)源码分析(一、定义与基础操作实现)

在前面的文章我们介绍了,切片的结构体与创建\扩容等基本操作实现,这篇文章我们一起来学习一下切片append的实现逻辑

注意当前go版本代码为1.23

定义

先来看看 append 函数的原型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/builtin/builtin.go
// 内置函数append将元素追加到切片的末尾。
// 如果有足够的容量,则重新划分目标以容纳
// 新建元素。
// 如果没有,将分配一个新的底层数组。
// Append返回是更新后的切片。Go编译器不允许调用了 append 函数后不使用返回值。
// 因此,有必要存储append的结果,通常在保存切片本身的变量中:
// 常见用法:
// 添加元素
// slice = append(slice, elem1, elem2)
// 直接追加一个切片
// slice = append(slice, anotherSlice…)
//
// 作为特殊情况,将字符串附加到字节片是合法的,如下所示:
//
// slice = append([]byte("hello "), “world”…)
func append(slice []Type, elems ...Type) []Type

append函数返回值是一个新的slice,Go编译器不允许调用了 append 函数后不使用返回值。

1
2
append(slice, elem1, elem2)
append(slice, anotherSlice...)

所以上面的用法是错的,不能编译通过。

使用 append 可以向 slice 追加元素,实际上是往底层数组添加元素。但是底层数组的长度是固定的,如果索引 len-1 所指向的元素已经是底层数组的最后一个元素,就没法再添加了。

这时,slice 会迁移到新的内存位置,新底层数组的长度也会增加,这样就可以放置新增的元素。同时,为了应对未来可能再次发生的 append 操作,新的底层数组的长度,也就是新 slice 的容量是留了一定的 buffer 的。否则,每次添加元素的时候,都会发生迁移,成本太高。

编译过程

Go编译可分为四个阶段:词法与语法分析、类型检查与抽象语法树(AST)转换、中间代码生成和生成最后的机器码。

我们主要需要关注的是编译期第二和第三阶段的代码,分别是位于src/cmd/compile/internal/typecheck/typecheck.go下的类型检查逻辑

1
2
3
4
5
6
func typecheck1(n *Node, top int) (res *Node) {
...
switch n.Op {
case OAPPEND:
...
}

位于src/cmd/compile/internal/gc/walk.go下的抽象语法树转换逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func walkexpr(n *Node, init *Nodes) *Node {
...
case OAPPEND:
// x = append(...)
r := n.Right
if r.Type.Elem().NotInHeap() {
yyerror("%v can't be allocated in Go; it is incomplete (or unallocatable)", r.Type.Elem())
}
switch {
case isAppendOfMake(r):
// x = append(y, make([]T, y)...)
r = extendslice(r, init)
case r.IsDDD():
r = appendslice(r, init) // also works for append(slice, string).
default:
r = walkappend(r, init, n)
}
...
}

和位于src/cmd/compile/internal/gc/ssa.go下的中间代码生成逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func (s *state) exprCheckPtr(n ir.Node, checkPtrOK bool) *ssa.Value {
...
switch n.Op {
case ir.OAPPEND:
return s.append(n.(*ir.CallExpr), false)
}

// append 将 OAPPEND 节点转换为 SSA形式。SSA,Static Single Assignment,静态单赋值,是Go编译器在优化阶段使用的一种中间代码表示形式。在SSA形式
// 中,每个变量只会被赋值一次。这意味着一旦一个变量被赋值,它的值就不会再改变。用于简化和改进编译器优化
// 如果 inplace 为 false,它将 OAPPEND 表达式 n 转换为 ssa.Value,
// 将其添加到 s,并返回 Value。
// 如果 inplace 为 true,它会将 OAPPEND 表达式 n 的结果
// 写回到被追加的切片中,并返回 nil。
// 如果切片可以被 SSA 化,则 inplace 必须设置为 false。
// 注意:此代码仅处理固定数量的追加。 Dotdotdot 追加
// 此时已被重写(通过 walk)。
func (s *state) append(n *ir.CallExpr, inplace bool) *ssa.Value {
...
}

其中,中间代码生成阶段的state.append方法,是我们重点关注的地方。入参 inplace 代表返回值是否覆盖原变量。如果为false,展开逻辑如下(注意:以下代码只是为了方便理解的伪代码,并不是 state.append 中实际的代码)。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 如果 inplace 为 false,则处理表达式 "append(s, e1, e2, e3)":
//
// ptr, len, cap := s
// len += 3
// if uint(len) > uint(cap) {
// ptr, len, cap = growslice(ptr, len, cap, 3, typ)
// 注意,growslice 不会修改 len。
// }
// // 如果需要,使用写屏障:
// *(ptr+(len-3)) = e1
// *(ptr+(len-2)) = e2
// *(ptr+(len-1)) = e3
// return makeslice(ptr, len, cap)

如果是true,例如 slice = append(slice, 1, 2, 3) 语句,那么返回值会覆盖原变量。展开方式逻辑如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 // 如果 inplace 为 true,则处理语句 "s = append(s, e1, e2, e3)":
// a := &s
// ptr, len, cap := s
// len += 3
// if uint(len) > uint(cap) {
// ptr, len, cap = growslice(ptr, len, cap, 3, typ)
// vardef(a) // 如果需要,通知 liveness 我们正在写入一个新的 a
// *a.cap = cap // 在 ptr 之前写入以避免溢出
// *a.ptr = ptr // 使用写屏障
// }
// *a.len = len
// // 如果需要,使用写屏障:
// *(ptr+(len-3)) = e1
// *(ptr+(len-2)) = e2
// *(ptr+(len-1)) = e3

不管 inpalce 是否为true,我们均会获取切片的数组指针、大小和容量,如果在追加元素后,切片新的大小大于原始容量,就会调用 runtime.growslice 对切片进行扩容,并将新的元素依次加入切片。

关于growslice的源码分析可参考Golang 切片(slice)源码分析(一、定义与基础操作实现)

下述为go1.23源码逻辑src/runtime/slice.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
// growslice 为一个切片分配新的底层存储。
//
// 参数:
// oldPtr = 指向切片底层数组的指针
// newLen = 新的长度(= oldLen + num)
// oldCap = 原始切片的容量
// num = 正在添加的元素数量
// et = 元素类型
// 返回值:
// newPtr = 指向新底层存储的指针
// newLen = 新的长度与传参相同
// newCap = 新底层存储的容量
//
// 要求 uint(newLen) > uint(oldCap)。
// 假设原始切片的长度为 newLen - num
//
// 分配一个至少能容纳 newLen 个元素的新底层存储。
// 已存在的条目 [0, oldLen) 被复制到新的底层存储中。
// 添加的条目 [oldLen, newLen) 不会被 growslice 初始化
// (虽然对于包含指针的元素类型,它们会被清零)。调用者必须初始化这些条目。
// 尾随条目 [newLen, newCap) 被清零。
//
// growslice 的特殊调用约定使得调用此函数的生成代码更简单。特别是,它接受并返回
// 新的长度,这样旧的长度不需要被保留/恢复,而新的长度返回时也不需要被保留/恢复。
func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice {
oldLen := newLen - num
// ... 函数非核心部分省略

if newLen < 0 {
panic(errorString("growslice: len out of range"))
}

if et.Size_ == 0 {
// append不应该创建一个指针为nil但长度非零的切片。
// 我们假设在这种情况下,append不需要保留oldPtr。
return slice{unsafe.Pointer(&zerobase), newLen, newLen}
}

// 1.计算新容量
newcap := nextslicecap(newLen, oldCap)

var overflow bool
var lenmem, newlenmem, capmem uintptr
// 针对常见的 et.Size 进行优化
// 对于1我们不需要任何除法/乘法。
// 对于 goarch.PtrSize, 编译器会优化除法/乘法为一个常量移位。
// 对于2的幂,使用变量移位。
noscan := !et.Pointers()
// 2.内存对齐
switch {
case et.Size_ == 1:
lenmem = uintptr(oldLen)
newlenmem = uintptr(newLen)
capmem = roundupsize(uintptr(newcap), noscan)
overflow = uintptr(newcap) > maxAlloc
newcap = int(capmem)
case et.Size_ == goarch.PtrSize:
lenmem = uintptr(oldLen) * goarch.PtrSize
newlenmem = uintptr(newLen) * goarch.PtrSize
capmem = roundupsize(uintptr(newcap)*goarch.PtrSize, noscan)
overflow = uintptr(newcap) > maxAlloc/goarch.PtrSize
newcap = int(capmem / goarch.PtrSize)
case isPowerOfTwo(et.Size_):
var shift uintptr
if goarch.PtrSize == 8 {
shift = uintptr(sys.TrailingZeros64(uint64(et.Size_))) & 63
} else {
shift = uintptr(sys.TrailingZeros32(uint32(et.Size_))) & 31
}
lenmem = uintptr(oldLen) << shift
newlenmem = uintptr(newLen) << shift
capmem = roundupsize(uintptr(newcap)<<shift, noscan)
overflow = uintptr(newcap) > (maxAlloc >> shift)
newcap = int(capmem >> shift)
capmem = uintptr(newcap) << shift
default:
lenmem = uintptr(oldLen) * et.Size_
newlenmem = uintptr(newLen) * et.Size_
capmem, overflow = math.MulUintptr(et.Size_, uintptr(newcap))
capmem = roundupsize(capmem, noscan)
newcap = int(capmem / et.Size_)
capmem = uintptr(newcap) * et.Size_
}
// ... 函数非核心部分省略
}


核心代码:
// nextslicecap 计算下一个合适的切片容量。
// 该函数用于在切片需要扩容时,确定新的容量大小。
// newLen: 切片的新长度(所需容量)。
// oldCap: 切片的旧容量。
// 返回值: 新的切片容量。
func nextslicecap(newLen, oldCap int) int {
newcap := oldCap // 将新的容量初始化为旧的容量

doublecap := newcap + newcap // 计算旧容量的两倍

// 如果新长度大于旧容量的两倍,则直接使用新长度作为新容量
// 这是为了避免频繁的扩容操作,当所需长度远大于当前容量时,直接分配所需的空间
if newLen > doublecap {
return newLen
}

// 设置一个阈值,用于区分小切片和大切片
const threshold = 256

// 对于容量小于阈值的小切片,新容量直接设置为旧容量的两倍
// 这是因为小切片的扩容成本相对较低
if oldCap < threshold {
return doublecap
}

// 对于容量大于等于阈值的大切片,采用更保守的扩容策略
for {
// 从2倍增长(小切片)过渡到1.25倍增长(大切片)。
// 该公式在两者之间提供平滑的过渡。
// (newcap + 3*threshold) >> 2 等价于 (newcap + 3*threshold) / 4
// 这使得新容量的增长比例在1.25到2之间,并随着切片容量的增大而逐渐接近1.25
newcap += (newcap + 3*threshold) >> 2

// 需要检查 `newcap >= newLen` 以及 `newcap` 是否溢出。
// newLen 保证大于零,因此当 newcap 溢出时,`uint(newcap) > uint(newLen)` 不成立。
// 这允许使用相同的比较来检查两者。
// 使用uint类型进行比较是为了处理溢出情况。如果newcap溢出变成负数,转换为uint类型后会变成一个很大的正数,从而使得比较仍然有效。
if uint(newcap) >= uint(newLen) {
break // 如果新容量足够大,则退出循环
}
}

// 当 newcap 计算溢出时,将 newcap 设置为请求的容量。
// 如果 newcap 小于等于 0,说明发生了溢出
if newcap <= 0 {
return newLen
}
return newcap // 返回计算出的新容量
}


// roundupsize 返回 mallocgc 为指定大小分配的内存块的大小,减去任何用于元数据的内联空间。
// size: 请求分配的内存大小。
// noscan: 如果为 true,则表示该内存块不需要垃圾回收扫描。
// 返回值: mallocgc 实际分配的内存块大小。
func roundupsize(size uintptr, noscan bool) (reqSize uintptr) {
reqSize = size // 初始化请求大小

// 处理小对象(小于等于 maxSmallSize-mallocHeaderSize)
if reqSize <= maxSmallSize-mallocHeaderSize {
// 小对象。
// 如果需要垃圾回收扫描 (noscan 为 false) 并且大小大于 minSizeForMallocHeader,则添加 mallocHeaderSize 用于存储元数据。
// heapBitsInSpan(reqSize) 用于检查对象是否足够小到可以存储在堆的位图中,如果可以,则不需要 mallocHeader。
if !noscan && reqSize > minSizeForMallocHeader { // !noscan && !heapBitsInSpan(reqSize)
reqSize += mallocHeaderSize
}

// (reqSize - size) 为 mallocHeaderSize 或 0。如果添加了 mallocHeaderSize,我们需要从结果中减去它,因为 mallocgc 会再次添加它。
// 这里是为了确保返回的大小是 mallocgc 实际分配的大小,而不是包含了头部之后的大小。

// 进一步区分更小的对象和中等大小的对象,使用不同的查找表进行向上取整
if reqSize <= smallSizeMax-8 {
// 对于非常小的对象,使用 size_to_class8 和 class_to_size 查找表进行向上取整,以 8 字节为粒度。
// divRoundUp(reqSize, smallSizeDiv) 计算 reqSize 在 smallSizeDiv 粒度下的向上取整值。
// class_to_size[...] 获取对应大小类的实际分配大小。
// 最后减去 (reqSize - size) 移除之前可能添加的 mallocHeaderSize。
return uintptr(class_to_size[size_to_class8[divRoundUp(reqSize, smallSizeDiv)]]) - (reqSize - size)
}
// 对于中等大小的对象,使用 size_to_class128 和 class_to_size 查找表进行向上取整,以 128 字节为粒度。
return uintptr(class_to_size[size_to_class128[divRoundUp(reqSize-smallSizeMax, largeSizeDiv)]]) - (reqSize - size)
}

// 处理大对象(大于 maxSmallSize-mallocHeaderSize)
// 大对象。将 reqSize 向上对齐到下一页。检查溢出。
reqSize += pageSize - 1 // 将 reqSize 增加到下一页边界之前

// 检查溢出。如果 reqSize 加上 pageSize - 1 后反而变小了,说明发生了溢出。
if reqSize < size {
return size // 返回原始大小,避免分配过大的内存
}

// 通过按位与运算将 reqSize 对齐到下一页边界。
return reqSize &^ (pageSize - 1)
}

问题

【引申1】

来看一个例子,来源于这里

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func main() {
s := []int{5}
s = append(s, 7)
s = append(s, 9)
x := append(s, 11)
fmt.Println(s, x)
y := append(s, 12)
fmt.Println(s, x, y)
}
代码 切片对应状态
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
2
[5 7 9] [5 7 9 11]
[5 7 9] [5 7 9 12] [5 7 9 12]

这里要注意的是,append函数执行完后,返回的是一个全新的 slice,并且对传入的 slice 并不影响。

解释

  • 切片在追加元素时,如果不超过其容量,会直接在原数组上修改。

【引申2】

关于 append,来源于 Golang Slice的扩容规则

1
2
3
4
5
6
7
8
9
package main

import "fmt"

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

运行结果是:

1
len=5, cap=6

如果按网上各种文章中总结的那样:小于原 slice 长度小于 1024 的时候,容量每次增加 1 倍。添加元素 4 的时候,容量变为4;添加元素 5 的时候不变;添加元素 6 的时候容量增加 1 倍,变成 8。

那上面代码的运行结果应该是是:

1
`len=5, cap=8 `

这是错误的!我们来仔细看看,为什么会这样,再次搬出代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// nextslicecap 计算下一个合适的切片容量。
// 该函数用于在切片需要扩容时,确定新的容量大小。
// newLen: 切片的新长度(所需容量)。
// oldCap: 切片的旧容量。
// 返回值: 新的切片容量。
func nextslicecap(newLen, oldCap int) int {
newcap := oldCap // 将新的容量初始化为旧的容量

doublecap := newcap + newcap // 计算旧容量的两倍

// 如果新长度大于旧容量的两倍,则直接使用新长度作为新容量
// 这是为了避免频繁的扩容操作,当所需长度远大于当前容量时,直接分配所需的空间
if newLen > doublecap {
return newLen
}

// 设置一个阈值,用于区分小切片和大切片
const threshold = 256

// 对于容量小于阈值的小切片,新容量直接设置为旧容量的两倍
// 这是因为小切片的扩容成本相对较低
if oldCap < threshold {
return doublecap
}

// 对于容量大于等于阈值的大切片,采用更保守的扩容策略
for {
// 从2倍增长(小切片)过渡到1.25倍增长(大切片)。
// 该公式在两者之间提供平滑的过渡。
// (newcap + 3*threshold) >> 2 等价于 (newcap + 3*threshold) / 4
// 这使得新容量的增长比例在1.25到2之间,并随着切片容量的增大而逐渐接近1.25
newcap += (newcap + 3*threshold) >> 2

// 需要检查 `newcap >= newLen` 以及 `newcap` 是否溢出。
// newLen 保证大于零,因此当 newcap 溢出时,`uint(newcap) > uint(newLen)` 不成立。
// 这允许使用相同的比较来检查两者。
// 使用uint类型进行比较是为了处理溢出情况。如果newcap溢出变成负数,转换为uint类型后会变成一个很大的正数,从而使得比较仍然有效。
if uint(newcap) >= uint(newLen) {
break // 如果新容量足够大,则退出循环
}
}

// 当 newcap 计算溢出时,将 newcap 设置为请求的容量。
// 如果 newcap 小于等于 0,说明发生了溢出
if newcap <= 0 {
return newLen
}
return newcap // 返回计算出的新容量
}

这个函数的参数依次是 元素的类型,老的 slice,新 slice 最小求的容量

例子中 s 原来只有 2 个元素,lencap 都为 2,append 了三个元素后,长度变为 5,容量最小要变成 5,即调用 growslice 函数时,传入的第三个参数应该为 5。即 cap=5。而一方面,doublecap 是原 slice容量的 2 倍,等于 4。满足第一个 if 条件,所以 newcap 变成了 5。

接着调用了 roundupsize 函数,传入 40。(代码中ptrSize是指一个指针的大小,在64位机上是8)

我们再看内存对齐,搬出 roundupsize 函数的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// roundupsize 返回 mallocgc 为指定大小分配的内存块的大小,减去任何用于元数据的内联空间。
// size: 请求分配的内存大小。
// noscan: 如果为 true,则表示该内存块不需要垃圾回收扫描。
// 返回值: mallocgc 实际分配的内存块大小。
func roundupsize(size uintptr, noscan bool) (reqSize uintptr) {
reqSize = size // 初始化请求大小

// 处理小对象(小于等于 maxSmallSize-mallocHeaderSize)
if reqSize <= maxSmallSize-mallocHeaderSize {
// 小对象。
// 如果需要垃圾回收扫描 (noscan 为 false) 并且大小大于 minSizeForMallocHeader,则添加 mallocHeaderSize 用于存储元数据。
// heapBitsInSpan(reqSize) 用于检查对象是否足够小到可以存储在堆的位图中,如果可以,则不需要 mallocHeader。
if !noscan && reqSize > minSizeForMallocHeader { // !noscan && !heapBitsInSpan(reqSize)
reqSize += mallocHeaderSize
}

// (reqSize - size) 为 mallocHeaderSize 或 0。如果添加了 mallocHeaderSize,我们需要从结果中减去它,因为 mallocgc 会再次添加它。
// 这里是为了确保返回的大小是 mallocgc 实际分配的大小,而不是包含了头部之后的大小。

// 进一步区分更小的对象和中等大小的对象,使用不同的查找表进行向上取整
if reqSize <= smallSizeMax-8 {
// 对于非常小的对象,使用 size_to_class8 和 class_to_size 查找表进行向上取整,以 8 字节为粒度。
// divRoundUp(reqSize, smallSizeDiv) 计算 reqSize 在 smallSizeDiv 粒度下的向上取整值。
// class_to_size[...] 获取对应大小类的实际分配大小。
// 最后减去 (reqSize - size) 移除之前可能添加的 mallocHeaderSize。
return uintptr(class_to_size[size_to_class8[divRoundUp(reqSize, smallSizeDiv)]]) - (reqSize - size)
}
// 对于中等大小的对象,使用 size_to_class128 和 class_to_size 查找表进行向上取整,以 128 字节为粒度。
return uintptr(class_to_size[size_to_class128[divRoundUp(reqSize-smallSizeMax, largeSizeDiv)]]) - (reqSize - size)
}

// 处理大对象(大于 maxSmallSize-mallocHeaderSize)
// 大对象。将 reqSize 向上对齐到下一页。检查溢出。
reqSize += pageSize - 1 // 将 reqSize 增加到下一页边界之前

// 检查溢出。如果 reqSize 加上 pageSize - 1 后反而变小了,说明发生了溢出。
if reqSize < size {
return size // 返回原始大小,避免分配过大的内存
}

// 通过按位与运算将 reqSize 对齐到下一页边界。
return reqSize &^ (pageSize - 1)
}

很明显,我们最终将返回这个式子的结果:

1
class_to_size[size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv]] 

这是 Go 源码中有关内存分配的两个 sliceclass_to_size通过 spanClass获取 span划分的 object大小。而 size_to_class8 表示通过 size 获取它的 spanClass

1
2
3
var size_to_class8 = [smallSizeMax/smallSizeDiv + 1]uint8{0, 1, 2, 3, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13, 14, 14, 15, 15, 16, 16, 17, 17, 18, 18, 19, 19, 19, 19, 20, 20, 20, 20, 21, 21, 21, 21, 22, 22, 22, 22, 23, 23, 23, 23, 24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 27, 27, 27, 27, 27, 27, 27, 27, 28, 28, 28, 28, 28, 28, 28, 28, 29, 29, 29, 29, 29, 29, 29, 29, 30, 30, 30, 30, 30, 30, 30, 30, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32}

var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536, 1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768}

我们传进去的 size 等于 40。所以 (size+smallSizeDiv-1)/smallSizeDiv = 5;获取 size_to_class8 数组中索引为 5 的元素为 5;获取 class_to_size 中索引为 5 的元素为 48

最终,新的 slice 的容量为 6

1
newcap = int(capmem / ptrSize) // 6 

至于,上面的两个魔法数组的由来,就不展开了。

【引申3】 向一个nil的slice添加元素会发生什么?为什么?

其实 nil slice 或者 empty slice 都是可以通过调用 append 函数来获得底层数组的扩容。最终都是调用 mallocgc 来向 Go 的内存管理器申请到一块内存,然后再赋给原来的nil sliceempty slice,然后摇身一变,成为“真正”的 slice 了。

【引申4】两次append的data和slice内的数据是什么?

1
2
3
4
data := [10]int{}
slice := data[5:8]
slice = append(slice,9)// slice=? data=?
slice = append(slice,10,11,12)// slice=? data=?

流程如下:

初始状态

1
data := [10]int{}

这里定义了一个长度为10的整型数组data,所有元素初始化为0。

创建切片

1
slice := data[5:8]

这里创建了一个切片slice,它引用data数组从索引5到7的元素。因此,slice的初始状态是[0, 0, 0]

第一次追加元素

1
slice = append(slice, 9)

这里向slice中追加一个元素9。由于slice的容量足够(data数组的容量是10,slice当前的长度是3,容量是5),这个追加操作不会导致新的数组分配。因此,slice变为[0, 0, 0, 9],同时data数组也会被更新为:

1
[0, 0, 0, 0, 0, 0, 0, 0, 9, 0]

第二次追加元素

1
slice = append(slice, 10, 11, 12)

这里向slice中追加三个元素10, 11, 12。由于slice当前的长度是4,容量是5,追加三个元素会超出当前容量,因此Go会为slice分配一个新的数组来存储这些元素。

新的slice将是[0, 0, 0, 9, 10, 11, 12],而原来的data数组不会受到影响,保持不变:

1
[0, 0, 0, 0, 0, 0, 0, 0, 9, 0]

总结

  • 第一次追加后:
    • 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
2
3
4
5
6
7
8
9
10
11
	data := [10]int{}
slice := data[5:8]
slice = append(slice, 9) // slice=? data=?
fmt.Printf("slice=?", slice)
fmt.Printf("data=?", data)
slice = append(slice, 10) // slice=? data=?
fmt.Printf("slice=?", slice)
fmt.Printf("data=?", data)
输出:
slice=?%!(EXTRA []int=[0 0 0 9])data=?%!(EXTRA [10]int=[0 0 0 0 0 0 0 0 9 0])
slice=?%!(EXTRA []int=[0 0 0 9 10])data=?%!(EXTRA [10]int=[0 0 0 0 0 0 0 0 9 10]) //因为未超出其容量

【引申5】切片作为函数参数是值传递还是引用传递,取自Go 程序员面试笔试宝典

Go 语言的函数参数传递,只有值传递,没有引用传递。

当 slice 作为函数参数时,就是一个普通的结构体。其实很好理解:若直接传 slice,在调用者看来,实参 slice 并不会被函数中的操作改变;若传的是 slice 的指针,在调用者看来,是会被改变原 slice 的。

值得注意的是,不管传的是 slice 还是 slice 指针,如果改变了 slice 底层数组的数据,会反应到实参 slice 的底层数据。为什么能改变底层数组的数据?很好理解:底层数据在 slice 结构体里是一个指针,尽管 slice 结构体自身不会被改变,也就是说底层数据地址不会被改变。 但是通过指向底层数据的指针,可以改变切片的底层数据,没有问题。

通过 slice 的 array 字段就可以拿到数组的地址。在代码里,是直接通过类似 s[i]=10 这种操作改变 slice 底层数组元素值。

来看一个代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

func main() {
s := []int{1, 1, 1}
f(s)
fmt.Println(s)
}

func f(s []int) {
// i只是一个副本,不能改变s中元素的值
/*for _, i := range s {
i++
}
*/

for i := range s {
s[i] += 1
}
}

运行一下,程序输出:

1
[2 2 2]

果真改变了原始 slice 的底层数据。这里传递的是一个 slice 的副本,在 f 函数中,s 只是 main 函数中 s 的一个拷贝。在f 函数内部,对 s 的作用并不会改变外层 main 函数的 s

要想真的改变外层 slice,只有将返回的新的 slice 赋值到原始 slice,或者向函数传递一个指向 slice 的指针。再来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import "fmt"

func myAppend(s []int) []int {
// 这里 s 虽然改变了,但并不会影响外层函数的 s
s = append(s, 100)
return s
}

func myAppendPtr(s *[]int) {
// 会改变外层 s 本身
*s = append(*s, 100)
return
}

func main() {
s := []int{1, 1, 1}
newS := myAppend(s)

fmt.Println(s)
fmt.Println(newS)

s = newS

myAppendPtr(&s)
fmt.Println(s)
}
1
2
3
[1 1 1]
[1 1 1 100]
[1 1 1 100 100]

myAppend 函数里,虽然改变了 s,但它只是一个值传递,并不会影响外层的 s,因此第一行打印出来的结果仍然是 [1 1 1]

newS 是一个新的 slice,它是基于 s 得到的。因此它打印的是追加了一个 100 之后的结果: [1 1 1 100]

最后,将 newS 赋值给了 ss 这时才真正变成了一个新的slice。之后,再给 myAppendPtr 函数传入一个 s 指针,这回它真的被改变了:[1 1 1 100 100]

参考链接

1.Go 程序员面试笔试宝典

2.《Go学习笔记》

3.golangSlice的扩容规则

4.Go append 扩容机制


02.Golang 切片(slice)源码分析(二、append实现)
https://blog.longpi1.com/2024/11/10/02-Golang-切片(slice)源码分析(二、append实现)/