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 擴容機制

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/diannao/82950.shtml
繁體地址,請注明出處:http://hk.pswp.cn/diannao/82950.shtml
英文地址,請注明出處:http://en.pswp.cn/diannao/82950.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

mysql常用方法

mysql常用方法 一、基本用法 -- MySQL創建唯一索引 CREATE UNIQUE INDEX 索引名 ON 表名(列名1,列名2,...); --也可以使用ALTER TABLE語句給現有表添加唯一索引&#xff08;UNIQUE&#xff09; ALTER TABLE 表名 ADD CONSTRAINT 索引名 UNIQUE KEY(列名1,列名2,...); alter t…

STM32F103C8T6板子使用說明

第一章 計算機體系結構(了解) 后續在板子上開發的時候&#xff0c;需要考慮是否有操作系統 方式一&#xff1a;有操作系統&#xff0c;通過c庫通過os api操作硬件方式二&#xff1a;無操作系統&#xff0c; 通過c庫通過固件庫操作硬件 第二章 STM32開發板概述 板子/開發板&…

PBR材質-Unity/Blender/UE

目錄 前言&#xff1a; 一、Unity&#xff1a; 二、Blender&#xff1a; 三、UE&#xff1a; 四、全家福&#xff1a; 五、后記&#xff1a; 前言&#xff1a; PBR流程作為表達物理效果的經典方式&#xff0c;很值得一學。紋理貼圖使用的是上一期的Textures | cgbookcas…

【生產實踐】Linux中/usr/bin、/usr/sbin與/usr/local的關系解析(2025年技術規范)

一、核心定位與功能劃分 /usr/bin&#xff1a;用戶級通用命令庫 ? 定位&#xff1a;存儲系統預裝的用戶級可執行文件&#xff0c;這些命令通常由Linux發行版官方軟件包管理器&#xff08;如APT、YUM&#xff09;安裝&#xff0c;屬于系統默認功能的一部分。 ? 示例命令&#…

智能網聯汽車 “中央計算” 博弈:RTOS 與跨域融合的算力分配挑戰

一、引言 隨著智能駕駛技術的飛速發展&#xff0c;汽車逐漸從傳統的交通工具演變為移動的智能終端。智能網聯汽車的核心競爭力日益體現在其強大的計算能力和高效的算力管理上。汽車電子電氣架構&#xff08;EEA&#xff09;正經歷從分布式架構向 “中央計算 區域控制” 架構的…

【PDF】使用Adobe Acrobat dc添加水印和加密

【PDF】使用Adobe Acrobat dc添加水印和加密 文章目錄 [TOC](文章目錄) 前言一、添加保護加密口令二、添加水印三、實驗四、參考文章總結 實驗工具&#xff1a; 1.Adobe Acrobat dc 前言 提示&#xff1a;以下是本篇文章正文內容&#xff0c;下面案例可供參考 一、添加保護加…

python面試實戰經驗分享

2025/3/28第一面 杭州實在智能 見習python開發工程師 1、Python方法中參數默認值可以是列表或者字典嗎? 在Python中,函數參數的默認值可以是列表或字典,列表或者字典在初始化應在函數體內,如果不正確處理,可能會導致所有調用都共享同一個列表。 2、協程、線性和進程 進程…

MIST:一鍵解鎖 macOS 歷史版本,舊系統安裝不再難!

在 Mac 電腦的使用過程中&#xff0c;你是否遇到過這些困擾&#xff1f;為了運行一款經典設計軟件&#xff0c;新系統卻無法兼容&#xff1b;或是想給老舊 Mac 設備升級&#xff0c;卻找不到適配的系統版本。而 App Store 里&#xff0c;舊版 macOS 安裝包就像 “隱藏副本”&am…

win10 局域網內聊天

在 Windows 10 的局域網 中&#xff0c;如果你想實現 多個用戶之間的聊天功能&#xff0c;可以選擇以下幾種方案&#xff0c;取決于你需要的是&#xff1a; ? ? 命令行純文字聊天&#xff08;如 Linux talk&#xff09; ? ? 圖形界面聊天室 ? ? 局域網廣播消息 ? ? 多人…

Android CountDownTimer重寫

