在 Go 中使用切片的容量和長度的技巧

linuxprobe2017發表於2018-01-10


快速測試 - 下面的程式碼輸出什麼?

vals := make([]int, 5)   for i := 0; i < 5; i++ {    vals = append(vals, i) } fmt.Println(vals)  

如果你猜測的是 [0 0 0 0 0 0 1 2 3 4],那你是對的。

如果你在測試中做錯了,你也不用擔心。這是在過渡到 Go 語言的過程中相當常見的錯誤,在這篇文章中,我們將說明為什麼輸出不是你預期的,以及如何利用 Go 的細微差別來使你的程式碼更有效率。

切片 vs 陣列

在 Go 中同時有陣列(array)和切片(slice)。這可能令人困惑,但一旦你習慣了,你會喜歡上它。請相信我。

切片和陣列之間存在許多差異,但我們要在本文中重點介紹的內容是陣列的大小是其型別的一部分,而切片可以具有動態大小,因為它們是圍繞陣列的封裝。

這在實踐中意味著什麼?那麼假設我們有陣列 val a [10]int。該陣列具有固定大小,且無法更改。如果我們呼叫 len(a),它總是返回 10,因為這個大小是型別的一部分。因此,如果你突然需要在陣列中超過 10 個項,則必須建立一個完全不同型別的新物件,例如 val b [11]int,然後將所有值從 a 複製到 b。

在特定情況下,含有集合大小的陣列是有價值的,但一般而言,這不是開發人員想要的。相反,他們希望在 Go 中使用類似於陣列的東西,但是隨著時間的推移,它們能夠隨時增長。一個粗略的方式是建立一個比它需要大得多的陣列,然後將陣列的一個子集視為陣列。下面的程式碼是個例子。

var vals [20]int   for i := 0; i < 5; i++ {    vals[i] = i * i } subsetLen := 5 fmt.Println("The subset of our array has a length of:", subsetLen) // Add a new item to our array vals[subsetLen] = 123   subsetLen++   fmt.Println("The subset of our array has a length of:", subsetLen)  

在程式碼中,我們有一個長度為 20 的陣列,但是由於我們只使用一個子集,程式碼中我們可以假定陣列的長度是 5,然後在我們向陣列中新增一個新的項之後是 6。

這是(非常粗略地說)切片是如何工作的。它們包含一個具有設定大小的陣列,就像我們前面的例子中的陣列一樣,它的大小為 20。

它們還跟蹤程式中使用的陣列的子集 - 這就是 append 屬性,它類似於上一個例子中的 subsetLen 變數。

最後,一個切片還有一個 capacity,類似於前面例子中我們的陣列的總長度(20)。這是很有用的,因為它會告訴你的子集在無法容納切片陣列之前可以增長的大小。當發生這種情況時,需要分配一個新的陣列,但所有這些邏輯都隱藏在 append 函式的後面。

簡而言之,使用 append 函式組合切片給我們一個非常類似於陣列的型別,但隨著時間的推移,它可以處理更多的元素。

我們再來看一下前面的例子,但是這次我們將使用切片而不是陣列。

var vals []int   for i := 0; i < 5; i++ {    vals = append(vals, i)  fmt.Println("The length of our slice is:", len(vals))  fmt.Println("The capacity of our slice is:", cap(vals)) } // Add a new item to our array vals = append(vals, 123)   fmt.Println("The length of our slice is:", len(vals))   fmt.Println("The capacity of our slice is:", cap(vals)) // Accessing items is the same as an array fmt.Println(vals[5])   fmt.Println(vals[2])  

我們仍然可以像陣列一樣訪問我們的切片中的元素,但是通過使用切片和 append 函式,我們不再需要考慮背後陣列的大小。我們仍然可以通過使用 len 和 cap 函式來計算出這些東西,但是我們不用擔心太多。簡潔吧?

回到測試

記住這點,讓我們回顧前面的測試,看下什麼出錯了。

vals := make([]int, 5)   for i := 0; i < 5; i++ {    vals = append(vals, i) } fmt.Println(vals)  

