Go 切片繞坑指南

在Go中按值傳遞時,爲何有時會更改切片?

不知道你們有沒有發如今一個函數內部對切片參數進行了排序後也會改變函數外部原來的切片中元素的順序,可是在函數內向切片增長了元素後在函數外的原切片卻沒有新增元素,更奇怪的是添加並排序後,外部的切片有可能元素數量和元素順序都不會變,這是爲何呢?咱們經過三個小測驗來解釋形成這個現象的緣由。golang

測驗一

下面的代碼的輸出什麼?編程

func main() {
  var s []int
  for i := 1; i <= 3; i++ {
    s = append(s, i)
  }
  reverse(s)
  fmt.Println(s)
}

func reverse(s []int) {
  for i, j := 0, len(s) - 1; i < j; i++ {
    j = len(s) - (i + 1)
    s[i], s[j] = s[j], s[i]
  }
}
複製代碼

Run it on the Go Playground → play.golang.org/p/faJ3WNxpR…數組

上面的代碼中雖然經過值傳遞了s,爲何在函數調用後在外部仍能看到s的變化?bash

你們都知道切片是指向底層數組的指針,切片自己不存儲任何數據。這意味着即便在這裏按值傳遞切片,函數中的切片仍指向相同的內存地址。因此在reverse()內部使用的切片是一個不一樣的指針對象,但仍將指向相同的內存地址,共享相同的數組。因此在函數調用以後,該數組中的數字從新排列,函數外部的切片與內部的切片共享着相同的底層數組,因此外部的 s 表現出來的就是它也被排序了。app

測驗二

咱們將在reverse()函數內稍微更改一下代碼,在函數裏添加單個append調用。它如何改變咱們的輸出?編程語言

func main() {
  var s []int
  for i := 1; i <= 3; i++ {
    s = append(s, i)
  }
  reverse(s)
  fmt.Println(s)
}

func reverse(s []int) {
  s = append(s, 999)
  for i, j := 0, len(s) - 1; i < j; i++ {
    j = len(s) - (i + 1)
    s[i], s[j] = s[j], s[i]
  }
}
複製代碼

Run it on the Go Playground → play.golang.org/p/tZpkaLA9c…函數

這一次,在函數外面輸出s時能夠看到它保持了排序後的順序,可是以前的元素1去哪了?ui

咱們先看一下 slice 的定義spa

type slice struct {
  array unsafe.Pointer
  len   int
  cap   int
}
複製代碼

當咱們調用append時,將建立一個新切片。新切片具備新的「長度」屬性,該屬性不是指針,但仍指向同一數組。所以,咱們函數內的代碼最終會反轉原始切片所引用的數組,可是原始切片的長度屬性仍是以前的長度值,這就是形成了上面 1被丟掉的緣由。指針

最終測驗

最後咱們在reverse()函數內部的切片中添加一些額外的數字。函數執行完後在外部打印切片s看看會輸出什麼。

func main() {
  var s []int
  for i := 1; i <= 3; i++ {
    s = append(s, i)
  }
  reverse(s)
  fmt.Println(s)
}

func reverse(s []int) {
  s = append(s, 999, 1000, 1001)
  for i, j := 0, len(s)-1; i < j; i++ {
    j = len(s) - (i + 1)
    s[i], s[j] = s[j], s[i]
  }
}
複製代碼

Run it on the Go Playground → play.golang.org/p/dnbKtLZG8…

在咱們的最終測驗中,不只切片長度沒有保留,並且切片的順序也不受影響。爲何?

如前所述,當咱們調用append時,會建立一個新的切片。在第二個測驗中,此新切片仍指向同一底層數組,由於它具備足夠的容量來添加新元素,所以該數組沒有更改,可是在此示例中,咱們添加了三個元素,而咱們的切片沒有足夠的容量。因而 系統分配了一個新數組,讓切片指向該數組。當咱們最終在reverse函數內開始反轉切片中的元素時,它再也不影響咱們的初始數組,而是在徹底不一樣的數組上運行。

