「快學 Go 語言」第 5 課——靈活的切片


「快學 Go 語言」第 5 課——靈活的切片


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

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


「快學 Go 語言」第 5 課——靈活的切片


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

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

切片的創建

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

<code>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]
複製代碼/<code>

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

<code>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]
複製代碼/<code>

切片的初始化

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

<code>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
複製代碼/<code>

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

空切片

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

<code>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
複製代碼/<code>

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

切片的賦值

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

<code>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]
複製代碼/<code>

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

切片的遍歷

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

<code>package main


import "fmt"


func main() {
\tvar s = []int{1,2,3,4,5}
\tfor index := range s {
\t\tfmt.Println(index, s[index])
\t}
\tfor index, value := range s {
\t\tfmt.Println(index, value)
\t}
}

--------
0 1
1 2
2 3
3 4
4 5
0 1
1 2
2 3
3 4
4 5
複製代碼/<code>

切片的追加

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

<code>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
複製代碼/<code>

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

<code>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
複製代碼/<code>

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

<code>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]
複製代碼/<code>

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

切片的域是隻讀的

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

切割切割

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

<code>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
複製代碼/<code>

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


「快學 Go 語言」第 5 課——靈活的切片


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

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

<code>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
複製代碼/<code>

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

<code>package main

import "fmt"

func main() {
var s = make([]int, 5, 8)
for i:=0;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
複製代碼
/<code>

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

數組變切片

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

<code>package main

import "fmt"

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

-------
[3 4 5 6]
[3 4 100 6]
複製代碼/<code>

copy 函數

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

<code>func copy(dst, src []T) int
複製代碼/<code>

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

<code>package main

import "fmt"

func main() {
var s = make([]int, 5, 8)
for i:=0;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]

複製代碼
/<code>

切片的擴容點

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

<code>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
複製代碼/<code>

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

<code>$ go run main.go
7 12
1025 1280
複製代碼/<code>

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


分享到:


相關文章: