Go 切片繞坑指南

KevinYan發表於2019-12-03

在Go中按值傳遞時,為什麼有時會更改切片?

不知道大家有沒有發現在一個函式內部對切片引數進行了排序後也會改變函式外部原來的切片中元素的順序,但是在函式內向切片增加了元素後在函式外的原切片卻沒有新增元素,更奇怪的是新增並排序後,外部的切片有可能元素數量和元素順序都不會變,這是為什麼呢?我們通過三個小測驗來解釋造成這個現象的原因。

測驗一

下面的程式碼的輸出什麼?

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 → https://play.golang.org/p/faJ3WNxpRw

上面的程式碼中雖然通過值傳遞了s,為什麼在函式呼叫後在外部仍能看到s的變化?

大家都知道切片是指向底層陣列的指標,切片本身不儲存任何資料。這意味著即使在這裡按值傳遞切片,函式中的切片仍指向相同的記憶體地址。所以在reverse()內部使用的切片是一個不同的指標物件,但仍將指向相同的記憶體地址,共享相同的陣列。所以在函式呼叫之後,該陣列中的數字重新排列,函式外部的切片與內部的切片共享著相同的底層陣列,所以外部的 s 表現出來的就是它也被排序了。

測驗二

我們將在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 → https://play.golang.org/p/tZpkaLA9c_

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

我們先看一下 slice 的定義

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 → https://play.golang.org/p/dnbKtLZG8y

在我們的最終測驗中,不僅切片長度沒有保留,而且切片的順序也不受影響。為什麼?

如前所述,當我們呼叫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 → https://play.golang.org/p/SBHRj4dPFa

只要不超出切片的容量,我們最終就會在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 → https://play.golang.org/p/8X-57DvgMm

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

type slice struct {
  array unsafe.Pointer
  len   int
  cap   int
}

注意到array欄位實際上是一個指標了嗎?這意味著切片會表現得像Go中其他具有巢狀指標的任何型別一樣,實際上一點都不特殊,它只是恰好是很少有人關注其內部的型別。

最終,這意味著開發人員需要知道他們傳遞的資料型別以及所呼叫的函式可能會如何影響它們。當你將切片傳遞給其他函式或方法時,應該注意函式可能會,也可能不會更改原始切片中的元素。

同樣,你應始終意識到,內部帶有指標的結構很容易陷入相同的情況。除非指標本身被更新為引用記憶體中的另一個物件,否則指標內部資料的任何更改都將被保留。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

公眾號:網管叨bi叨 | Golang、PHP、Laravel、Docker等學習經驗分享

相關文章