當呼叫 make 時,我們允許最多傳入 3 個引數。第一個是我們分配的型別,第二個是型別的“長度”,第三個是型別的“容量”(這個引數是可選的)。

通過傳遞引數 make([]int, 5),我們告訴程式我們要建立一個長度為 5 的切片,在這種情況下,預設的容量與長度相同 - 本例中是 5。

雖然這可能看起來像我們想要的那樣,這裡的重要區別是我們告訴我們的切片,我們要將“長度”和“容量”設定為 5,假設你想要在初始的 5 個元素之後新增新的元素,我們接著呼叫 append 函式,那麼它會增加容量的大小,並且會在切片的最後新增新的元素。

如果在程式碼中新增一條 Println() 語句,你可以看到容量的變化。

vals := make([]int, 5)   fmt.Println("Capacity was:", cap(vals))   for i := 0; i < 5; i++ {    vals = append(vals, i)  fmt.Println("Capacity is now:", cap(vals)) } fmt.Println(vals)  

最後,我們最終得到 [0 0 0 0 0 0 1 2 3 4] 的輸出而不是希望的 [0 1 2 3 4]。

如何修復它呢?好的,這有幾種方法,我們將講解兩種,你可以選取任何一種在你的場景中最有用的方法。

直接使用索引寫入而不是 append

第一種修復是保留 make 呼叫不變,並且顯式地使用索引來設定每個元素。這樣,我們就得到如下的程式碼:

vals := make([]int, 5)   for i := 0; i < 5; i++ {    vals[i] = i } fmt.Println(vals)  

在這種情況下,我們設定的值恰好與我們要使用的索引相同,但是你也可以獨立跟蹤索引。

比如,如果你想要獲取 map 的鍵,你可以使用下面的程式碼。

package main import "fmt" func main() {    fmt.Println(keys(map[string]struct{}{    "dog": struct{}{},    "cat": struct{}{},  })) } func keys(m map[string]struct{}) []string {    ret := make([]string, len(m))  i := 0  for key := range m {    ret[i] = key    i++  }  return ret }

這樣做很好,因為我們知道我們返回的切片的長度將與 map 的長度相同,因此我們可以用該長度初始化我們的切片,然後將每個元素分配到適當的索引中。這種方法的缺點是我們必須跟蹤 i,以便了解每個索引要設定的值。

這就讓我們引出了第二種方法……

使用 0 作為你的長度並指定容量

與其跟蹤我們要新增的值的索引,我們可以更新我們的 make 呼叫,並在切片型別之後提供兩個引數。第一個,我們的新切片的長度將被設定為 0,因為我們還沒有新增任何新的元素到切片中。第二個,我們新切片的容量將被設定為 map 引數的長度,因為我們知道我們的切片最終會新增許多字串。

這會如前面的例子那樣仍舊會在背後構建相同的陣列,但是現在當我們呼叫 append 時,它會將它們放在切片開始處,因為切片的長度是 0。

package main import "fmt" func main() {    fmt.Println(keys(map[string]struct{}{    "dog": struct{}{},    "cat": struct{}{},  })) } func keys(m map[string]struct{}) []string {    ret := make([]string, 0, len(m))  for key := range m {    ret = append(ret, key)  }  return ret }

如果 append 處理它,為什麼我們還要擔心容量呢?

接下來你可能會問:“如果 append 函式可以為我增加切片的容量,那我們為什麼要告訴程式容量呢?”

事實是,在大多數情況下,你不必擔心這太多。如果它使你的程式碼變得更復雜,只需用 var vals []int 初始化你的切片,然後讓 append 函式處理接下來的事。

但這種情況是不同的。它並不是宣告容量困難的例子,實際上這很容易確定我們的切片的最後容量,因為我們知道它將直接對映到提供的 map 中。因此,當我們初始化它時,我們可以宣告切片的容量,並免於讓我們的程式執行不必要的記憶體分配。

如果要檢視額外的記憶體分配情況,請在 Go Playground 上執行以下程式碼。每次增加容量,程式都需要做一次記憶體分配。

