線性結(jié)構(gòu)是計算機(jī)最常用的數(shù)據(jù)結(jié)構(gòu)之一陶珠。無論是數(shù)組(arrary)還是鏈表(list),在編程中不可或缺碉纺。golang也有數(shù)組徐裸,不同于別的語言遣鼓,golang還提供了切片(slice)。切片比數(shù)組有更好的靈活性重贺,具有某些動態(tài)特性骑祟。然而切片又不像動態(tài)語言的列表(Python list)宫补。不明白切片的基本實現(xiàn),寫程序的時候容易掉“坑”里曾我。
slice參數(shù)
本來寫一個堆排序,使用了golang的slice來做堆健民,可是發(fā)現(xiàn)在pop數(shù)據(jù)的時候抒巢,切片不改變。進(jìn)而引發(fā)了golang函數(shù)切片的參數(shù)秉犹,是傳值還是傳引用呢蛉谜?我們知道slice相比array是引用類型。那么直覺上告訴我們?nèi)绻瘮?shù)修改了參數(shù)的切片崇堵,那么外層的切片變量也會變啦型诚。
func main() {
slice := []int{0, 1, 2, 3}
fmt.Printf("slice: %v slice addr %p \n", slice, &slice)
ret := changeSlice(slice)
fmt.Printf("slice: %v ret: %v slice addr %p \n", slice, &slice, ret)
}
func changeSlice(s []int) []int {
s[1] = 111
return s
}
結(jié)果和假設(shè)的一樣:
slice: [0 1 2 3], slice addr: 0xc4200660c0
slice: [0 111 2 3], ret: [0 111 2 3], slice addr: 0xc4200660c0
changeSlice
函數(shù)修改了切片,變量 slice也跟著修改了鸳劳≌幔可是如果輕易就下結(jié)論,切片參數(shù)是按照引用傳遞赏廓,那么下面的現(xiàn)象就需要一種說法了:
func changeSlice(s []int) []int {
fmt.Printf("func: %p \n", &s)
s[1] = 111
return s
}
我們在函數(shù)中打出參數(shù) s 的地址涵紊,可以看見這個地址和main函數(shù)中的slice竟然不是同一個。為了了解這個幔摸,我們需要了解golang中的slice基本實現(xiàn)摸柄。
slice基本實現(xiàn)
Golang中的slice,是一個看似array卻不是array的復(fù)合結(jié)構(gòu)既忆。切片顧名思義驱负,就是數(shù)組切下來的一個片段。slice結(jié)構(gòu)大致存儲了三個部分患雇,第一部分為指向底層數(shù)組的指針ptr
跃脊,其次是切片的大小len
和切片的容量cap
:
有一個數(shù)組arr
是一個包含五個int類型的結(jié)構(gòu),它的切片slice只是從其取了 1到3這幾個數(shù)字苛吱。我們同樣可以再生成一個切片 slice2 := arr[2:5]
, 所取的就是數(shù)組后面的連續(xù)塊匾乓。他們共同使用arr作為底層的結(jié)構(gòu),可以看見共用了數(shù)字的第3又谋,4個元素拼缝。修改其中任何一個,都能改變兩個切片的值彰亥。
func main() {
arr := [5]int{0, 1, 2, 3, 4}
fmt.Println(arr)
slice := arr[1:4]
slice2 := arr[2:5]
fmt.Printf("arr %v, slice1 %v, slice2 %v, %p %p %p\n", arr, slice, slice2, &arr, &slice, &slice2)
fmt.Printf("arr[2]%p slice[1] %p slice2[0]%p\n", &arr[2], &slice[1], &slice2[0])
arr[2] = 2222
fmt.Printf("arr %v, slice1 %v, slice2 %v\n", arr, slice, slice2)
slice[1] = 1111
fmt.Printf("arr %v, slice1 %v, slice2 %v\n", arr, slice, slice2)
}
輸出的值為:
[0 1 2 3 4]
arr [0 1 2 3 4], slice1 [1 2 3], slice2 [2 3 4], 0xc42006e0c0 0xc4200660c0 0xc4200660e0
arr[2]0xc42006e0d0 slice[1] 0xc42006e0d0 slice2[0]0xc42006e0d0
arr [0 1 2222 3 4], slice1 [1 2222 3], slice2 [2222 3 4]
arr [0 1 1111 3 4], slice1 [1 1111 3], slice2 [1111 3 4]
由此可見咧七,數(shù)組的切片,只是從數(shù)組上切一段數(shù)據(jù)下來任斋,不同的切片继阻,其實是共享這些底層的數(shù)據(jù)數(shù)據(jù)。不過這些切片本身是不一樣的對象,其內(nèi)存地址都不一樣瘟檩。
從數(shù)組中切一塊下來形成切片很好理解抹缕,有時候我們用make函數(shù)創(chuàng)建切片,實際上golang會在底層創(chuàng)建一個匿名的數(shù)組墨辛。如果從新的slice再切卓研,那么新創(chuàng)建的兩個切片都共享這個底層的匿名數(shù)組。
func main() {
slice := make([]int, 5)
for i:=0; i<len(slice);i++{
slice[i] = i
}
fmt.Printf("slice %v \n", slice)
slice2 := slice[1:4]
fmt.Printf("slice %v, slice2 %v \n", slice, slice2)
slice[1] = 1111
fmt.Printf("slice %v, slice2 %v \n", slice, slice2)
}
輸出如下:
slice [0 1 2 3 4]
slice [0 1 2 3 4], slice2 [1 2 3]
slice [0 1111 2 3 4], slice2 [1111 2 3]
slice的復(fù)制
既然slice的創(chuàng)建依賴于數(shù)組睹簇,有時候新生成的slice會修改奏赘,但是又不想修改原來的切片或者數(shù)組。此時就需要針對原來的切片進(jìn)行復(fù)制了太惠。
func main() {
slice := []int{0, 1, 2, 3, 4}
slice2 := slice[1:4]
slice3 := make([]int, len(slice2))
for i, e := range slice2 {
slice3[i] = e
}
fmt.Printf("slice %v, slice3 %v \n", slice, slice3)
slice[1] = 1111
fmt.Printf("slice %v, slice3 %v \n", slice, slice3)
}
輸出:
slice [0 1 2 3 4], slice3 [1 2 3]
slice [0 1111 2 3 4], slice3 [1 2 3]
由此可見磨淌,新創(chuàng)建的slice3,不會因為slice和slice2的修改而改變slice3凿渊。復(fù)制很有用梁只,因此golang實現(xiàn)了一個內(nèi)建的函數(shù)copy
, copy有兩個參數(shù)埃脏,第一個參數(shù)是復(fù)制后的對象敛纲,第二個是復(fù)制前的數(shù)組切片對象。
func main() {
slice := []int{0, 1, 2, 3, 4}
slice2 := slice[1:4]
slice4 := make([]int, len(slice2))
copy(slice4, slice2)
fmt.Printf("slice %v, slice4 %v \n", slice, slice4)
slice[1] = 1111
fmt.Printf("slice %v, slice4 %v \n", slice, slice4)
}
slice4是從slice2中copy生成剂癌,slice和slice4底層的匿名數(shù)組是不一樣的淤翔。因此修改他們不會影響彼此。
slice 追加
append 簡介
創(chuàng)建復(fù)制切片都是常用的操作佩谷,還有一個追加元素或者追加數(shù)組也是很常用的功能旁壮。golang提供了append
函數(shù)用于給切片追加元素。append第一個參數(shù)為原切片谐檀,隨后是一些可變參數(shù)抡谐,用于將要追加的元素或多個元素。
func main() {
slice := make([]int, 1, 2)
slice[0] = 111
fmt.Printf("slice %v, slice addr %p, len %d, cap %d \n", slice, &slice, len(slice), cap(slice))
slice = append(slice, 222)
fmt.Printf("slice %v, slice addr %p, len %d, cap %d \n", slice, &slice, len(slice), cap(slice))
slice = append(slice, 333)
fmt.Printf("slice %v, slice addr %p, len %d, cap %d \n", slice, &slice, len(slice), cap(slice))
}
輸出結(jié)果為:
slice [111], slice addr 0xc4200660c0, len 1, cap 2
slice [111 222], slice addr 0xc4200660c0, len 2, cap 2
slice [111 222 333], slice addr 0xc4200660c0, len 3, cap 4
切片容量
無論數(shù)組還是切片桐猬,都有長度限制麦撵。也就是追加切片的時候,如果元素正好在切片的容量范圍內(nèi)溃肪,直接在尾部追加一個元素即可免胃。如果超出了最大容量,再追加元素就需要針對底層的數(shù)組進(jìn)行復(fù)制和擴(kuò)容操作了惫撰。
這里有一個切片容量的概念羔沙,從數(shù)組中切數(shù)據(jù),切片的容量應(yīng)該是切片的最后一個數(shù)據(jù)厨钻,和數(shù)組剩下元素的大小扼雏,再加上現(xiàn)有切片的大小坚嗜。
數(shù)組 [0, 1, 2, 3, 4] 中,數(shù)組有5個元素诗充。如果切片 s = [1, 2, 3]苍蔬,那么3在數(shù)組的索引為3,也就是數(shù)組還剩最后一個元素的大小蝴蜓,加上s已經(jīng)有3個元素碟绑,因此最后s的容量為 1 + 3 = 4。如果切片是s1 = [4]励翼,4的索引再數(shù)組中是最大的了,數(shù)組空余的元素為0辜荠,那么s1的容量為 0 + 1 = 1汽抚。具體如下表:
切片 | 切片字面量 | 數(shù)組剩下空間 | 長度 | 容量 |
---|---|---|---|---|
s[1:3] | [1 2] | 2 | 2 | 4 |
s[1:1] | [] | 4 | 0 | 4 |
s[4:4] | [] | 1 | 0 | 1 |
s[4:5] | [4] | 0 | 1 | 1 |
盡管上面的第二個和第三個切片的長度一樣,但是他們的容量不一樣伯病。容量與最終append的策略有關(guān)系造烁。
append簡單實現(xiàn)
我們已經(jīng)知道,切片都依賴底層的數(shù)組結(jié)構(gòu)午笛,即使是直接創(chuàng)建的切片惭蟋,也會生成一個匿名的數(shù)組。使用append時候药磺,本質(zhì)上是針對底層依賴的數(shù)組進(jìn)行操作告组。如果切片的容量大于長度,給切片追加元素其實是修改底層數(shù)中癌佩,切片元素后面的元素木缝。如果容量滿了,就不能在原來的數(shù)組上修改围辙,而是要創(chuàng)建一個新的數(shù)組我碟,當(dāng)然golang是通過創(chuàng)建一個新的切片實現(xiàn)的,因為新切片必然也有一個新的數(shù)組姚建,并且這個數(shù)組的長度是原來的2倍矫俺,使用動態(tài)規(guī)劃算法的簡單實現(xiàn)。
func main() {
arr := [3]int{0, 1, 2}
slice := arr[1:2]
fmt.Printf("arr %v len %d, slice %v len %d, cap %d, \n", arr, len(arr), slice, len(slice), cap(slice))
slice[0] = 333
fmt.Printf("arr %v len %d, slice %v len %d, cap %d, \n", arr, len(arr), slice, len(slice), cap(slice))
slice = append(slice, 4444)
fmt.Printf("arr %v len %d, slice %v len %d, cap %d, \n", arr, len(arr), slice, len(slice), cap(slice))
slice = append(slice, 5555)
fmt.Printf("arr %v len %d, slice %v len %d, cap %d, \n", arr, len(arr), slice, len(slice), cap(slice))
slice[0] = 333
fmt.Printf("arr %v len %d, slice %v len %d, cap %d, \n", arr, len(arr), slice, len(slice), cap(slice))
}
輸出:
arr [0 1 2] len 3, slice [1] len 1, cap 2,
arr [0 333 2] len 3, slice [333] len 1, cap 2,
arr [0 333 444] len 3, slice [333 444] len 2, cap 2,
arr [0 333 444] len 3, slice [333 444 555] len 3, cap 4,
arr [0 333 444] len 3, slice [333 444 555] len 3, cap 4,
小于容量的append
重輸出掸冤,我們來畫一下這個動態(tài)過程的圖示:
arr 是一個含有三個元素的數(shù)組厘托,slice從arr中切了一個元素,由于切片的最后一個元素1是數(shù)組的索引是1稿湿,距離數(shù)組的最大長度還是1催烘,因此slice的容量為2。當(dāng)修改slice的第一個元素缎罢,由于slice底層是arr數(shù)組伊群,因此arr的第二個元素也相應(yīng)被修改考杉。使用append方法給slice追加元素的時候,由于slice的容量還未滿舰始,因此等同于擴(kuò)展了slice指向數(shù)組的內(nèi)容崇棠,可以理解為重新切了一個數(shù)組內(nèi)容附給slice,同時修改了數(shù)組的內(nèi)容丸卷。
超出容量的append
如果接著append一個元素枕稀,那么數(shù)組肯定越界。此時append的原理大致如下:
創(chuàng)建一個新的臨時切片t谜嫉,t的長度和slice切片的長度一樣萎坷,但是t的容量是slice切片的2倍,一個動態(tài)規(guī)劃的方式沐兰。新建切片的時候哆档,底層也創(chuàng)建了一個匿名的數(shù)組,數(shù)組的長度和切片容量一樣住闯。
復(fù)制s里面的元素到t里瓜浸,即填入匿名數(shù)組中。然后把t賦值給slice比原,現(xiàn)在slice的指向了底層的匿名數(shù)組插佛。
轉(zhuǎn)變成小于容量的append方法。
上面的圖示描述了大于容量的時候append的操作原理量窘。新生成的切片其依賴的數(shù)組和原來的數(shù)組就沒有關(guān)系了雇寇,因此在修改新的切片元素,舊的數(shù)組也不會有關(guān)系蚌铜。至于臨時的切片t谢床,將會被golang的gc回收。當(dāng)然arr或它衍生的切片都沒有應(yīng)用的時候厘线,也會被gc所回收识腿。
slice和array的關(guān)系十分密切,通過兩者的合理構(gòu)建造壮,既能實現(xiàn)動態(tài)靈活的線性結(jié)構(gòu)渡讼,也能提供訪問元素的高效性能。當(dāng)然耳璧,這種結(jié)構(gòu)也不是完美無暇成箫,共用底層數(shù)組,在部分修改操作的時候旨枯,可能帶來副作用蹬昌,同時如果一個很大的數(shù)組,那怕只有一個元素被切片應(yīng)用攀隔,那么剩下的數(shù)組都不會被垃圾回收皂贩,這往往也會帶來額外的問題栖榨。
作為函數(shù)參數(shù)的切片
直接改變切片
回到最開始的問題,當(dāng)函數(shù)的參數(shù)是切片的時候明刷,到底是傳值還是傳引用婴栽?從changeSlice函數(shù)中打出的參數(shù)s的地址,可以看出肯定不是傳引用辈末,畢竟引用都是一個地址才對愚争。然而changeSlice函數(shù)內(nèi)改變了s的值,也改變了原始變量slice的值挤聘,這個看起來像引用的現(xiàn)象轰枝,實際上正是我們前面討論的切片共享底層數(shù)組的實現(xiàn)。
即切片傳遞的時候组去,傳的是數(shù)組的值鞍陨,等效于從原始切片中再切了一次。原始切片slice和參數(shù)s切片的底層數(shù)組是一樣的添怔。因此修改函數(shù)內(nèi)的切片湾戳,也就修改了數(shù)組贤旷。
例如下面的代碼:
slice := make([]int, 2, 3)
for i := 0; i < len(slice); i++ {
slice[i] = i
}
fmt.Printf("slice %v %p \n", slice, &slice)
ret := changeSlice(slice)
fmt.Printf("slice %v %p, ret %v \n", slice, &slice, ret)
ret[1] = 1111
fmt.Printf("slice %v %p, ret %v \n", slice, &slice, ret)
}
func changeSlice(s []int) []int {
fmt.Printf("func s %v %p \n", s, &s)
s = append(s, 3)
return s
}
輸出:
slice [0 1] 0xc42000a1e0
func s [0 1] 0xc42000a260
slice [0 1] 0xc42000a1e0, ret [0 1 3]
slice [0 1111] 0xc42000a1e0, ret [0 1111 3]
從輸出可以看出广料,當(dāng)slice傳遞給函數(shù)的時候,新建了切片s幼驶。在函數(shù)中給s進(jìn)行了append一個元素艾杏,由于此時s的容量足夠到,并沒有生成新的底層數(shù)組盅藻。當(dāng)修改返回的ret的時候购桑,ret也共用了底層的數(shù)組,因此修改ret的原始氏淑,相應(yīng)的也看到了slice的改變勃蜘。
append 操作
如果在函數(shù)內(nèi),append操作超過了原始切片的容量假残,將會有一個新建底層數(shù)組的過程缭贡,那么此時再修改函數(shù)返回切片,應(yīng)該不會再影響原始切片辉懒。例如下面代碼:
func main() {
slice := make([]int, 2, 2)
for i := 0; i < len(slice); i++ {
slice[i] = i
}
fmt.Printf("slice %v %p \n", slice, &slice)
ret := changeSlice(slice)
fmt.Printf("slice %v %p, ret %v \n", slice, &slice, ret)
ret[1] = -1111
fmt.Printf("slice %v %p, ret %v \n", slice, &slice, ret)
}
func changeSlice(s []int) []int {
fmt.Printf("func s %v %p \n", s, &s)
s[0] = -1
s = append(s, 3)
s[1] = 1111
return s
}
輸出:
slice [0 1] 0xc42000a1a0
func s [0 1] 0xc42000a200
slice [-1 1] 0xc42000a1a0, ret [-1 1111 3]
slice [-1 1] 0xc42000a1a0, ret [-1 -1111 3]
從輸出可以很清楚的看到了我們的猜想阳惹。 即函數(shù)中先改變s第一個元素的值,由于slice和s都共用了底層數(shù)組眶俩,因此無論原始切片slice還是ret莹汤,第一個元素都是-1.然后append操作之后,因為超出了s的容量颠印,因此會新建底層數(shù)組纲岭,雖然s變量沒變抹竹,但是他的底層數(shù)組變了,此時修改s第一個元素荒勇,并不會影響原始的slice切片柒莉。也就是slice[1]還是1,而ret[1]則是-1沽翔。最后在外面修改ret[1]為 -1111兢孝,也不會影響原始的切片slice。
通過上面的分析仅偎,我們大致可以下結(jié)論跨蟹,slice或者array作為函數(shù)參數(shù)傳遞的時候,本質(zhì)是傳值而不是傳引用橘沥。傳值的過程復(fù)制一個新的切片窗轩,這個切片也指向原始變量的底層數(shù)組。(個人感覺稱之為傳切片可能比傳值的表述更準(zhǔn)確)座咆。函數(shù)中無論是直接修改切片痢艺,還是append創(chuàng)建新的切片,都是基于共享切片底層數(shù)組的情況作為基礎(chǔ)介陶。也就是最外面的原始切片是否改變堤舒,取決于函數(shù)內(nèi)的操作和切片本身容量。
傳引用方式
array和slice作為參數(shù)傳遞的過程基本上是一樣的哺呜,即傳遞他們切片舌缤。有時候我們需要處理傳遞引用的形式。golang提供了指針很方便實現(xiàn)類似的功能某残。
func main() {
slice := []int{0, 1}
fmt.Printf("slice %v %p \n", slice, &slice)
changeSlice(&slice)
fmt.Printf("slice %v %p \n", slice, &slice)
slice[1] = -1111
fmt.Printf("slice %v %p \n", slice, &slice)
}
func changeSlice(s *[]int) {
fmt.Printf("func s %v %p \n", *s, s)
(*s)[0] = -1
*s = append(*s, 3)
(*s)[1] = 1111
}
輸出如下:
slice [0 1] 0xc42000a1e0
func s [0 1] 0xc42000a1e0
slice [-1 1111 3] 0xc42000a1e0
slice [-1 -1111 3] 0xc42000a1e0
從輸出可以看到国撵,傳遞給函數(shù)的是slice的指針,函數(shù)內(nèi)對對s的操作本質(zhì)上都是對slice的操作玻墅。并且也可以從函數(shù)內(nèi)打出的s地址看到介牙,至始至終就只有一個切片。雖然在append過程中會出現(xiàn)臨時的切片或數(shù)組澳厢。
總結(jié)
golang提供了array和slice兩種序列結(jié)構(gòu)环础。其中array是值類型。slice則是復(fù)合類型赏酥。slice是基于array實現(xiàn)的喳整。slice的第一個內(nèi)容為指向數(shù)組的指針,然后是其長度和容量裸扶。通過array的切片可以切出slice框都,也可以使用make創(chuàng)建slice,此時golang會生成一個匿名的數(shù)組。
因為slice依賴其底層的array魏保,修改slice本質(zhì)是修改array熬尺,而array又是有大小限制,當(dāng)超過slice的容量谓罗,即數(shù)組越界的時候粱哼,需要通過動態(tài)規(guī)劃的方式創(chuàng)建一個新的數(shù)組塊。把原有的數(shù)據(jù)復(fù)制到新數(shù)組檩咱,這個新的array則為slice新的底層依賴揭措。
數(shù)組還是切片,在函數(shù)中傳遞的不是引用刻蚯,是另外一種值類型绊含,即通過原始變量進(jìn)行切片傳入。函數(shù)內(nèi)的操作即對切片的修改操作了炊汹。當(dāng)然躬充,如果為了修改原始變量,可以指定參數(shù)的類型為指針類型讨便。傳遞的就是slice的內(nèi)存地址充甚。函數(shù)內(nèi)的操作都是根據(jù)內(nèi)存地址找到變量本身。