Android 倒計時器重寫&#xff0c;實現可重復使用&#xff0c;動態修改計時時間 CountDownTimerRew 是一個可重寫、動態修改計時時間的 Android 倒計時器類。它允許開發者設置倒計時總時長、間隔時間&#xff0c;并通過 onTick 和 onFinish 方法實現定時回調。該類支持動態修改…

dp自動化登陸之hCaptcha 驗證碼

hCaptcha 是一種常見的驗證碼服務&#xff0c;用于區分人類用戶和自動化程序。由于其基于圖像識別和行為分析&#xff0c;下面介紹如何使用自動化點擊驗證碼完成登陸。 思路&#xff1a;登陸目標網站觸發驗證碼&#xff0c;截圖并發給打碼平臺返回坐標&#xff0c;模擬人工點擊…

【工作記錄】crmeb后端項目打開、運行

1、下載代碼 1&#xff09;安裝git 不再詳述 2&#xff09;git拉代碼 項目地址如下&#xff0c;在vscode-分支中拉代碼 # 克隆項目 git clone https://gitee.com/ZhongBangKeJi/crmeb_java/ 截圖如下是已經成功拉下來 注意安裝對應版本 2、maven配置 安裝配置見&#x…

敏捷軟件開發與Scrum

目錄 一、敏捷軟件開發 敏捷開發原則 敏捷開發特點 二、Scrum (一)Scrum 基礎知識

Three.js模型材質調整與性能優化實戰

一、材質基礎調整 1.1 顏色與透明度控制 通過Material.color屬性可直接修改材質顏色&#xff1a; material.color new THREE.Color(0xff0000); // 紅色結合opacity屬性實現透明效果&#xff1a; material.opacity 0.5; // 50%透明度如需動態調整&#xff0c;可通過Color.…

Flutter速成指南:不懂編程也能10天開發專業級App

Flutter速成指南&#xff1a;不懂編程也能10天開發專業級App &#x1f680; 輕松構建漂亮的跨平臺應用 &#x1f4d1; 目錄 一、Flutter是什么&#xff1f; 為什么選擇Flutter&#xff1f;Flutter工作原理 二、環境搭建與命令行 安裝Flutter SDK常用Flutter命令創建第一個項目…

【面試真題】王者榮耀億級排行榜,如何設計?

目錄 一、數據庫 order by 二、Redis 的zset 三、抗億級數據存在的問題 3.1 熱點 key 問題 3.1.1 多級緩存&#xff08;RedisJVM本地緩存&#xff09; 3.1.2 讀寫分離 從庫負載均衡 3.1.3 分片Key設計 3.2 內存爆炸 3.2.1 縮短鍵名 3.2.2 分片存儲 3.3 數據持久化風…

Java 語法基礎(筆記)

java 的數據類型 基本類型 Java 有八種基本類型&#xff1a; byte&#xff1a;1 字節&#xff0c;-128~127short&#xff1a;2 字節&#xff0c;-32768~32767int&#xff1a;4 字節&#xff0c;-2147483648~2147483647long&#xff1a;8 字節&#xff0c;-92233720368547758…

C# 方法(棧幀)

本章內容: 方法的結構 方法體內部的代碼執行 局部變量 局部常量 控制流 方法調用 返回值 返回語句和void方法 局部函數 參數 值參數 引用參數 引用類型作為值參數和引用參數 輸出參數 參數數組 參數類型總結 方法重載 命名參數 可選參數 棧幀 遞歸 棧幀 至此&#xff0c;我們已…

C# 使用 WinUI 3 項目模板創建桌面應用程序

文章目錄 1. 概述2. 先決條件3. 創建項目步驟4. 項目結構簡介5. 代碼示例5.1. MainWindow.xaml (UI 定義)5.2. MainWindow.xaml.cs (邏輯代碼) 6. 生成和運行應用程序7. 關鍵概念 1. 概述 本示例演示如何使用 Visual Studio 中的 “Blank App, Packaged (WinUI 3 in Desktop)”…

設計模式簡述(十八)享元模式

享元模式 描述基本組件使用 描述 當內存中存在大量類似的對象時&#xff0c;可以考慮使用享元模式減少整體內存占用。 可以將相同的部分和不同的部分進行拆分&#xff0c;以達到多個對象共享相同部分內存的目的。 基本組件 通常享元對象通過共享的屬性映射一個享元對象。 公…