這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
線性結構是電腦最常用的資料結構之一。無論是數組(arrary)還是鏈表(list),在編程中不可或缺。golang也有數組,不同於別的語言,golang還提供了切片(slice)。切片比數組有更好的靈活性,具有某些動態特性。然而切片又不像動態語言的列表(Python list)。不明白切片的基本實現,寫程式的時候容易掉“坑”裡。
slice參數
本來寫一個堆排序,使用了golang的slice來做堆,可是發現在pop資料的時候,切片不改變。進而引發了golang函數切片的參數,是傳值還是傳引用呢?我們知道slice相比array是參考型別。那麼直覺上告訴我們如果函數修改了參數的切片,那麼外層的切片變數也會變啦。
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}
結果和假設的一樣:
slice: [0 1 2 3], slice addr: 0xc4200660c0 slice: [0 111 2 3], ret: [0 111 2 3], slice addr: 0xc4200660c0
changeSlice
函數修改了切片,變數 slice也跟著修改了。可是如果輕易就下結論,切片參數是按照引用傳遞,那麼下面的現象就需要一種說法了:
func changeSlice(s []int) []int { fmt.Printf("func: %p \n", &s) s[1] = 111 return s}
我們在函數中打出參數 s 的地址,可以看見這個地址和main函數中的slice竟然不是同一個。為了瞭解這個,我們需要瞭解golang中的slice基本實現。
slice基本實現
Golang中的slice,是一個看似array卻不是array的複合結構。切片顧名思義,就是數組切下來的一個片段。slice結構大致儲存了三個部分,第一部分為指向底層數組的指標ptr
,其次是切片的大小len
和切片的容量cap
:
+--------+ | | | ptr |+------------+-------+-----------+ | | | | +--------+ | | | | | | | | | | | len 5 | | | | | | | +--------+ v v | | +-----+-----+-----+-----+----+ | | | | | | | | | cap 5 | [5]int | 0 | 1 | 2 | 3 | 4 | | | +-----+-----+-----+-----+----+ +--------+ slice := arr[1:4] arr := [5]int{0,1,2,3,4}
有一個數組arr
是一個包含五個int類型的結構,它的切片slice只是從其取了 1到3這幾個數字。我們同樣可以再產生一個切片 slice2 := arr[2:5]
, 所取的就是數組後面的連續塊。他們共同使用arr作為底層的結構,可以看見共用了數位第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 0xc4200660e0arr[2]0xc42006e0d0 slice[1] 0xc42006e0d0 slice2[0]0xc42006e0d0arr [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]
由此可見,數組的切片,只是從數組上切一段資料下來,不同的切片,其實是共用這些底層的資料資料。不過這些切片本身是不一樣的對象,其記憶體位址都不一樣。
從數組中切一塊下來形成切片很好理解,有時候我們用make函數建立切片,實際上golang會在底層建立一個匿名的數組。如果從新的slice再切,那麼新建立的兩個切片都共用這個底層的匿名數組。
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的複製
既然slice的建立依賴於數組,有時候新產生的slice會修改,但是又不想修改原來的切片或者數組。此時就需要針對原來的切片進行複製了。
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]
由此可見,新建立的slice3,不會因為slice和slice2的修改而改變slice3。複製很有用,因此golang實現了一個內建的函數copy
, copy有兩個參數,第一個參數是複製後的對象,第二個是複製前的數組切片對象。
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底層的匿名數組是不一樣的。因此修改他們不會影響彼此。
slice 追加
append 簡介
建立複製切片都是常用的操作,還有一個追加元素或者追加數組也是很常用的功能。golang提供了append
函數用於給切片追加元素。append第一個參數為原切片,隨後是一些可變參數,用於將要追加的元素或多個元素。
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))}
輸出結果為:
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
切片容量
無論數組還是切片,都有長度限制。也就是追加切片的時候,如果元素正好在切片的容量範圍內,直接在尾部追加一個元素即可。如果超出了最大容量,再追加元素就需要針對底層的數組進行複製和擴容操作了。
這裡有一個切片容量的概念,從數組中切資料,切片的容量應該是切片的最後一個資料,和數組剩下元素的大小,再加上現有切片的大小。
數組 [0, 1, 2, 3, 4] 中,數組有5個元素。如果切片 s = [1, 2, 3],那麼3
在數組的索引為3,也就是數組還剩最後一個元素的大小,加上s已經有3個元素,因此最後s的容量為 1 + 3 = 4。如果切片是
s1 = [4],4的索引再數組中是最大的了,數組空餘的元素為0,那麼s1的容量為 0 + 1 = 1。具體如下表:
切片 |
切片字面量 |
數組剩下空間 |
長度 |
容量 |
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的策略有關係。
append簡單實現
我們已經知道,切片都依賴底層的數組結構,即使是直接建立的切片,也會產生一個匿名的數組。使用append時候,本質上是針對底層依賴的數組進行操作。如果切片的容量大於長度,給切片追加元素其實是修改底層數中,切片元素後面的元素。如果容量滿了,就不能在原來的數組上修改,而是要建立一個新的數組,當然golang是通過建立一個新的切片實現的,因為新切片必然也有一個新的數組,並且這個數組的長度是原來的2倍,使用動態規划算法的簡單實現。
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
重輸出,我們來畫一下這個動態過程的圖示:
+----+----+----+ +----+----+----+ +----+----+----+ | | | | | | | | | | | | arr | 0 | 1 | 2 | arr | 0 |333 | 2 | arr | 0 |333 |444 | +----+----+----+ +----+----+----+ +----+----+----+ ^ ^ ^ ^ | | | | | | | | | slic0] = 333 | slice = append(slice, 444) +----+ | +-----------------> | +----------------> | | | | +--+--+----+----+ +--+--+----+----+ +--+--+----+----+ | | | | | | | | | | | | | p | 1 | 2 | | p | 1 | 2 | | p | 2 | 2 | +-----+----+----+ +-----+----+----+ +-----+----+----+ slice :=arr[1:2] slice :=arr[1:2] slice :=arr[1:2]
arr 是一個含有三個元素的數組,slice從arr中切了一個元素,由於切片的最後一個元素1
是數組的索引是1
,距離數組的最大長度還是1,因此slice的容量為2。當修改slice的第一個元素,由於slice底層是arr數組,因此arr的第二個元素也相應被修改。使用append方法給slice追加元素的時候,由於slice的容量還未滿,因此等同於擴充了slice指向數組的內容,可以理解為重新切了一個數組內容附給slice,同時修改了數組的內容。
超出容量的append
如果接著append一個元素,那麼數組肯定越界。此時append的原理大致如下:
- 建立一個新的臨時切片t,t的長度和slice切片的長度一樣,但是t的容量是slice切片的2倍,一個動態規劃的方式。建立切片的時候,底層也建立了一個匿名的數組,數組的長度和切片容量一樣。
- 複製s裡面的元素到t裡,即填入匿名數組中。然後把t賦值給slice,現在slice的指向了底層的匿名數組。
- 轉變成小於容量的append方法。
+----+----+----+ +----+----+----+----+----+----+ | | | | | | | | | | | arr | 0 |333 |444 | | 333| 444| | | | | +----+----+----+ +----+----+----+----+----+----+ ^ ^ ^ ^ | | | | | | +-----+ +----+ +---------------> | | | | + +--+--+----+----+ +-----+-----+-----+ | | | | | | | | | p | 2 | 2 | | p | 2 | 6 | +-----+----+----+ +-----+-----+-----+ slice :=arr[1:2] t := make([]int, len=2, cap=6) + | | | | v +----+----+----+----+----+----+ | | | | | | | | 333| 444|555 | | | | +----+----+----+----+----+----+ ^ ^ | | +----+----+ | | + +-----+-----+-----+ | | | | | p | 3 | 6 | +-----+-----+-----+ slice = t
上面的圖示描述了大於容量的時候append的操作原理。新產生的切片其依賴的數組和原來的數組就沒有關係了,因此在修改新的切片元素,舊的數組也不會有關係。至於臨時的切片t,將會被golang的gc回收。當然arr或它衍生的切片都沒有應用的時候,也會被gc所回收。
slice和array的關係十分密切,通過兩者的合理構建,既能實現動態靈活的線性結構,也能提供訪問元素的高效效能。當然,這種結構也不是完美無暇,共用底層數組,在部分修改操作的時候,可能帶來副作用,同時如果一個很大的數組,那怕只有一個元素被切片應用,那麼剩下的數組都不會被記憶體回收,這往往也會帶來額外的問題。
作為函數參數的切片
直接改變切片
回到最開始的問題,當函數的參數是切片的時候,到底是傳值還是傳引用?從changeSlice函數中打出的參數s的地址,可以看出肯定不是傳引用,畢竟引用都是一個地址才對。然而changeSlice函數內改變了s的值,也改變了原始變數slice的值,這個看起來像引用的現象,實際上正是我們前面討論的切片共用底層數組的實現。
即切片傳遞的時候,傳的是數組的值,等效於從原始切片中再切了一次。原始切片slice和參數s切片的底層數組是一樣的。因此修改函數內的切片,也就修改了數組。
+-----+----+-----+ | | | | +-----------------------------+| p | 3 | 3 | | + +-----+----+-----+ | | | | s | | | | v v +----+----+-----+ | | | | arr | 0 | 1 | 2 | +----+----+-----+ ^ ^ | | | | +-----------+ | | +-+--+----+-----+ | | | | | p | 3 | 3 | +----+----+-----+ slice
例如下面的代碼:
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]
從輸出可以看出,當slice傳遞給函數的時候,建立了切片s。在函數中給s進行了append一個元素,由於此時s的容量足夠到,並沒有產生新的底層數組。當修改返回的ret的時候,ret也共用了底層的數組,因此修改ret的原始,相應的也看到了slice的改變。
append 操作
如果在函數內,append操作超過了原始切片的容量,將會有一個建立底層數組的過程,那麼此時再修改函數返回切片,應該不會再影響原始切片。例如下面代碼:
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]
從輸出可以很清楚的看到了我們的猜想。 即函數中先改變s第一個元素的值,由於slice和s都共用了底層數組,因此無論原始切片slice還是ret,第一個元素都是-1.然後append操作之後,因為超出了s的容量,因此會建立底層數組,雖然s變數沒變,但是他的底層數組變了,此時修改s第一個元素,並不會影響原始的slice切片。也就是slice[1]還是1,而ret[1]則是-1。最後在外面修改ret[1]為 -1111,也不會影響原始的切片slice。
通過上面的分析,我們大致可以下結論,slice或者array作為函數參數傳遞的時候,本質是傳值而不是傳引用。傳值的過程複製一個新的切片,這個切片也指向原始變數的底層數組。(個人感覺稱之為傳切片可能比傳值的表述更準確)。函數中無論是直接修改切片,還是append建立新的切片,都是基於共用切片底層數組的情況作為基礎。也就是最外面的原始切片是否改變,取決於函數內的操作和切片本身容量。
傳引用方式
array和slice作為參數傳遞的過程基本上是一樣的,即傳遞他們切片。有時候我們需要處理傳遞引用的形式。golang提供了指標很方便實作類別似的功能。
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
從輸出可以看到,傳遞給函數的是slice的指標,函數內對對s的操作本質上都是對slice的操作。並且也可以從函數內打出的s地址看到,至始至終就只有一個切片。雖然在append過程中會出現臨時的切片或數組。
總結
golang提供了array和slice兩種序列結構。其中array是實值型別。slice則是複合類型。slice是基於array實現的。slice的第一個內容為指向數組的指標,然後是其長度和容量。通過array的切片可以切
出slice,也可以使用make建立slice,此時golang會產生一個匿名的數組。
因為slice依賴其底層的array,修改slice本質是修改array,而array又是有大小限制,當超過slice的容量,即數組越界的時候,需要通過動態規劃的方式建立一個新的數組塊。把原有的資料複製到新數組,這個新的array則為slice新的底層依賴。
數組還是切片,在函數中傳遞的不是引用,是另外一種實值型別,即通過原始變數進行切片
傳入。函數內的操作即對切片的修改操作了。當然,如果為了修改原始變數,可以指定參數的類型為指標類型。傳遞的就是slice的記憶體位址。函數內的操作都是根據記憶體位址找到變數本身。
參考資料:Go 切片:用法和本質