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 个元素,len
和 cap
都为 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
源码中有关内存分配的两个 slice
。class_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 slice
或 empty 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
赋值给了 s
,s
这时才真正变成了一个新的slice。之后,再给 myAppendPtr
函数传入一个 s 指针
,这回它真的被改变了:[1 1 1 100 100]
。
参考链接
1.Go 程序员面试笔试宝典
2.《Go学习笔记》
3.golangSlice的扩容规则
4.Go append 扩容机制
相关文章:
03.Golang 切片(slice)源码分析(二、append实现)
Golang 切片(slice)源码分析(二、append实现) 前言: Golang 切片(slice)源码分析(一、定义与基础操作实现) 在前面的文章我们介绍了,切片的结构体与创建\扩容…...
Python实例题:pygame开发打飞机游戏
目录 Python实例题 题目 pygame-aircraft-game使用 Pygame 开发的打飞机游戏脚本 代码解释 初始化部分: 游戏主循环: 退出部分: 运行思路 注意事项 Python实例题 题目 pygame开发打飞机游戏 pygame-aircraft-game使用 Pygame 开发…...
MySQL创建了一个索引表,如何来验证这个索引表是否使用了呢?
MySQL创建了一个索引表,如何来验证这个索引表是否使用了呢? 1. 使用 EXPLAIN 分析查询执行计划 在 SQL 查询前添加 EXPLAIN 关键字,查看 MySQL 优化器是否选择了你的索引。 示例: EXPLAIN SELECT * FROM db关键输出字段: typ…...
Go语言多线程爬虫与代理IP反爬
有个朋友想用Go语言编写一个多线程爬虫,并且使用代理IP来应对反爬措施。多线程在Go中通常是通过goroutine实现的,所以应该使用goroutine来并发处理多个网页的抓取。然后,代理IP的话,可能需要一个代理池,从中随机选择代…...
Linux文件编程:操作流程与内核机制
在 Linux 操作系统中,一切皆文件,这意味着从硬盘上的数据文件、设备驱动、到管道、套接字等都以文件的形式存在。Linux 的文件系统将这些不同类型的文件统一抽象成文件对象,允许程序通过文件描述符来访问它们。 一、核心概念解析 文件描述符…...
用短说社区搭建的沉浸式生活方式分享平台
你是否想打造一个融合小红书式种草基因与论坛深度互动的全新社区?本文依托短说社区论坛系统的社区功能规划,一起来规划,如何搭建一个集内容分享、社交互动、消费决策于一体的沉浸式生活社区。 短说社区的界面样式支持普通资讯列表或瀑布流列…...
【ASR学习笔记】:语音识别领域基本术语
一、基础术语 ASR (Automatic Speech Recognition) 自动语音识别,把语音信号转换成文本的技术。 VAD (Voice Activity Detection) 语音活动检测,判断一段音频里哪里是说话,哪里是静音或噪音。 Acoustic Model(声学模型࿰…...
2025年best好用的3dsmax插件和脚本
copitor 可以从一个3dsmax场景里将物体直接复制到另一个场景中 Move to surface 这个插件可以将一些物体放到一个平面上 instancer 实体器,举例:场景中有若干独立的光源,不是实体对象,我们可以使用instancer将他变成实体。 paste …...
电厂除灰系统优化:时序数据库如何降低粉尘排放
在环保要求日益严苛的当下,电厂作为能源生产的重要主体,其除灰系统的运行效率与粉尘排放控制效果紧密相关。传统除灰系统在数据处理和排放控制方面存在一定局限性,而时序数据库凭借对时间序列数据的高效存储、处理和分析能力,为电…...
upload-labs通关笔记-第2关 文件上传之MIME绕过
目录 一、MIME字段 1. MIME 类型的作用 2. 常见的 MIME 类型 二、实验准备 1.构造脚本 2.打开靶场 3.源码分析 三、修改MIME字段渗透法 1.选择shell脚本 2.bp开启拦截 3.上传脚本bp拦包 4.bp改包 5.获取脚本地址 6.获取木马URL 7.hackbar渗透 8.蚁剑渗透 本文通…...
未来技术展望:光子量子计算集成与连续变量可视化
光子量子计算作为量子计算的重要分支,凭借其独特的光子传输优势和连续变量编码方式,正在量子计算领域掀起新的技术革命。以Xanadu公司的Borealis光量子处理器为代表,连续变量量子计算的可视化技术将面临全新的挑战与机遇。以下从技术适配、可视化方法及工具开发三个维度展开…...
vite项目使用i18n-ally未读取到文件
前言 在使用 Vue CLI 创建的Vue 3项目中,语言文件(src/lang/zh.js和en.js)正常加载。 .vscode/settings.json如下:i18n-ally.enabledParsers中增加了js {"i18n-ally.localesPaths": ["src/i18n","src/…...
yarn workspace使用指南
作用 Yarn workspace 是 Yarn 包管理工具中的一个功能,主要用于管理多包项目(monorepo)。它的主要作用如下: 支持多包结构:允许在一个仓库中管理多个独立的包或项目。项目间依赖管理:方便地在不同包之间添…...
Spring Boot 参数验证
一、依赖配置 首先确保在 pom.xml 中添加了以下依赖: <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId> </dependency> 这个依赖包含了 Hibernate Valida…...
Electron学习大纲
Electron 实际工作学习大纲路线,结合技术原理、实战开发与工程化最佳实践,分为 5 大核心阶段,每个阶段包含关键知识点和实践目标,帮助快速掌握桌面应用开发能力: 阶段一:Electron 基础与环境搭建(1-2周) 核心概念与架构Electron 组成: 主进程(Main Process):控制应…...
Linux 系统中设置开机启动脚本
Linux 系统中设置开机启动脚本有多种方法,适用于不同的场景和需求。以下是几种最常用且详细的方法: 核心理念: 无论哪种方法,核心都是让系统在启动过程中的某个阶段执行你的脚本。 1. 使用 systemd (推荐,现代 Linux 发行版的标准) systemd 是目前大多数主流 Linux 发行…...
如何解决Deepseek服务器繁忙的问题?
在现如今互联网技术飞速发展的时代,AI技术也逐渐开始兴起,Deepseek作为一款强大的AI工具,可以帮助各个行业的用户高效的处理复杂任务,但是,用户在使用这一工具的过程中,可能会遇到服务器繁忙的问题…...
四、STM32 HAL库API完全指南:从功能分类到实战示例
STM32 HAL库API完全指南:从功能分类到实战示例 一、HAL库API的总体架构 STM32 HAL库(Hardware Abstraction Layer)作为STMicroelectronics推出的统一驱动框架,提供了覆盖所有STM32外设的标准化API。HAL库的API设计遵循严格的分层…...
集成学习——Bagging,Boosting
一.什么是集成学习 集成学习的基本思想是通过结合多个基学习器的预测结果,来提高模型的泛化能力和稳定性。这些基学习器可以是相同类型的算法,也可以是不同类型的算法。 当基学习器之间具有一定的差异性时,它们在面对不同的样本子集或特征子…...
如何有效追踪需求的实现情况
有效追踪需求实现情况,需要清晰的需求定义、高效的需求跟踪工具、持续的沟通反馈机制,其中高效的需求跟踪工具尤为关键。 使用需求跟踪工具能确保需求实现进度可视化、提高团队协作效率,并帮助识别和管理潜在风险。例如,使用专业的…...
网页Web端无人机直播RTSP视频流,无需服务器转码,延迟300毫秒
随着无人机技术的飞速发展,全球无人机直播应用市场也快速扩张,从农业植保巡检到应急救援指挥,从大型活动直播到智慧城市安防,实时视频传输已成为刚需。预计到2025年,全球将有超过1000万架商用无人机搭载直播功能&#…...
基于SpringBoot的蜗牛兼职网设计与实现|源码+数据库+开发说明文档
一、项目简介 蜗牛兼职网是一个集职位信息发布、用户申请、企业管理、后台运维于一体的校园类兼职招聘平台,使用 SpringBoot 作为后端核心框架,搭配 Layui Bootstrap 实现前端页面开发,前后端结合,功能齐全。 系统共分为 三种角…...
kafka消费组
Kafka【二】关于消费者组(Consumer Group)、分区(partition)和副本(replica)的理解_consumergroup-CSDN博客 定义: 消费者组是一组可以协同工作的消费者实例的集合。 每个消费者都属于一个特定…...
每日一题洛谷P8662 [蓝桥杯 2018 省 AB] 全球变暖c++
P8662 [蓝桥杯 2018 省 AB] 全球变暖 - 洛谷 (luogu.com.cn) DFS #include<iostream> using namespace std; char a[1001][1001]; bool s[1001][1001]; int res 0; int n; bool flag true; int dx[4] { -1,0,1,0 }; int dy[4] { 0,-1,0,1 }; void dfs(int x, int y)…...
2025年Energy SCI1区TOP,改进雪消融优化算法ISAO+电池健康状态估计,深度解析+性能实测
目录 1.摘要2.雪消融优化算SAO原理3.改进策略4.结果展示5.参考文献6.代码获取7.读者交流 1.摘要 锂离子电池(LIBs)的健康状态(SOH)估计对于电池健康管理系统至关重要,为了准确估计LIBs的健康状态,本文提出…...
docker使用过程中遇到概念问题
容器和虚拟机的区别 容器共享主机内核;虚拟机占用主机内核硬件容器的启动速度是秒级别;虚拟机的启动速度是分钟级别容器资源占用低,性能接近原生;虚拟机资源占用高,性能有一定的损耗容器是进程级别的隔离;…...
leetcode-hot-100(双指针)
1. 移动零 题目链接:移动 0 题目描述:给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。 请注意 ,必须在不复制数组的情况下原地对数组进行操作。 解答 类似于签到题&#x…...
力扣HOT100之二叉树:101. 对称二叉树
这道题我本来想着挑战一下自己,尝试着用迭代的方法来做,然后就是用层序遍历,将每一层的元素收集到一个临时的一维向量中,然后再逐层判断每一层是否都是轴对称的,一旦发现某一层不是轴对称的,就直接return f…...
深入解读tcpdump:原理、数据结构与操作手册
一、tcpdump 核心原理 tcpdump 是基于 libpcap 库实现的网络数据包捕获与分析工具,其工作原理可分解为以下层次: 数据包捕获机制 底层依赖:通过操作系统的 数据链路层接口(如 Linux 的 PF_PACKET 套接字或 AF_PACKET 类型&#x…...
HTML5 中实现盒子水平垂直居中的方法
在 HTML5 中,有几种方法可以让一个定位的盒子在父容器中水平垂直居中。以下是几种常用的方法: 使用 Flexbox 布局 <div class"parent"><div class"child">居中内容</div> </div><style>.parent {di…...
个人博客系统测试报告
目录 1 项目背景 2 项目功能 3 项目测试 3.1 测试用例 3.2 登录页面测试 3.3 博客列表页面测试 3.4 博客详情页面测试 3.5 自动化测试 3.5.1 Utils类 3.5.2 登录测试页面类 3.5.3 博客列表页测试类 3.5.4 博客详情页测试类 3.5.5 博客修改页测试类 3.5.6 未登录…...
适配WIN7的最高版本Chrome谷歌浏览器109版本下载
本仓库提供了一个适用于Windows 操作系统的谷歌浏览器109版本的离线安装包。 点击下面链接下载 WIN7的最高版本Chrome谷歌浏览器109版本下载...
从规划到完善,原型标注图全流程设计
一、原型标注图:设计到开发的精准翻译器 1. 设计意图的精准传递 消除模糊性:将设计师的视觉、交互逻辑转化为可量化的数据(尺寸、颜色、动效参数),避免开发“凭感觉还原”。 统一理解标准:通过标注建立团…...
极狐GitLab 通用软件包存储库功能介绍
极狐GitLab 是 GitLab 在中国的发行版,关于中文参考文档和资料有: 极狐GitLab 中文文档极狐GitLab 中文论坛极狐GitLab 官网 极狐GitLab 通用软件包存储库 (BASIC ALL) 在项目的软件包库中发布通用文件,如发布二进制文件。然后,…...
系统架构-嵌入式系统架构
原理与特征 嵌入式系统的典型架构可概括为两种模式,即层次化模式架构和递归模式架构 层次化模式架构,位于高层的抽象概念与低层的更加具体的概念之间存在着依赖关系,封闭型层次架构指的是,高层的对象只能调用同一层或下一层对象…...
hive两个表不同数据类型字段关联引发的数据倾斜
不同数据类型引发的Hive数据倾斜解决方案 #### 一、原因分析 当两个表的关联字段存在数据类型不一致时(如int vs string、bigint vs decimal),Hive会触发隐式类型转换引发以下问题: Key值的精度损失:若关联字…...
制作一款打飞机游戏45:简单攻击
粒子系统修复 首先,我们要加载cow(可能是某个项目或资源),然后直接处理粒子系统。你们看到在粒子系统中,我们仍然有X滚动。这现在已经没什么意义了,因为我们正在使用一个奇怪的新系统。所以我们实际上不再…...
《Vuejs设计与实现》第 5 章(非原始值响应式方案) 中
目录 5.4 合理触发响应 5.5 浅响应与深响应 5.6 只读和浅只读 5.4 合理触发响应 为了合理触发响应,我们需要处理一些问题。 首先,当值没有变化时,我们不应该触发响应: const obj = { foo: 1 } const p = new Proxy(obj, { /* ... */ })effect(() => {console.log(p…...
深入理解 Webpack 核心机制与编译流程
🤖 作者简介:水煮白菜王,一位前端劝退师 👻 👀 文章专栏: 前端专栏 ,记录一下平时在博客写作中,总结出的一些开发技巧和知识归纳总结✍。 感谢支持💕💕&#…...
okhttp3.Interceptor简介-笔记
1. Interceptor 简介 okhttp3.Interceptor 是 OkHttp 提供的一个核心接口,用于拦截 HTTP 请求和响应,允许开发者在请求发送前和响应接收后插入自定义逻辑。它在构建灵活、可扩展的网络请求逻辑中扮演着重要角色。常见的用途包括: 添加请求头…...
交易流水表的分库分表设计
交易流水表的分库分表设计需要结合业务特点、数据增长趋势和查询模式,以下是常见的分库分表策略及实施建议: 一、分库分表核心目标 解决性能瓶颈:应对高并发写入和查询压力。数据均衡分布:避免单库/单表数据倾斜。简化运维&#…...
《AI大模型应知应会100篇》第59篇:Flowise:无代码搭建大模型应用
第59篇:Flowise:无代码搭建大模型应用 摘要:本文将详细探讨 Flowise 无代码平台的核心特性、使用方法和最佳实践,提供从安装到部署的全流程指南,帮助开发者和非技术用户快速构建复杂的大模型应用。文章结合实战案例与配…...
开发环境(Development Environment)
在软件开发与部署过程中,通常会划分 开发环境(Development)、测试环境(Testing)、生产环境(Production) 这三个核心环境,以确保代码在不同阶段的质量和稳定性。以下是它们的详细介绍…...
MySQL的sql_mode详解:从优雅草分发平台故障谈数据库模式配置-优雅草卓伊凡
MySQL的sql_mode详解:从优雅草分发平台故障谈数据库模式配置-优雅草卓伊凡 引言:优雅草分发平台的故障与解决 近日,优雅草分发平台(youyacaocn)在运行过程中遭遇了一次数据库访问故障。在排查过程中,技术…...
PyCharm 快捷键指南
PyCharm 快捷键指南 常用编辑快捷键 代码完成:Ctrl Space 提供基本的代码完成选项(类、方法、属性)导入类:Ctrl Alt Space 快速导入所需类语句完成:Ctrl Shift Enter 自动结束代码(如添加分号&#…...
【数据结构】map_set前传:二叉搜索树(C++)
目录 二叉搜索树K模型的模拟实现 二叉搜索树的结构: Insert()插入: InOrder()中序遍历: Find()查找: Erase()删除: 参考代码: 二叉搜索树K/V模型的模拟实现: K/V模型的简单应用举例&…...
ZYNQ处理器在发热后功耗增加的原因分析及解决方案
Zynq处理器(结合ARM Cortex-A系列CPU和FPGA可编程逻辑)在发热后功耗增大的现象,通常由以下原因导致。以下是系统性分析及解决方案: 1. 根本原因分析 现象物理机制漏电流(Leakage Current)增加温度升高导致…...
Vue学习百日计划-Deepseek版
阶段1:基础夯实(Day 1-30) 目标:掌握HTML/CSS/JavaScript基础,理解Vue核心概念和基础语法。 每日学习内容(2小时): HTML/CSS(Day 1-10) 学习HTML标签语义化…...
DeepSeek-R1-Distill-Qwen-1.5B代表什么含义?
DeepSeek‑R1‑Distill‑Qwen‑1.5B 完整释义与合规须知 一句话先行 这是 DeepSeek‑AI 把自家 R1 大模型 的知识,通过蒸馏压缩进一套 Qwen‑1.5B 架构 的轻量学生网络,并以宽松开源许可证发布的模型权重。 1 | 名字逐段拆解 片段意义备注DeepSee…...
内网服务器之间传输单个大文件最佳解决方案
内网服务器之间传输单个大文件,采用python的http.server模块,结合wget下载文件是最快的传输方案。 笔者在ubuntu与debian之间传输单个单文件进行文件,尝试了scp、sftp、rsync等方案,但传输速度都只有1-3MB/秒;采用pyt…...