package main import "fmt" func main() {    fmt.Println(keys(map[string]struct{}{    "dog":       struct{}{},    "cat":       struct{}{},    "mouse":     struct{}{},    "wolf":      struct{}{},    "alligator": struct{}{},  })) } func keys(m map[string]struct{}) []string {    var ret []string  fmt.Println(cap(ret))  for key := range m {    ret = append(ret, key)    fmt.Println(cap(ret))  }  return ret }

現在將此與相同的程式碼進行比較,但具有預定義的容量。

package main import "fmt" func main() {    fmt.Println(keys(map[string]struct{}{    "dog":       struct{}{},    "cat":       struct{}{},    "mouse":     struct{}{},    "wolf":      struct{}{},    "alligator": struct{}{},  })) } func keys(m map[string]struct{}) []string {    ret := make([]string, 0, len(m))  fmt.Println(cap(ret))  for key := range m {    ret = append(ret, key)    fmt.Println(cap(ret))  }  return ret }

在第一個程式碼示例中,我們的容量從 0 開始,然後增加到 1、 2、 4, 最後是 8,這意味著我們不得不分配 5 次陣列,最後一個容納我們切片的陣列的容量是 8,這比我們最終需要的要大。

另一方面,我們的第二個例子開始和結束都是相同的容量(5),它只需要在 keys() 函式的開頭分配一次。我們還避免了浪費任何額外的記憶體,並返回一個能放下這個陣列的完美大小的切片。

不要過分優化

如前所述,我通常不鼓勵任何人做這樣的小優化,但如果最後大小的效果真的很明顯,那麼我強烈建議你嘗試為切片設定適當的容量或長度。

這不僅有助於提高程式的效能,還可以通過明確說明輸入的大小和輸出的大小之間的關係來幫助澄清你的程式碼。

總結

本文並不是對切片或陣列之間差異的詳細討論,而是簡要介紹了容量和長度如何影響切片,以及它們在方案中的用途。


本文轉載自:http://www.linuxprobe.com/slice-size-length.html

快速測試 - 下面的程式碼輸出什麼?

vals := make([]int, 5)   for i := 0; i &lt; 5; i++ {    vals = append(vals, i) } fmt.Println(vals)  

如果你猜測的是 [0 0 0 0 0 0 1 2 3 4],那你是對的。

如果你在測試中做錯了,你也不用擔心。這是在過渡到 Go 語言的過程中相當常見的錯誤,在這篇文章中,我們將說明為什麼輸出不是你預期的,以及如何利用 Go 的細微差別來使你的程式碼更有效率。

切片 vs 陣列

在 Go 中同時有陣列(array)和切片(slice)。這可能令人困惑,但一旦你習慣了,你會喜歡上它。請相信我。

切片和陣列之間存在許多差異,但我們要在本文中重點介紹的內容是陣列的大小是其型別的一部分,而切片可以具有動態大小,因為它們是圍繞陣列的封裝。

這在實踐中意味著什麼?那麼假設我們有陣列 val a [10]int。該陣列具有固定大小,且無法更改。如果我們呼叫 len(a),它總是返回 10,因為這個大小是型別的一部分。因此,如果你突然需要在陣列中超過 10 個項,則必須建立一個完全不同型別的新物件,例如 val b [11]int,然後將所有值從 a 複製到 b。

在特定情況下,含有集合大小的陣列是有價值的,但一般而言,這不是開發人員想要的。相反,他們希望在 Go 中使用類似於陣列的東西,但是隨著時間的推移,它們能夠隨時增長。一個粗略的方式是建立一個比它需要大得多的陣列,然後將陣列的一個子集視為陣列。下面的程式碼是個例子。

var vals [20]int   for i := 0; i < 5; i++ {    vals[i] = i * i } subsetLen := 5 fmt.Println("The subset of our array has a length of:", subsetLen) // Add a new item to our array vals[subsetLen] = 123   subsetLen++   fmt.Println("The subset of our array has a length of:", subsetLen)  

