《快學 Go 語言》第 5 課 —— 靈活的切片

老錢發表於2018-11-12

切片無疑是 Go 語言中最重要的資料結構,也是最有趣的資料結構,它的英文詞彙叫 slice。所有的 Go 語言開發者都津津樂道地談論切片的內部機制,它也是 Go 語言技能面試中面試官最愛問的知識點之一。初級使用者很容易濫用它,這小小的切片想要徹底的理解它是需要花費一番功夫的。在使用切片之前,我覺得很有必要將切片的內部結構做一下說明。

學過 Java 語言的人會比較容易理解切片,因為它的內部結構非常類似於 ArrayList,ArrayList 的內部實現也是一個陣列。當陣列容量不夠需要擴容時,就會換新的陣列,還需要將老陣列的內容拷貝到新陣列。ArrayList 內部有兩個非常重要的屬性 capacity 和 length。capacity 表示內部陣列的總長度,length 表示當前已經使用的陣列的長度。length 永遠不能超過 capacity。

《快學 Go 語言》第 5 課 —— 靈活的切片

上圖中一個切片變數包含三個域,分別是底層陣列的指標、切片的長度 length 和切片的容量 capacity。切片支援 append 操作可以將新的內容追加到底層陣列,也就是填充上面的灰色格子。如果格子滿了,切片就需要擴容,底層的陣列就會更換。

形象一點說,切片變數是底層陣列的檢視,底層陣列是臥室,切片變數是臥室的窗戶。通過窗戶我們可以看見底層陣列的一部分或全部。一個臥室可以有多個窗戶,不同的窗戶能看到臥室的不同部分。

切片的建立

切片的建立有多種方式,我們先看切片最通用的建立方法,那就是內建的 make 函式

package main

import "fmt"

func main() {
 var s1 []int = make([]int, 5, 8)
 var s2 []int = make([]int, 8) // 滿容切片
 fmt.Println(s1)
 fmt.Println(s2)
}

-------------
[0 0 0 0 0]
[0 0 0 0 0 0 0 0]
複製程式碼

make 函式建立切片,需要提供三個引數,分別是切片的型別、切片的長度和容量。其中第三個引數是可選的,如果不提供第三個引數,那麼長度和容量相等,也就是說切片的滿容的。切片和普通變數一樣,也可以使用型別自動推導,省去型別定義以及 var 關鍵字。比如上面的程式碼和下面的程式碼是等價的。

package main

import "fmt"

func main() {
 var s1 = make([]int, 5, 8)
 s2 := make([]int, 8)
 fmt.Println(s1)
 fmt.Println(s2)
}

-------------
[0 0 0 0 0]
[0 0 0 0 0 0 0 0]
複製程式碼

切片的初始化

使用 make 函式建立的切片內容是「零值切片」,也就是內部陣列的元素都是零值。Go 語言還提供了另一個種建立切片的語法,允許我們給它賦初值。使用這種方式建立的切片是滿容的。

package main

import "fmt"

func main() {
 var s []int = []int{1,2,3,4,5}  // 滿容的
 fmt.Println(s, len(s), cap(s))
}

---------
[1 2 3 4 5] 5 5
複製程式碼

Go 語言提供了內建函式 len() 和 cap() 可以直接獲得切片的長度和容量屬性。

空切片

在建立切片時,還有兩個非常特殊的情況需要考慮,那就是容量和長度都是零的切片,叫著「空切片」,這個不同於前面說的「零值切片」。

package main

import "fmt"

func main() {
 var s1 []int
 var s2 []int = []int{}
 var s3 []int = make([]int, 0)
 fmt.Println(s1, s2, s3)
 fmt.Println(len(s1), len(s2), len(s3))
 fmt.Println(cap(s1), cap(s2), cap(s3))
}

-----------
[] [] []
0 0 0
0 0 0
複製程式碼

上面三種形式建立的切片都是「空切片」,不過在內部結構上這三種形式是有差異的,甚至第一種都不叫「空切片」,而是叫著「 nil 切片」。但是在形式上它們一摸一樣,用起來沒有區別。所以初級使用者可以不必區分「空切片」和「 nil 切片」,到後續章節我們會仔細分析這兩種形式的區別。

切片的賦值

切片的賦值是一次淺拷貝操作,拷貝的是切片變數的三個域,你可以將切片變數看成長度為 3 的 int 型陣列,陣列的賦值就是淺拷貝。拷貝前後兩個變數共享底層陣列,對一個切片的修改會影響另一個切片的內容,這點需要特別注意。

package main

import "fmt"

func main() {
 var s1 = make([]int, 5, 8)
 // 切片的訪問和陣列差不多
 for i := 0; i < len(s1); i++ {
  s1[i] = i + 1
 }
 var s2 = s1
 fmt.Println(s1, len(s1), cap(s1))
 fmt.Println(s2, len(s2), cap(s2))
 
 // 嘗試修改切片內容
 s2[0] = 255
 fmt.Println(s1)
 fmt.Println(s2)
}

