07 | 陣列和切片
我們這次主要討論 Go 語言的陣列(array)型別和切片(slice)型別。
它們的共同點是都屬於集合類的型別,並且,它們的值也都可以用來儲存某一種型別的值(或者說元素)。
不過,它們最重要的不同是:陣列型別的值(以下簡稱陣列)的長度是固定的,而切片型別的值(以下簡稱切片)是可變長的。
陣列的長度在宣告它的時候就必須給定,並且之後不會再改變。可以說,陣列的長度是其型別的一部分。比如,[1]string和[2]string就是兩個不同的陣列型別。
而切片的型別字面量中只有元素的型別,而沒有長度。切片的長度可以自動地隨著其中元素數量的增長而增長,但不會隨著元素數量的減少而減小。
我們其實可以把切片看做是對陣列的一層簡單的封裝,因為在每個切片的底層資料結構中,一定會包含一個陣列。陣列可以被叫做切片的底層陣列,而切片也可以被看作是對陣列的某個連續片段的引用。
也正因為如此,Go 語言的切片型別屬於引用型別,同屬引用型別的還有字典型別、通道型別、函式型別等;而 Go 語言的陣列型別則屬於值型別,同屬值型別的有基礎資料型別以及結構體型別。
注意,Go 語言裡不存在像 Java 等程式語言中令人困惑的“傳值或傳引用”問題。在 Go 語言中,我們判斷所謂的“傳值”或者“傳引用”只要看被傳遞的值的型別就好了。
如果傳遞的值是引用型別的,那麼就是“傳引用”。如果傳遞的值是值型別的,那麼就是“傳值”。從傳遞成本的角度講,引用型別的值往往要比值型別的值低很多。
我們在陣列和切片之上都可以應用索引表示式,得到的都會是某個元素。我們在它們之上也都可以應用切片表示式,也都會得到一個新的切片。
我們通過呼叫內建函式len,得到陣列和切片的長度。通過呼叫內建函式cap,我們可以得到它們的容量。
但要注意,陣列的容量永遠等於其長度,都是不可變的。切片的容量卻不是這樣,並且它的變化是有規律可尋的。
我們今天的問題就是:怎樣正確估算切片的長度和容量?
為此,我編寫了一個簡單的命令原始碼檔案 demo15.go。
package main
import "fmt"
func main() {
// 示例1。
s1 := make([]int, 5)
fmt.Printf("The length of s1: %d\n", len(s1))
fmt.Printf("The capacity of s1: %d\n", cap(s1))
fmt.Printf("The value of s1: %d\n", s1)
s2 := make([]int, 5, 8)
fmt.Printf("The length of s2: %d\n", len(s2))
fmt.Printf("The capacity of s2: %d\n", cap(s2))
fmt.Printf("The value of s2: %d\n", s2)
}
首先,我用內建函式make宣告瞭一個[]int型別的變數s1。我傳給make函式的第二個引數是5,從而指明瞭該切片的長度。我用幾乎同樣的方式宣告瞭切片s2,只不過多傳入了一個引數8以指明該切片的容量。
現在,具體的問題是:切片s1和s2的容量都是多少?
這道題的典型回答:切片s1和s2的容量分別是5和8。
問題解析
解析一下這道題。s1的容量為什麼是5呢?因為我在宣告s1的時候把它的長度設定成了5。當我們用make函式初始化切片時,如果不指明其容量,那麼它就會和長度一致。如果在初始化時指明瞭容量,那麼切片的實際容量也就是它了。這也正是s2的容量是8的原因。
我們順便通過s2再來明確下長度、容量以及它們的關係。我在初始化s2代表的切片時,同時也指定了它的長度和容量。
我在剛才說過,可以把切片看做是對陣列的一層簡單的封裝,因為在每個切片的底層資料結構中,一定會包含一個陣列。陣列可以被叫做切片的底層陣列,而切片也可以被看作是對陣列的某個連續片段的引用。
在這種情況下,切片的容量實際上代表了它的底層陣列的長度,這裡是8。(注意,切片的底層陣列等同於我們前面講到的陣列,其長度不可變。)
現在你需要跟著我一起想象:有一個視窗,你可以通過這個視窗看到一個陣列,但是不一定能看到該陣列中的所有元素,有時候只能看到連續的一部分元素。
現在,這個陣列就是切片s2的底層陣列,而這個視窗就是切片s2本身。s2的長度實際上指明的就是這個視窗的寬度,決定了你透過s2,可以看到其底層陣列中的哪幾個連續的元素。
由於s2的長度是5,所以你可以看到底層陣列中的第 1 個元素到第 5 個元素,對應的底層陣列的索引範圍是[0, 4]。
切片代表的視窗也會被劃分成一個一個的小格子,就像我們家裡的窗戶那樣。每個小格子都對應著其底層陣列中的某一個元素。
我們繼續拿s2為例,這個視窗最左邊的那個小格子對應的正好是其底層陣列中的第一個元素,即索引為0的那個元素。因此可以說,s2中的索引從0到4所指向的元素恰恰就是其底層陣列中索引從0到4代表的那 5 個元素。
請記住,當我們用make函式或切片值字面量(比如[]int{1, 2, 3})初始化一個切片時,該視窗最左邊的那個小格子總是會對應其底層陣列中的第 1 個元素。
但是當我們通過切片表示式基於某個陣列或切片生成新切片的時候,情況就變得複雜起來了。
我們再來看一個例子:
s3 := []int{1, 2, 3, 4, 5, 6, 7, 8}
s4 := s3[3:6]
fmt.Printf("The length of s4: %d\n", len(s4))
fmt.Printf("The capacity of s4: %d\n", cap(s4))
fmt.Printf("The value of s4: %d\n", s4)
切片s3中有 8 個元素,分別是從1到8的整數。s3的長度和容量都是8。然後,我用切片表示式s3[3:6]初始化了切片s4。問題是,這個s4的長度和容量分別是多少?
這並不難,用減法就可以搞定。首先你要知道,切片表示式中的方括號裡的那兩個整數都代表什麼。我換一種表達方式你也許就清楚了,即:[3, 6)。
這是數學中的區間表示法,常用於表示取值範圍,我其實已經在本專欄用過好幾次了。由此可知,[3:6]要表達的就是透過新視窗能看到的s3中元素的索引範圍是從3到5(注意,不包括6)。
這裡的3可被稱為起始索引,6可被稱為結束索引。那麼s4的長度就是6減去3,即3。因此可以說,s4中的索引從0到2指向的元素對應的是s3及其底層陣列中索引從3到5的那 3 個元素。
(切片與陣列的關係)
再來看容量。我在前面說過,切片的容量代表了它的底層陣列的長度,但這僅限於使用make函式或者切片值字面量初始化切片的情況。
更通用的規則是:一個切片的容量可以被看作是透過這個視窗最多可以看到的底層陣列中元素的個數。
由於s4是通過在s3上施加切片操作得來的,所以s3的底層陣列就是s4的底層陣列。
又因為,在底層陣列不變的情況下,切片代表的視窗可以向右擴充套件,直至其底層陣列的末尾。
所以,s4的容量就是其底層陣列的長度8, 減去上述切片表示式中的那個起始索引3,即5。
注意,切片代表的視窗是無法向左擴充套件的。也就是說,我們永遠無法透過s4看到s3中最左邊的那 3 個元素。
最後,順便提一下把切片的視窗向右擴充套件到最大的方法。對於s4來說,切片表示式s4[0:cap(s4)]就可以做到。我想你應該能看懂。該表示式的結果值(即一個新的切片)會是[]int{4, 5, 6, 7, 8},其長度和容量都是5。
知識擴充套件
問題 1:怎樣估算切片容量的增長?
一旦一個切片無法容納更多的元素,Go 語言就會想辦法擴容。但它並不會改變原來的切片,而是會生成一個容量更大的切片,然後將把原有的元素和新元素一併拷貝到新切片中。在一般的情況下,你可以簡單地認為新切片的容量(以下簡稱新容量)將會是原切片容量(以下簡稱原容量)的 2 倍。
但是,當原切片的長度(以下簡稱原長度)大於或等於1024時,Go 語言將會以原容量的1.25倍作為新容量的基準(以下新容量基準)。新容量基準會被調整(不斷地與1.25相乘),直到結果不小於原長度與要追加的元素數量之和(以下簡稱新長度)。最終,新容量往往會比新長度大一些,當然,相等也是可能的。
另外,如果我們一次追加的元素過多,以至於使新長度比原容量的 2 倍還要大,那麼新容量就會以新長度為基準。注意,與前面那種情況一樣,最終的新容量在很多時候都要比新容量基準更大一些。更多細節可參見runtime包中 slice.go 檔案裡的growslice及相關函式的具體實現。
我把展示上述擴容策略的一些例子都放到了 demo16.go 檔案中。你可以去試執行看看。
package main
import "fmt"
func main() {
// 示例1。
s6 := make([]int, 0)
fmt.Printf("The capacity of s6: %d\n", cap(s6))
for i := 1; i <= 5; i++ {
s6 = append(s6, i)
fmt.Printf("s6(%d): len: %d, cap: %d\n", i, len(s6), cap(s6))
}
fmt.Println()
// 示例2。
s7 := make([]int, 1024)
fmt.Printf("The capacity of s7: %d\n", cap(s7))
s7e1 := append(s7, make([]int, 200)...)
fmt.Printf("s7e1: len: %d, cap: %d\n", len(s7e1), cap(s7e1))
s7e2 := append(s7, make([]int, 400)...)
fmt.Printf("s7e2: len: %d, cap: %d\n", len(s7e2), cap(s7e2))
s7e3 := append(s7, make([]int, 600)...)
fmt.Printf("s7e3: len: %d, cap: %d\n", len(s7e3), cap(s7e3))
fmt.Println()
// 示例3。
s8 := make([]int, 10)
fmt.Printf("The capacity of s8: %d\n", cap(s8))
s8a := append(s8, make([]int, 11)...)
fmt.Printf("s8a: len: %d, cap: %d\n", len(s8a), cap(s8a))
s8b := append(s8a, make([]int, 23)...)
fmt.Printf("s8b: len: %d, cap: %d\n", len(s8b), cap(s8b))
s8c := append(s8b, make([]int, 45)...)
fmt.Printf("s8c: len: %d, cap: %d\n", len(s8c), cap(s8c))
}
問題 2:切片的底層陣列什麼時候會被替換?
確切地說,一個切片的底層陣列永遠不會被替換。為什麼?雖然在擴容的時候 Go 語言一定會生成新的底層陣列,但是它也同時生成了新的切片。
它只是把新的切片作為了新底層陣列的視窗,而沒有對原切片,及其底層陣列做任何改動。
請記住,在無需擴容時,append函式返回的是指向原底層陣列的原切片,而在需要擴容時,append函式返回的是指向新底層陣列的新切片。所以,嚴格來講,“擴容”這個詞用在這裡雖然形象但並不合適。不過鑑於這種稱呼已經用得很廣泛了,我們也沒必要另找新詞了。
順便說一下,只要新長度不會超過切片的原容量,那麼使用append函式對其追加元素的時候就不會引起擴容。這隻會使緊鄰切片視窗右邊的(底層陣列中的)元素被新的元素替換掉。你可以執行 demo17.go 檔案以增強對這些知識的理解。
package main
import "fmt"
func main() {
// 示例1。
a1 := [7]int{1, 2, 3, 4, 5, 6, 7}
fmt.Printf("a1: %v (len: %d, cap: %d)\n",
a1, len(a1), cap(a1))
s9 := a1[1:4]
//s9[0] = 1
fmt.Printf("s9: %v (len: %d, cap: %d)\n",
s9, len(s9), cap(s9))
for i := 1; i <= 5; i++ {
s9 = append(s9, i)
fmt.Printf("s9(%d): %v (len: %d, cap: %d)\n",
i, s9, len(s9), cap(s9))
}
fmt.Printf("a1: %v (len: %d, cap: %d)\n",
a1, len(a1), cap(a1))
fmt.Println()
}
總結
總結一下,我們今天一起探討了陣列和切片以及它們之間的關係。切片是基於陣列的,可變長的,並且非常輕快。一個切片的容量總是固定的,而且一個切片也只會與某一個底層陣列繫結在一起。
此外,切片的容量總會是在切片長度和底層陣列長度之間的某一個值,並且還與切片視窗最左邊對應的元素在底層陣列中的位置有關係。那兩個分別用減法計算切片長度和容量的方法你一定要記住
另外,如果新的長度比原有切片的容量還要大,那麼底層陣列就一定會是新的,而且append函式也會返回一個新的切片。還有,你其實不必太在意切片“擴容”策略中的一些細節,只要能夠理解它的基本規律並可以進行近似的估算就可以了。
思考題
這裡仍然是聚焦於切片的問題。
- 如果有多個切片指向了同一個底層陣列,那麼你認為應該注意些什麼?
- 怎樣沿用“擴容”的思想對切片進行“縮容”?請寫出程式碼。
本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。
歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。