經過 cap 函數驗證咱們的結論

咱們能夠經過使用cap函數來檢查傳遞給reverse()的切片的容量來驗證正在發生的事情。

func reverse(s []int) {
  newElem := 999
  for len(s) < cap(s) {
    fmt.Println("Adding an element:", newElem, "cap:", cap(s), "len:", len(s))
    s = append(s, newElem)
    newElem++
  }
  for i, j := 0, len(s)-1; i < j; i++ {
    j = len(s) - (i + 1)
    s[i], s[j] = s[j], s[i]
  }
}
複製代碼

Run it on the Go Playground → play.golang.org/p/SBHRj4dPF…

只要不超出切片的容量,咱們最終就會在main()函數中看到reverse函數對切片進行的更改。咱們仍然看不到長度的變化,可是咱們將看到切片的底層數組中元素的從新排列。

若是在將切片填充到容量長度後,在s上再調用append(),咱們將不會再在main()函數中看到這些更改,由於咱們的reverse 函數中的代碼將一個新切片指向到了一個徹底不一樣的數組。

從切片或數組派生的切片也會受到影響

若是咱們恰巧在代碼中建立了從現有切片或數組派生的新切片,那麼咱們也能夠看到相同的效果。例如,若是您調用s2:= s [:]而後將s2傳遞到咱們的reverse()函數中,則可能最終仍會影響s,由於s2s都指向同一個支持數組。一樣,若是咱們向s2附加新元素,最終致使其超出支持數組,咱們將再也不看到對一個切片的更改會影響另外一個切片。

嚴格來講,這不是一件壞事。經過在絕對須要以前不隨意複製基礎數組,咱們最終得到了效率更高的代碼,但編寫代碼時須要考慮到這一點,因此想確保在函數外也能看到函數內程序對切片的更改,那麼在函數中必定要把新切片 return 給外部,即便切片是一種引用類型。這也是不要其餘編程語言經驗帶入到 Go上的緣由。

這個問題不只限於切片類型

這不只限於切片。切片是最容易陷入此陷阱的類型,可是任何帶有指針的類型均可能受到影響。以下所示。

type A struct {
  Ptr1 *B
  Ptr2 *B
  Val B
}

type B struct {
  Str string
}

func main() {
  a := A{
    Ptr1: &B{"ptr-str-1"},
    Ptr2: &B{"ptr-str-2"},
    Val: B{"val-str"},
  }
  fmt.Println(a.Ptr1)
  fmt.Println(a.Ptr2)
  fmt.Println(a.Val)
  demo(a)
  fmt.Println(a.Ptr1)
  fmt.Println(a.Ptr2)
  fmt.Println(a.Val)
}

func demo(a A) {
  // Update a value of a pointer and changes will persist
  a.Ptr1.Str = "new-ptr-str1"
  // Use an entirely new B object and changes won't persist
  a.Ptr2 = &B{"new-ptr-str-2"}
  a.Val.Str = "new-val-str"
}
複製代碼

Run it on the Go Playground → play.golang.org/p/8X-57DvgM…

和這個例子相似,在 Go 中切片的定義以下:

type slice struct {
  array unsafe.Pointer
  len   int
  cap   int
}
複製代碼

注意到array字段其實是一個指針了嗎?這意味着切片會表現得像Go中其餘具備嵌套指針的任何類型同樣,實際上一點都不特殊,它只是剛好是不多有人關注其內部的類型。

最終,這意味着開發人員須要知道他們傳遞的數據類型以及所調用的函數可能會如何影響它們。當你將切片傳遞給其餘函數或方法時,應該注意函數可能會,也可能不會更改原始切片中的元素。

一樣,你應始終意識到,內部帶有指針的結構很容易陷入相同的狀況。除非指針自己被更新爲引用內存中的另外一個對象,不然指針內部數據的任何更改都將被保留。

相關文章
相關標籤/搜索