--------
[1 2 3 4 5] 5 8
[1 2 3 4 5] 5 8
[255 2 3 4 5]
[255 2 3 4 5]
複製程式碼

從上面的輸出中可以看到賦值的兩切片共享了底層陣列。

切片的遍歷

切片在遍歷的語法上和陣列是一樣的,除了支援下標遍歷外,那就是使用 range 關鍵字

package main


import "fmt"


func main() {
	var s = []int{1,2,3,4,5}
	for index := range s {
		fmt.Println(index, s[index])
	}
	for index, value := range s {
		fmt.Println(index, value)
	}
}

--------
0 1
1 2
2 3
3 4
4 5
0 1
1 2
2 3
3 4
4 5
複製程式碼

切片的追加

文章開頭提到切片是動態的陣列,其長度是可以變化的。什麼操作可以改變切片的長度呢,這個操作就是追加操作。切片每一次追加後都會形成新的切片變數,如果底層陣列沒有擴容,那麼追加前後的兩個切片變數共享底層陣列,如果底層陣列擴容了,那麼追加前後的底層陣列是分離的不共享的。如果底層陣列是共享的,一個切片的內容變化就會影響到另一個切片,這點需要特別注意。

package main

import "fmt"

func main() {
 var s1 = []int{1,2,3,4,5}
 fmt.Println(s1, len(s1), cap(s1))

 // 對滿容的切片進行追加會分離底層陣列
 var s2 = append(s1, 6)
 fmt.Println(s1, len(s1), cap(s1))
 fmt.Println(s2, len(s2), cap(s2))

 // 對非滿容的切片進行追加會共享底層陣列
 var s3 = append(s2, 7)
 fmt.Println(s2, len(s2), cap(s2))
 fmt.Println(s3, len(s3), cap(s3))
}

--------------------------
[1 2 3 4 5] 5 5
[1 2 3 4 5] 5 5
[1 2 3 4 5 6] 6 10
[1 2 3 4 5 6] 6 10
[1 2 3 4 5 6 7] 7 10
複製程式碼

正是因為切片追加後是新的切片變數,Go 編譯器禁止追加了切片後不使用這個新的切片變數,以避免使用者以為追加操作的返回值和原切片變數是同一個變數。

package main

import "fmt"

func main() {
 var s1 = []int{1,2,3,4,5}
 append(s1, 6)
 fmt.Println(s1)
}

--------------
./main.go:7:8: append(s1, 6) evaluated but not used
複製程式碼

如果你真的不需要使用這個新的變數,可以將 append 的結果賦值給下劃線變數。下劃線變數是 Go 語言特殊的內建變數,它就像一個黑洞,可以將任意變數賦值給它,但是卻不能讀取這個特殊變數。

package main

import "fmt"

func main() {
 var s1 = []int{1,2,3,4,5}
 _ = append(s1, 6)
 fmt.Println(s1)
}

----------
[1 2 3 4 5]
複製程式碼

還需要注意的是追加雖然會導致底層陣列發生擴容,更換的新的陣列,但是舊陣列並不會立即被銷燬被回收,因為老切片還指向這舊陣列。

切片的域是隻讀的

我們剛才說切片的長度是可以變化的,為什麼又說切片是隻讀的呢?這不是矛盾麼。這是為了提醒讀者注意切片追加後形成了一個新的切片變數,而老的切片變數的三個域其實並不會改變,改變的只是底層的陣列。這裡說的是切片的「域」是隻讀的,而不是說切片是隻讀的。切片的「域」就是組成切片變數的三個部分,分別是底層陣列的指標、切片的長度和切片的容量。這裡讀者需要仔細咀嚼。

切割切割

到目前位置還沒有說明切片名字的由來,既然叫著切片,那總得可以切割吧。切割切割,有些人聽到這個詞彙時身上會起雞皮疙瘩。切片的切割可以類比字串的子串,它並不是要把切片割斷,而是從母切片中拷貝出一個子切片來,子切片和母切片共享底層陣列。下面我們來看一下切片究竟是如何切割的。

package main

import "fmt"

func main() {
 var s1 = []int{1,2,3,4,5,6,7}
 // start_index 和 end_index,不包含 end_index
 // [start_index, end_index)
 var s2 = s1[2:5] 
 fmt.Println(s1, len(s1), cap(s1))
 fmt.Println(s2, len(s2), cap(s2))
}

------------
[1 2 3 4 5 6 7] 7 7
[3 4 5] 3 5
複製程式碼

上面的輸出需要特別注意的是,既然切割前後共享底層陣列,那為什麼容量不一樣呢?解釋它我必須要畫圖了,讀者請務必仔細觀察下面這張圖

《快學 Go 語言》第 5 課 —— 靈活的切片

