Golang 切片與函數參數“陷阱”

來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。

線性結構是電腦最常用的資料結構之一。無論是數組(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的原理大致如下:

  1. 建立一個新的臨時切片t,t的長度和slice切片的長度一樣,但是t的容量是slice切片的2倍,一個動態規劃的方式。建立切片的時候,底層也建立了一個匿名的數組,數組的長度和切片容量一樣。
  2. 複製s裡面的元素到t裡,即填入匿名數組中。然後把t賦值給slice,現在slice的指向了底層的匿名數組。
  3. 轉變成小於容量的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 切片:用法和本質

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.