当前位置: 首页 > news >正文

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

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

前言:

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

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

注意当前go版本代码为1.23

定义

先来看看 append 函数的原型:

// 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 函数后不使用返回值。

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

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

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

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

编译过程

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

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

func typecheck1(n *Node, top int) (res *Node) {...switch n.Op {case OAPPEND:...
}

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

func walkexpr(n *Node, init *Nodes) *Node {...case OAPPEND:// x = append(...)r := n.Rightif 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下的中间代码生成逻辑

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 中实际的代码)。

        // 如果 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) 语句,那么返回值会覆盖原变量。展开方式逻辑如下

         // 如果 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 :

// 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 boolvar 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) > maxAllocnewcap = int(capmem)case et.Size_ == goarch.PtrSize:lenmem = uintptr(oldLen) * goarch.PtrSizenewlenmem = uintptr(newLen) * goarch.PtrSizecapmem = roundupsize(uintptr(newcap)*goarch.PtrSize, noscan)overflow = uintptr(newcap) > maxAlloc/goarch.PtrSizenewcap = int(capmem / goarch.PtrSize)case isPowerOfTwo(et.Size_):var shift uintptrif goarch.PtrSize == 8 {shift = uintptr(sys.TrailingZeros64(uint64(et.Size_))) & 63} else {shift = uintptr(sys.TrailingZeros32(uint32(et.Size_))) & 31}lenmem = uintptr(oldLen) << shiftnewlenmem = uintptr(newLen) << shiftcapmem = roundupsize(uintptr(newcap)<<shift, noscan)overflow = uintptr(newcap) > (maxAlloc >> shift)newcap = int(capmem >> shift)capmem = uintptr(newcap) << shiftdefault: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.25newcap += (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】

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

package mainimport "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

所以最后程序的执行结果是:

[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的扩容规则。

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

运行结果是:

len=5, cap=6

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

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

`len=5, cap=8 `

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

// 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.25newcap += (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 函数的代码:

// 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)
}

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

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

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

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

newcap = int(capmem / ptrSize) // 6 

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

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

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

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

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

流程如下:

初始状态

data := [10]int{}

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

创建切片

slice := data[5:8]

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

第一次追加元素

slice = append(slice, 9)

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

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

第二次追加元素

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

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

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

[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的操作。

如果是:

	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 底层数组元素值。

来看一个代码片段:

package mainfunc 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}
}

运行一下,程序输出:

 [2 2 2]

果真改变了原始 slice 的底层数据。

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

package mainimport "fmt"func myAppend(s []int) []int {// 这里 s 虽然改变了,但并不会影响外层函数的 ss = append(s, 200) // append 超过容量,创建新的底层数组,调用者不可见s = s[2:]          // 切片操作,创建新的 slice,调用者不可见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 = newSmyAppendPtr(&s)fmt.Println(s)
}
[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 扩容机制

相关文章:

  • 循环语句:for、range -《Go语言实战指南》
  • 【layout组件 与 路由镶嵌】vue3 后台管理系统
  • uniapp(微信小程序)>关于父子组件的样式传递问题(自定义组件样式穿透)
  • 涨薪技术|0到1学会性能测试第58课-垃圾回收器
  • 【Pandas】pandas DataFrame cummax
  • 【LeeCode】1.两数之和
  • 读取.ini后缀类型的马达配置文件并进行赋值
  • 【操作系统】零拷贝技术
  • LearnOpenGL02:绘制三角形和矩形
  • 【办公类-99-06】20250512用Python制作PPT的GIF照片动图(统一图片大小、自定义不同切换秒数,以蝴蝶为例)
  • 家具制造行业的现状 质检LIMS如何赋能家具制造企业质检升级
  • 学习黑客5 分钟深入浅出理解系列之 Windows 资源监视器
  • LeetCode 热题 100_只出现一次的数字(96_136_简单_C++)(哈希表;哈希集合;排序+遍历;位运算)
  • Windows 安装 Milvus
  • 基于ESP32的健康智能机器人
  • 使用conda导致无法找到libpython动态库
  • 高粘度、强腐蚀介质解决方案:V型气动带手动活塞式开关调节球阀的五大核心优势-耀圣控制
  • react项目阅读记录
  • 做为一个平台,给第三方提供接口的时候,除了要求让他们申请 appId 和 AppSecret 之外,还应当有哪些安全选项,要过等保3级
  • 加固python文件
  • 外媒:初步结果显示,菲律宾前总统杜特尔特当选达沃市市长
  • 马上评丨75万采购300元设备,仅仅终止采购还不够
  • 马上评丨摆摊要交芙蓉王?对吃拿卡要必须零容忍
  • 梅花奖在上海|“我的乱弹我的团”,民营院团首次入围终评
  • 体坛联播|巴萨4比3打服皇马,利物浦2比2战平阿森纳
  • 让胖东来“自闭”的玉石生意,究竟水有多深?