當我們需要使用陣列,但是又不能提前定義陣列大小時,可以使用golang的動態陣列結構,slice切片。在 Go 語言的眾多特性裡,slice 是我們經常用到的數據結構。但您有沒有想過,它在背後是怎麼工作的呢?接下來,咱們就一起仔仔細細地研究研究 slice 的底層到底是咋回事。比如它的底層數據結構是咋樣的,又是怎麼和陣列配合實現動態陣列功能的。把這些弄明白了,咱們寫程式碼不光能更高效,還能躲開不少容易出錯的地方。
1 原理
1.1 數據結構
我們每定義一個slice變數,golang底層都會構建一個slice結構的物件。slice結構體由3個成員變數構成:
array表示陣列指標,陣列用於儲存資料。
len表示切片長度,也就是陣列index從0到len-1已儲存資料。
cap表示切片容量,當切片長度超過最大容量時,需要擴容申請更大長度的陣列。
type slice struct { array unsafe.Pointer // 陣列指標 len int // 切片長度 cap int // 切片容量 }
1.2 切片擴容
當我們往切片中append時,如果新新增資料會導致切片的len>cap,則會觸發擴容。申請容量更大的新陣列,並將舊陣列資料複製到新陣列。
當切片擴容時,新申請的陣列長度要滿足3個需求:
陣列要能承載本次資料append進去,這是基本要求。
除了1中的基本要求,可以額外多申請一部分空間,防止後續append頻繁擴容,引起效能問題。
額外申請的空間不能過大,防止記憶體浪費。
爲了滿足上述的3個要求,golang底層的擴容策略是如果需要的容量比舊容量大很多,則不申請額外的空間;如果需要的容量比舊容量並沒有大很多,則可以多申請一些額外的記憶體空間。具體策略如下:
如果本次append之後,需要的容量大於舊切片容量*2,則新切片容量等於需要的容量。
如果舊切片容量<256,則新切片容量為舊切片容量*2。
否則,以公式newcap += (newcap + 3*threshold) / 4迭代,直到newcap大於需要的容量為止,將newcap作為新切片容量。
// growslice handles slice growth during append. // It is passed the slice element type, the old slice, and the desired new minimum capacity, // and it returns a new slice with at least that capacity, with the old data // copied into it. // The new slice's length is set to the old slice's length, // NOT to the new requested capacity. // This is for codegen convenience. The old slice's length is used immediately // to calculate where to write new values during an append. // 引數cap表示本次append之後需要的切片容量 func growslice(et *_type, old slice, cap int) slice { // 擴容策略,決定擴容後切片容量newcap,也就是需要申請的新陣列長度。 newcap := old.cap doublecap := newcap + newcap if cap > doublecap { newcap = cap } else { const threshold = 256 if old.cap < threshold { newcap = doublecap } else { // Check 0 < newcap to detect overflow // and prevent an infinite loop. for 0 < newcap && newcap < cap { // Transition from growing 2x for small slices // to growing 1.25x for large slices. This formula // gives a smooth-ish transition between the two. newcap += (newcap + 3*threshold) / 4 } // Set newcap to the requested cap when // the newcap calculation overflowed. if newcap <= 0 { newcap = cap } } } // 計算新切片的容量、長度 var overflow bool var lenmem, newlenmem, capmem uintptr lenmem = uintptr(old.len) * et.size newlenmem = uintptr(cap) * et.size capmem, overflow = math.MulUintptr(et.size, uintptr(newcap)) capmem = roundupsize(capmem) newcap = int(capmem / et.size) // 陣列記憶體申請 var p unsafe.Pointer if et.ptrdata == 0 { p = mallocgc(capmem, nil, false) } else { p = mallocgc(capmem, et, true) } // 資料複製 memmove(p, old.array, lenmem) // 構建新的切片返回 return slice{p, old.len, newcap} }
1.3 for 迴圈的坑
在for和for range迴圈中,對於迴圈迭代變數,它的作用域是整個迴圈。在迴圈時,會建立一個變數,每次迭代都會把值賦給同一個地址的變數。如果我們的程式碼有引用這個變數,可能會出現不符合預期的結果。
比如下面對for迴圈迭代變數i的使用,會輸出不符合預期的結果。
func main() { var out []*int for i := 0; i < 3; i++ { out = append(out, &i) } fmt.Println("Values:", *out[0], *out[1], *out[2]) // 輸出 Values: 3 3 3 fmt.Println("Addresses:", out[0], out[1], out[2]) // 輸出 Addresses: 0x40e020 0x40e020 0x40e020 }
原因是在每次迭代中,我們將變數 i 的地址附加到 out 切片,但由於它是同一個變數,因此我們附加相同的地址,該地址最終包含分配給 i 的最後一個值。解決方案之一是將迴圈變數複製到新變數中:
for i := 0; i < 3; i++ { + i := i // Copy i into a new variable. out = append(out, &i) }
改正之後輸出符合預期的結果:
Values: 0 1 2 Addresses: 0x40e024 0x40e028 0x40e032
又比如下面for range迴圈,對迭代變數v的使用,也會輸出不符合預期的結果。
package main import "fmt" type User struct { Name string Age int } func main() { userMap := make(map[string]*User) users := []User{ {Name: "a", Age: 22}, {Name: "b", Age: 23}, {Name: "c", Age: 21}, } for _, v := range users { userMap[v.Name] = &v } for name, user := range userMap { fmt.Println(name, "=>", user.Age, ",Addresses:", &user) // 輸出一下user的地址 } }
上面的程式碼輸出不符合預期的結果,三個人的年齡和資料地址變成了相同值:
a => 21 ,Addresses: 0xc000012028 b => 21 ,Addresses: 0xc000012028 c => 21 ,Addresses: 0xc000012028
原因跟for迴圈一樣,在迴圈時,建立了變數v,後續每次迭代都把值複製給變數v,導致迴圈結束後,複製的是最後一個,因此輸出的age都是21。解決方案之一也是將迴圈變數複製到新變數中:
for _, v := range users { + temp := v userMap[v.Name] = &temp }
**爲了防止迴圈迭代變數誤用導致bug,在Go 1.22中,迴圈迭代變數的作用域,不再是迴圈作用域,而是迭代作用域,每次迭代都會申請一個新變數。**Fixing For Loops in Go 1.22
2 實踐經驗
切片容量預分配。如果提前能預估切片容量,最好提前在make時就分配好容量,避免後續go底層的再次擴容,在一定程度上能提升程式碼執行效率。
注意對slice的迴圈,看是否需要將迴圈迭代變數賦值到一個臨時變數使用,防止bug。
3 高頻面試題
陣列和切片的區別 (基本必問)
切片的擴容策略是怎樣的?
for range 的時候它的地址會發生變化麼?for 迴圈遍歷 slice 有什麼問題?
複製大切片一定比小切片代價大嗎?
對於淺複製,比如下面的切片賦值,複製大切片和小切片代價一樣。原因是淺複製a和b共用底層陣列,不需要重新申請陣列空間,做陣列資料遷移,而只需要將a的slice數據結構array、len、cap原樣賦值給b的slice。
a:=[]int{1,2} b:=a
對於深複製,比如呼叫copy函式複製,複製大切片比小切片代價大。原因是深複製a和b底層陣列不共用,需要重新申請陣列空間,並將a中陣列元素複製到b,大切片的資料量大,因此陣列申請和資料複製的代價也高一些。
a:=[]int{1,2} b:=make([]int,0) copy(b,a)