在程式碼中,我們有一個長度為 20 的陣列,但是由於我們只使用一個子集,程式碼中我們可以假定陣列的長度是 5,然後在我們向陣列中新增一個新的項之後是 6。

這是(非常粗略地說)切片是如何工作的。它們包含一個具有設定大小的陣列,就像我們前面的例子中的陣列一樣,它的大小為 20。

它們還跟蹤程式中使用的陣列的子集 - 這就是 append 屬性,它類似於上一個例子中的 subsetLen 變數。

最後,一個切片還有一個 capacity,類似於前面例子中我們的陣列的總長度(20)。這是很有用的,因為它會告訴你的子集在無法容納切片陣列之前可以增長的大小。當發生這種情況時,需要分配一個新的陣列,但所有這些邏輯都隱藏在 append 函式的後面。

簡而言之,使用 append 函式組合切片給我們一個非常類似於陣列的型別,但隨著時間的推移,它可以處理更多的元素。

我們再來看一下前面的例子,但是這次我們將使用切片而不是陣列。

var vals []int   for i := 0; i < 5; i++ {    vals = append(vals, i)  fmt.Println("The length of our slice is:", len(vals))  fmt.Println("The capacity of our slice is:", cap(vals)) } // Add a new item to our array vals = append(vals, 123)   fmt.Println("The length of our slice is:", len(vals))   fmt.Println("The capacity of our slice is:", cap(vals)) // Accessing items is the same as an array fmt.Println(vals[5])   fmt.Println(vals[2])  

我們仍然可以像陣列一樣訪問我們的切片中的元素,但是通過使用切片和 append 函式,我們不再需要考慮背後陣列的大小。我們仍然可以通過使用 len 和 cap 函式來計算出這些東西,但是我們不用擔心太多。簡潔吧?

回到測試

記住這點,讓我們回顧前面的測試,看下什麼出錯了。

vals := make([]int, 5)   for i := 0; i < 5; i++ {    vals = append(vals, i) } fmt.Println(vals)  

當呼叫 make 時,我們允許最多傳入 3 個引數。第一個是我們分配的型別,第二個是型別的“長度”,第三個是型別的“容量”(這個引數是可選的)。

通過傳遞引數 make([]int, 5),我們告訴程式我們要建立一個長度為 5 的切片,在這種情況下,預設的容量與長度相同 - 本例中是 5。

雖然這可能看起來像我們想要的那樣,這裡的重要區別是我們告訴我們的切片,我們要將“長度”和“容量”設定為 5,假設你想要在初始的 5 個元素之後新增新的元素,我們接著呼叫 append 函式,那麼它會增加容量的大小,並且會在切片的最後新增新的元素。

如果在程式碼中新增一條 Println() 語句,你可以看到容量的變化。

vals := make([]int, 5)   fmt.Println("Capacity was:", cap(vals))   for i := 0; i < 5; i++ {    vals = append(vals, i)  fmt.Println("Capacity is now:", cap(vals)) } fmt.Println(vals)  

最後,我們最終得到 [0 0 0 0 0 0 1 2 3 4] 的輸出而不是希望的 [0 1 2 3 4]。

如何修復它呢?好的,這有幾種方法,我們將講解兩種,你可以選取任何一種在你的場景中最有用的方法。

直接使用索引寫入而不是 append

第一種修復是保留 make 呼叫不變,並且顯式地使用索引來設定每個元素。這樣,我們就得到如下的程式碼:

vals := make([]int, 5)   for i := 0; i < 5; i++ {    vals[i] = i } fmt.Println(vals)  

在這種情況下,我們設定的值恰好與我們要使用的索引相同,但是你也可以獨立跟蹤索引。

比如,如果你想要獲取 map 的鍵,你可以使用下面的程式碼。

package main import "fmt" func main() {    fmt.Println(keys(map[string]struct{}{    "dog": struct{}{},    "cat": struct{}{},  })) } func keys(m map[string]struct{}) []string {    ret := make([]string, len(m))  i := 0  for key := range m {    ret[i] = key    i++  }  return ret }