我們注意到子切片的內部資料指標指向了陣列的中間位置,而不再是陣列的開頭了。子切片容量的大小是從中間的位置開始直到切片末尾的長度,母子切片依舊共享底層陣列。

子切片語法上要提供起始和結束位置,這兩個位置都可選的,不提供起始位置,預設就是從母切片的初始位置開始(不是底層陣列的初始位置),不提供結束位置,預設就結束到母切片尾部(是長度線,不是容量線)。下面我們看個例子

package main

import "fmt"

func main() {
 var s1 = []int{1, 2, 3, 4, 5, 6, 7}
 var s2 = s1[:5]
 var s3 = s1[3:]
 var s4 = s1[:]
 fmt.Println(s1, len(s1), cap(s1))
 fmt.Println(s2, len(s2), cap(s2))
 fmt.Println(s3, len(s3), cap(s3))
 fmt.Println(s4, len(s4), cap(s4))
}

-----------
[1 2 3 4 5 6 7] 7 7
[1 2 3 4 5] 5 7
[4 5 6 7] 4 4
[1 2 3 4 5 6 7] 7 7
複製程式碼

細心的同學可能會注意到上面的 s1[:] 很特別,它和普通的切片賦值有區別麼?答案是沒區別,這非常讓人感到意外,同樣的共享底層陣列,同樣是淺拷貝。下面我們來驗證一下

package main

import "fmt"

func main() {
 var s = make([]int, 5, 8)
 for i:=0;i<len(s);i++ {
  s[i] = i+1
 }
 fmt.Println(s, len(s), cap(s))

 var s2 = s
 var s3 = s[:]
 fmt.Println(s2, len(s2), cap(s2))
 fmt.Println(s3, len(s3), cap(s3))

 // 修改母切片
 s[0] = 255
 fmt.Println(s, len(s), cap(s))
 fmt.Println(s2, len(s2), cap(s2))
 fmt.Println(s3, len(s3), cap(s3))
}

-------------
[1 2 3 4 5] 5 8
[1 2 3 4 5] 5 8
[1 2 3 4 5] 5 8
[255 2 3 4 5] 5 8
[255 2 3 4 5] 5 8
[255 2 3 4 5] 5 8
複製程式碼

使用過 Python 的同學可能會問,切片支援負數的位置麼,答案是不支援,下標不可以是負數。

陣列變切片

對陣列進行切割可以轉換成切片,切片將原陣列作為內部底層陣列。也就是說修改了原陣列會影響到新切片,對切片的修改也會影響到原陣列。

package main

import "fmt"

func main() {
	var a = [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
	var b = a[2:6]
	fmt.Println(b)
	a[4] = 100
	fmt.Println(b)
}

-------
[3 4 5 6]
[3 4 100 6]
複製程式碼

copy 函式

Go 語言還內建了一個 copy 函式,用來進行切片的深拷貝。不過其實也沒那麼深,只是深到底層的陣列而已。如果陣列裡面裝的是指標,比如 []*int 型別,那麼指標指向的內容還是共享的。

func copy(dst, src []T) int
複製程式碼

copy 函式不會因為原切片和目標切片的長度問題而額外分配底層陣列的記憶體,它只負責拷貝陣列的內容,從原切片拷貝到目標切片,拷貝的量是原切片和目標切片長度的較小值 —— min(len(src), len(dst)),函式返回的是拷貝的實際長度。我們來看一個例子

package main

import "fmt"

func main() {
 var s = make([]int, 5, 8)
 for i:=0;i<len(s);i++ {
  s[i] = i+1
 }
 fmt.Println(s)
 var d = make([]int, 2, 6)
 var n = copy(d, s)
 fmt.Println(n, d)
}
-----------
[1 2 3 4 5]
2 [1 2]

複製程式碼

切片的擴容點

當比較短的切片擴容時,系統會多分配 100% 的空間,也就是說分配的陣列容量是切片長度的2倍。但切片長度超過1024時,擴容策略調整為多分配 25% 的空間,這是為了避免空間的過多浪費。試試解釋下面的執行結果。

s1 := make([]int, 6)
s2 := make([]int, 1024)
s1 = append(s1, 1)
s2 = append(s2, 2)
fmt.Println(len(s1), cap(s1))
fmt.Println(len(s2), cap(s2))
-------------------------------------------
7 12
1025 1344
複製程式碼

上面的結果是在 goplayground 裡面執行的,如果在本地的環境執行,結果卻不一樣

$ go run main.go
7 12
1025 1280
複製程式碼

擴容是一個比較複雜的操作,內部的細節必須通過分析原始碼才能知曉,不去理解擴容的細節並不會影響到平時的使用,所以關於切片的原始碼我們後續在高階內容裡面再仔細分析。

《快學 Go 語言》第 5 課 —— 靈活的切片

掃一掃二維碼閱讀《快學 Go 語言》更多章節

相關文章