簡(jiǎn)介
切片(slice)是 Go 語(yǔ)言提供的一種數(shù)據(jù)結(jié)構(gòu)祟偷,使用非常簡(jiǎn)單、便捷毒坛。但是由于實(shí)現(xiàn)層面的原因,切片也經(jīng)常會(huì)產(chǎn)生讓人疑惑的結(jié)果林说。掌握切片的底層結(jié)構(gòu)和原理煎殷,可以避免很多常見(jiàn)的使用誤區(qū)。
底層結(jié)構(gòu)
切片結(jié)構(gòu)定義在源碼runtime
包下的 slice.go 文件中:
// src/runtime/slice.go
type slice struct {
array unsafe.Pointer
len int
cap int
}
-
array
:一個(gè)指針腿箩,指向底層存儲(chǔ)數(shù)據(jù)的數(shù)組 -
len
:切片的長(zhǎng)度豪直,在代碼中我們可以使用len()
函數(shù)獲取這個(gè)值 -
cap
:切片的容量,即在不擴(kuò)容的情況下珠移,最多能容納多少元素弓乙。在代碼中我們可以使用cap()
函數(shù)獲取這個(gè)值
[圖片上傳失敗...(image-8752db-1622475715538)]
我們可以通過(guò)下面的代碼輸出切片的底層結(jié)構(gòu):
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()
}
運(yùn)行輸出:
main.slice{array:(unsafe.Pointer)(0xc0000d6030), len:1, cap:10}
這里注意一個(gè)細(xì)節(jié),由于runtime.slice
結(jié)構(gòu)是非導(dǎo)出的钧惧,我們不能直接使用暇韧。所以我在代碼中手動(dòng)定義了一個(gè)slice
結(jié)構(gòu)體,字段與runtime.slice
結(jié)構(gòu)相同浓瞪。
我們結(jié)合切片的底層結(jié)構(gòu)懈玻,先回顧一下切片的基礎(chǔ)知識(shí),然后再逐一看看切片的常見(jiàn)問(wèn)題追逮。
基礎(chǔ)知識(shí)
創(chuàng)建切片
創(chuàng)建切片有 4 種方式:
var
var
聲明切片類型的變量酪刀,這時(shí)切片值為nil
粹舵。
var s []uint32
這種方式創(chuàng)建的切片,array
字段為空指針骂倘,len
和cap
字段都等于 0眼滤。
- 切片字面量
使用切片字面量將所有元素都列舉出來(lái),這時(shí)切片長(zhǎng)度和容量都等于指定元素的個(gè)數(shù)历涝。
s := []uint32{1, 2, 3}
創(chuàng)建之后s
的底層結(jié)構(gòu)如下:
[圖片上傳失敗...(image-8f4838-1622475715538)]
len
和cap
字段都等于 3诅需。
make
使用make
創(chuàng)建,可以指定長(zhǎng)度和容量荧库。格式為make([]type, len[, cap])
堰塌,可以只指定長(zhǎng)度,也可以長(zhǎng)度容量同時(shí)指定:
s1 := make([]uint32)
s2 := make([]uint32, 1)
s3 := make([]uint32, 1, 10)
- 切片操作符
使用切片操作符可以從現(xiàn)有的切片或數(shù)組中切取一部分分衫,創(chuàng)建一個(gè)新的切片场刑。切片操作符格式為[low:high]
,例如:
var arr [10]uint32
s1 := arr[0:5]
s2 := arr[:5]
s3 := arr[5:]
s4 := arr[:]
區(qū)間是左開(kāi)右閉的蚪战,即[low, high)
牵现,包括索引low
,不包括high
邀桑。切取生成的切片長(zhǎng)度為high-low
瞎疼。
另外low
和high
都有默認(rèn)值。low
默認(rèn)為 0壁畸,high
默認(rèn)為原切片或數(shù)組的長(zhǎng)度贼急。它們都可以省略,省略時(shí)捏萍,相當(dāng)于取默認(rèn)值太抓。
使用這種方式創(chuàng)建的切片底層共享相同的數(shù)據(jù)空間,在進(jìn)行切片操作時(shí)可能會(huì)造成數(shù)據(jù)覆蓋照弥,要格外小心腻异。
添加元素
可以使用append()
函數(shù)向切片中添加元素,可以一次添加 0 個(gè)或多個(gè)元素这揣。如果剩余空間(即cap-len
)足夠存放元素則直接將元素添加到后面悔常,然后增加字段len
的值即可。反之给赞,則需要擴(kuò)容机打,分配一個(gè)更大的數(shù)組空間,將舊數(shù)組中的元素復(fù)制過(guò)去片迅,再執(zhí)行添加操作残邀。
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
的長(zhǎng)度和容量都為 0芥挣,這很好理解驱闷。比較切片與nil
是否相等,實(shí)際上要檢查slice
結(jié)構(gòu)中的array
字段是否是空指針空免。顯然s1 == nil
返回true
空另,s2 == nil
返回false
。盡管s2
長(zhǎng)度為 0蹋砚,但是make()
為它分配了空間扼菠。所以,一般定義長(zhǎng)度為 0 的切片使用var
的形式坝咐。
- 傳值還是傳引用循榆?
下面代碼的輸出什么?
func main() {
s1 := []uint32{1, 2, 3}
s2 := append(s1, 4)
fmt.Println(s1)
fmt.Println(s2)
}
分析:
為什么append()
函數(shù)要有返回值墨坚?因?yàn)槲覀儗⑶衅瑐鬟f給append()
時(shí)秧饮,其實(shí)傳入的是runtime.slice
結(jié)構(gòu)。這個(gè)結(jié)構(gòu)是按值傳遞的框杜,所以函數(shù)內(nèi)部對(duì)array/len/cap
這幾個(gè)字段的修改都不影響外面的切片結(jié)構(gòu)浦楣。上面代碼中,執(zhí)行append()
之后s1
的len
和cap
保持不變咪辱,故輸出為:
[1 2 3]
[1 2 3 4]
所以我們調(diào)用append()
要寫成s = append(s, elem)
這種形式,將返回值賦值給原切片椎组,從而覆寫array/len/cap
這幾個(gè)字段的值油狂。
初學(xué)者還可能會(huì)犯忽略append()
返回值的錯(cuò)誤:
append(s, elem)
這就更加大錯(cuò)特錯(cuò)了。添加的元素將會(huì)丟失寸癌,以為函數(shù)外切片的內(nèi)部字段都沒(méi)有變化专筷。
我們可以看到,雖說(shuō)切片是按引用傳遞的蒸苇,但是實(shí)際上傳遞的是結(jié)構(gòu)runtime.slice
的值磷蛹。只是對(duì)現(xiàn)有元素的修改會(huì)反應(yīng)到函數(shù)外,因?yàn)榈讓訑?shù)組空間是共用的溪烤。
- 切片的擴(kuò)容策略
下面代碼的輸出是什么味咳?
func main() {
var s1 []uint32
s1 = append(s1, 1, 2, 3)
s2 := append(s1, 4)
fmt.Println(&s1[0] == &s2[0])
}
這涉及到切片的擴(kuò)容策略。擴(kuò)容時(shí)檬嘀,若:
- 當(dāng)前容量小于 1024槽驶,則將容量擴(kuò)大為原來(lái)的 2 倍;
- 當(dāng)前容量大于等于 1024鸳兽,則將容量逐次增加原來(lái)的 0.25 倍掂铐,直到滿足所需容量。
我翻看了 Go1.16 版本runtime/slice.go
中擴(kuò)容相關(guān)的源碼,在執(zhí)行上面規(guī)則后還會(huì)根據(jù)切片元素的大小和計(jì)算機(jī)位數(shù)進(jìn)行相應(yīng)的調(diào)整全陨。整個(gè)過(guò)程比較復(fù)雜爆班,感興趣可以自行去研究。
我們只需要知道一開(kāi)始容量較小辱姨,擴(kuò)大為 2 倍柿菩,降低后續(xù)因添加元素導(dǎo)致擴(kuò)容的頻次。容量擴(kuò)張到一定程度時(shí)炮叶,再按照 2 倍來(lái)擴(kuò)容會(huì)造成比較大的浪費(fèi)碗旅。
上面例子中執(zhí)行s1 = append(s1, 1, 2, 3)
后,容量會(huì)擴(kuò)大為 4镜悉。再執(zhí)行s2 := append(s1, 4)
由于有足夠的空間祟辟,s2
底層的數(shù)組不會(huì)改變。所以s1
和s2
第一個(gè)元素的地址相同侣肄。
- 切片操作符可以切取字符串
切片操作符可以切取字符串旧困,但是與切取切片和數(shù)組不同。切取字符串返回的是字符串稼锅,而非切片吼具。因?yàn)樽址遣豢勺兊模绻祷厍衅鼐唷6衅妥址蚕淼讓訑?shù)據(jù)拗盒,就可以通過(guò)切片修改字符串了。
func main() {
str := "hello, world"
fmt.Println(str[:5])
}
輸出 hello锥债。
- 切片底層數(shù)據(jù)共享
下面代碼的輸出是什么陡蝇?
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 已經(jīng)大于切片s1
的長(zhǎng)度了。要記住哮肚,使用切片操作符切取切片時(shí)登夫,上界是切片的容量,而非長(zhǎng)度允趟。這時(shí)兩個(gè)切片的底層結(jié)構(gòu)有重疊恼策,如下圖:
[圖片上傳失敗...(image-687c90-1622475715538)]
這時(shí)輸出s2
為:
[0, 0, 0, 0, 0]
然后向切片s1
中添加元素 6,這時(shí)結(jié)構(gòu)如下圖潮剪,其中切片s1
和s2
共享元素 6:
[圖片上傳失敗...(image-2467db-1622475715538)]
這時(shí)輸出的s1
和s2
為:
[1, 2, 3, 4, 5, 6]
[6, 0, 0, 0, 0]
可以看到由于切片底層數(shù)據(jù)共享可能造成修改一個(gè)切片會(huì)導(dǎo)致其他切片也跟著修改涣楷。這有時(shí)會(huì)造成難以調(diào)試的 BUG。為了一定程度上緩解這個(gè)問(wèn)題鲁纠,Go 1.2 版本中提供了一個(gè)擴(kuò)展切片操作符:[low:high:max]
总棵,用來(lái)限制新切片的容量。使用這種方式產(chǎn)生的切片容量為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)
}
執(zhí)行s1 := array[:5:5]
我們限定了s1
的容量為 5情龄,這時(shí)結(jié)構(gòu)如下圖所示:
[圖片上傳失敗...(image-c59a71-1622475715538)]
執(zhí)行s1 = append(s1, 6)
時(shí),發(fā)現(xiàn)沒(méi)有空閑容量了(因?yàn)?code>len == cap == 5),重新創(chuàng)建一個(gè)底層數(shù)組再執(zhí)行添加骤视。這時(shí)結(jié)構(gòu)如下圖鞍爱,s1
和s2
互不干擾:
[圖片上傳失敗...(image-eb729e-1622475715538)]
總結(jié)
了解了切片的底層數(shù)據(jù)結(jié)構(gòu),知道了切片傳遞的是結(jié)構(gòu)runtime.slice
的值专酗,我們就能解決 90% 以上的切片問(wèn)題睹逃。再結(jié)合圖形可以很直觀的看到切片底層數(shù)據(jù)是如何操作的。
這個(gè)系列的名字是我仿造《你不知道的 JavaScript》起的??祷肯。
參考
- 《Go 專家編程》沉填,豆瓣鏈接:https://book.douban.com/subject/35144587/
- 你不知道的Go GitHub:https://github.com/darjun/you-dont-know-go
我
歡迎關(guān)注我的微信公眾號(hào)【GoUpUp】,共同學(xué)習(xí)佑笋,一起進(jìn)步~