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