這樣做很好,因為我們知道我們返回的切片的長度將與 map 的長度相同,因此我們可以用該長度初始化我們的切片,然後將每個元素分配到適當的索引中。這種方法的缺點是我們必須跟蹤 i,以便了解每個索引要設定的值。

這就讓我們引出了第二種方法……

使用 0 作為你的長度並指定容量

與其跟蹤我們要新增的值的索引,我們可以更新我們的 make 呼叫,並在切片型別之後提供兩個引數。第一個,我們的新切片的長度將被設定為 0,因為我們還沒有新增任何新的元素到切片中。第二個,我們新切片的容量將被設定為 map 引數的長度,因為我們知道我們的切片最終會新增許多字串。

這會如前面的例子那樣仍舊會在背後構建相同的陣列,但是現在當我們呼叫 append 時,它會將它們放在切片開始處,因為切片的長度是 0。

package main import "fmt" func main() {    fmt.Println(keys(map[string]struct{}{    "dog": struct{}{},    "cat": struct{}{},  })) } func keys(m map[string]struct{}) []string {    ret := make([]string, 0, len(m))  for key := range m {    ret = append(ret, key)  }  return ret }

如果 append 處理它,為什麼我們還要擔心容量呢?

接下來你可能會問:“如果 append 函式可以為我增加切片的容量,那我們為什麼要告訴程式容量呢?”

事實是,在大多數情況下,你不必擔心這太多。如果它使你的程式碼變得更復雜,只需用 var vals []int 初始化你的切片,然後讓 append 函式處理接下來的事。

但這種情況是不同的。它並不是宣告容量困難的例子,實際上這很容易確定我們的切片的最後容量,因為我們知道它將直接對映到提供的 map 中。因此,當我們初始化它時,我們可以宣告切片的容量,並免於讓我們的程式執行不必要的記憶體分配。

如果要檢視額外的記憶體分配情況,請在 Go Playground 上執行以下程式碼。每次增加容量,程式都需要做一次記憶體分配。

package main import "fmt" func main() {    fmt.Println(keys(map[string]struct{}{    "dog":       struct{}{},    "cat":       struct{}{},    "mouse":     struct{}{},    "wolf":      struct{}{},    "alligator": struct{}{},  })) } func keys(m map[string]struct{}) []string {    var ret []string  fmt.Println(cap(ret))  for key := range m {    ret = append(ret, key)    fmt.Println(cap(ret))  }  return ret }

現在將此與相同的程式碼進行比較,但具有預定義的容量。

package main import "fmt" func main() {    fmt.Println(keys(map[string]struct{}{    "dog":       struct{}{},    "cat":       struct{}{},    "mouse":     struct{}{},    "wolf":      struct{}{},    "alligator": struct{}{},  })) } func keys(m map[string]struct{}) []string {    ret := make([]string, 0, len(m))  fmt.Println(cap(ret))  for key := range m {    ret = append(ret, key)    fmt.Println(cap(ret))  }  return ret }

在第一個程式碼示例中,我們的容量從 0 開始,然後增加到 1、 2、 4, 最後是 8,這意味著我們不得不分配 5 次陣列,最後一個容納我們切片的陣列的容量是 8,這比我們最終需要的要大。

另一方面,我們的第二個例子開始和結束都是相同的容量(5),它只需要在 keys() 函式的開頭分配一次。我們還避免了浪費任何額外的記憶體,並返回一個能放下這個陣列的完美大小的切片。

不要過分優化

如前所述,我通常不鼓勵任何人做這樣的小優化,但如果最後大小的效果真的很明顯,那麼我強烈建議你嘗試為切片設定適當的容量或長度。

這不僅有助於提高程式的效能,還可以通過明確說明輸入的大小和輸出的大小之間的關係來幫助澄清你的程式碼。

總結

本文並不是對切片或陣列之間差異的詳細討論,而是簡要介紹了容量和長度如何影響切片,以及它們在方案中的用途。


本文轉載自:http://www.linuxprobe.com/slice-size-length.html

相關文章