你不知道的 Go 之 slice
簡介
切片(slice)是 Go 語言提供的一種數據結構,使用非常簡單、便捷。但是由於實現層面的原因,切片也經常會產生讓人疑惑的結果。掌握切片的底層結構和原理,可以避免很多常見的使用誤區。
底層結構
切片結構定義在源碼runtime
包下的 slice.go 文件中:
// src/runtime/slice.go
type slice struct {
array unsafe.Pointer
len int
cap int
}
-
array
:一個指針,指向底層存儲數據的數組 -
len
:切片的長度,在代碼中我們可以使用len()
函數獲取這個值 -
cap
:切片的容量,即在不擴容的情況下,最多能容納多少元素。在代碼中我們可以使用cap()
函數獲取這個值
我們可以通過下面的代碼輸出切片的底層結構:
type slice struct {
array unsafe.Pointer
len int
cap int
}
func printSlice() {
s := make([]uint32, 1, 10)
fmt.Printf("%#v\n", *(*slice)(unsafe.Pointer(&s)))
}
func main() {
printSlice()
}
運行輸出:
main.slice{array:(unsafe.Pointer)(0xc0000d6030), len:1, cap:10}
這裏注意一個細節,由於runtime.slice
結構是非導出的,我們不能直接使用。所以我在代碼中手動定義了一個slice
結構體,字段與runtime.slice
結構相同。
我們結合切片的底層結構,先回顧一下切片的基礎知識,然後再逐一看看切片的常見問題。
基礎知識
創建切片
創建切片有 4 種方式:
var
var
聲明切片類型的變量,這時切片值爲nil
。
var s []uint32
這種方式創建的切片,array
字段爲空指針,len
和cap
字段都等於 0。
- 切片字面量
使用切片字面量將所有元素都列舉出來,這時切片長度和容量都等於指定元素的個數。
s := []uint32{1, 2, 3}
創建之後s
的底層結構如下:
len
和cap
字段都等於 3。
make
使用make
創建,可以指定長度和容量。格式爲make([]type, len[, cap])
,可以只指定長度,也可以長度容量同時指定:
s1 := make([]uint32)
s2 := make([]uint32, 1)
s3 := make([]uint32, 1, 10)
- 切片操作符
使用切片操作符可以從現有的切片或數組中切取一部分,創建一個新的切片。切片操作符格式爲[low:high]
,例如:
var arr [10]uint32
s1 := arr[0:5]
s2 := arr[:5]
s3 := arr[5:]
s4 := arr[:]
區間是左開右閉的,即[low, high)
,包括索引low
,不包括high
。切取生成的切片長度爲high-low
。
另外low
和high
都有默認值。low
默認爲 0,high
默認爲原切片或數組的長度。它們都可以省略,省略時,相當於取默認值。
使用這種方式創建的切片底層共享相同的數據空間,在進行切片操作時可能會造成數據覆蓋,要格外小心。
添加元素
可以使用append()
函數向切片中添加元素,可以一次添加 0 個或多個元素。如果剩餘空間(即cap-len
)足夠存放元素則直接將元素添加到後面,然後增加字段len
的值即可。反之,則需要擴容,分配一個更大的數組空間,將舊數組中的元素複製過去,再執行添加操作。
package main
import "fmt"
func main() {
s := make([]uint32, 0, 4)
s = append(s, 1, 2, 3)
fmt.Println(len(s), cap(s)) // 3 4
s = append(s, 4, 5, 6)
fmt.Println(len(s), cap(s)) // 6 8
}
你不知道的 slice
- 空切片等於
nil
嗎?
下面代碼的輸出什麼?
func main() {
var s1 []uint32
s2 := make([]uint32, 0)
fmt.Println(s1 == nil)
fmt.Println(s2 == nil)
fmt.Println("nil slice:", len(s1), cap(s1))
fmt.Println("cap slice:", len(s2), cap(s2))
}
分析:
首先s1
和s2
的長度和容量都爲 0,這很好理解。比較切片與nil
是否相等,實際上要檢查slice
結構中的array
字段是否是空指針。顯然s1 == nil
返回true
,s2 == nil
返回false
。儘管s2
長度爲 0,但是make()
爲它分配了空間。所以,一般定義長度爲 0 的切片使用var
的形式。
- 傳值還是傳引用?
下面代碼的輸出什麼?
func main() {
s1 := []uint32{1, 2, 3}
s2 := append(s1, 4)
fmt.Println(s1)
fmt.Println(s2)
}
分析:
爲什麼append()
函數要有返回值?因爲我們將切片傳遞給append()
時,其實傳入的是runtime.slice
結構。這個結構是按值傳遞的,所以函數內部對array/len/cap
這幾個字段的修改都不影響外面的切片結構。上面代碼中,執行append()
之後s1
的len
和cap
保持不變,故輸出爲:
[1 2 3]
[1 2 3 4]
所以我們調用append()
要寫成s = append(s, elem)
這種形式,將返回值賦值給原切片,從而覆寫array/len/cap
這幾個字段的值。
初學者還可能會犯忽略append()
返回值的錯誤:
append(s, elem)
這就更加大錯特錯了。添加的元素將會丟失,以爲函數外切片的內部字段都沒有變化。
我們可以看到,雖說切片是按引用傳遞的,但是實際上傳遞的是結構runtime.slice
的值。只是對現有元素的修改會反應到函數外,因爲底層數組空間是共用的。
- 切片的擴容策略
下面代碼的輸出是什麼?
func main() {
var s1 []uint32
s1 = append(s1, 1, 2, 3)
s2 := append(s1, 4)
fmt.Println(&s1[0] == &s2[0])
}
這涉及到切片的擴容策略。擴容時,若:
-
當前容量小於 1024,則將容量擴大爲原來的 2 倍;
-
當前容量大於等於 1024,則將容量逐次增加原來的 0.25 倍,直到滿足所需容量。
我翻看了 Go1.16 版本runtime/slice.go
中擴容相關的源碼,在執行上面規則後還會根據切片元素的大小和計算機位數進行相應的調整。整個過程比較複雜,感興趣可以自行去研究。
我們只需要知道一開始容量較小,擴大爲 2 倍,降低後續因添加元素導致擴容的頻次。容量擴張到一定程度時,再按照 2 倍來擴容會造成比較大的浪費。
上面例子中執行s1 = append(s1, 1, 2, 3)
後,容量會擴大爲 4。再執行s2 := append(s1, 4)
由於有足夠的空間,s2
底層的數組不會改變。所以s1
和s2
第一個元素的地址相同。
- 切片操作符可以切取字符串
切片操作符可以切取字符串,但是與切取切片和數組不同。切取字符串返回的是字符串,而非切片。因爲字符串是不可變的,如果返回切片。而切片和字符串共享底層數據,就可以通過切片修改字符串了。
func main() {
str := "hello, world"
fmt.Println(str[:5])
}
輸出 hello。
- 切片底層數據共享
下面代碼的輸出是什麼?
func main() {
array := [10]uint32{1, 2, 3, 4, 5}
s1 := array[:5]
s2 := s1[5:10]
fmt.Println(s2)
s1 = append(s1, 6)
fmt.Println(s1)
fmt.Println(s2)
}
分析:
首先注意到s2 := s1[5:10]
上界 10 已經大於切片s1
的長度了。要記住,使用切片操作符切取切片時,上界是切片的容量,而非長度。這時兩個切片的底層結構有重疊,如下圖:
這時輸出s2
爲:
[0, 0, 0, 0, 0]
然後向切片s1
中添加元素 6,這時結構如下圖,其中切片s1
和s2
共享元素 6:
這時輸出的s1
和s2
爲:
[1, 2, 3, 4, 5, 6]
[6, 0, 0, 0, 0]
可以看到由於切片底層數據共享可能造成修改一個切片會導致其他切片也跟着修改。這有時會造成難以調試的 BUG。爲了一定程度上緩解這個問題,Go 1.2 版本中提供了一個擴展切片操作符:[low:high:max]
,用來限制新切片的容量。使用這種方式產生的切片容量爲max-low
。
func main() {
array := [10]uint32{1, 2, 3, 4, 5}
s1 := array[:5:5]
s2 := array[5:10:10]
fmt.Println(s2)
s1 = append(s1, 6)
fmt.Println(s1)
fmt.Println(s2)
}
執行s1 := array[:5:5]
我們限定了s1
的容量爲 5,這時結構如下圖所示:
執行s1 = append(s1, 6)
時,發現沒有空閒容量了(因爲len == cap == 5
),重新創建一個底層數組再執行添加。這時結構如下圖,s1
和s2
互不干擾:
總結
瞭解了切片的底層數據結構,知道了切片傳遞的是結構runtime.slice
的值,我們就能解決 90% 以上的切片問題。再結合圖形可以很直觀的看到切片底層數據是如何操作的。
這個系列的名字是我仿造《你不知道的 JavaScript》起的😀。
參考
-
《Go 專家編程》,豆瓣鏈接:https://book.douban.com/subject/35144587/
-
你不知道的 Go GitHub:https://github.com/darjun/you-dont-know-go
我
我的博客:https://darjun.github.io
歡迎關注我的微信公衆號【GoUpUp】,共同學習,一起進步~
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/aOIp7jeBWubT-u85vQJxzA