在 Go 中如何使用切片的容量和長度
快速測試 - 下面的代碼輸出什麼?
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]
,那你是對的。
等等,什麼? 為什麼不是 [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()
函數的開頭分配一次。我們還避免了浪費任何額外的內存,並返回一個能放下這個數組的完美大小的切片。
不要過分優化
如前所述,我通常不鼓勵任何人做這樣的小優化,但如果最後大小的效果真的很明顯,那麼我強烈建議你嘗試為切片設置適當的容量或長度。
這不僅有助於提高程序的性能,還可以通過明確說明輸入的大小和輸出的大小之間的關係來幫助澄清你的代碼。
總結
你好!我寫了很多關於Go、Web 開發和其他我覺得有趣的話題。
如果你想跟上最新的文章,請註冊我的郵件列表。我會給你發送我新書的樣例、Go 的 Web 開發、以及每當有新文章(通常每周 1-2 次)會給你發送郵件。
哦,我保證不會發垃圾郵件。我像你一樣討厭它 🙂
本文並不是對切片或數組之間差異的詳細討論,而是簡要介紹了容量和長度如何影響切片,以及它們在方案中的用途。
為了進一步閱讀,我強烈推薦 Go 博客中的以下文章:
作者簡介:
Jon 是一名軟體顧問,也是 《Web Development with Go》 一書的作者。在此之前,他創立了 EasyPost,一家 Y Combinator 支持的創業公司,並在 Google 工作。
via: https://www.calhoun.io/how-to-use-slice-capacity-and-length-in-go
作者:Jon Calhoun 譯者:geekpi 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive