切片(Slice)無疑是 Go 語言中最重要的數(shù)據(jù)結構咆耿,也是最有趣的數(shù)據(jù)結構德谅,它的英文詞匯叫 slice。所有的 Go 語言開發(fā)者都津津樂道地談論切片的內(nèi)部機制萨螺,它也是 Go 語言技能面試中面試官最愛問的知識點之一窄做。初級用戶很容易濫用它,這小小的切片想要徹底的理解它是需要花費一番功夫的慰技。在使用切片之前椭盏,我覺得很有必要將切片的內(nèi)部結構做一下說明。
學過 Java 語言的人會比較容易理解切片吻商,因為它的內(nèi)部結構非常類似于 ArrayList掏颊,ArrayList 的內(nèi)部實現(xiàn)也是一個數(shù)組。
當數(shù)組容量不夠需要擴容時艾帐,就會換新的數(shù)組乌叶,還需要將老數(shù)組的內(nèi)容拷貝到新數(shù)組。
ArrayList 內(nèi)部有兩個非常重要的屬性 capacity 和 length柒爸。capacity 表示內(nèi)部數(shù)組的總長度准浴,length 表示當前已經(jīng)使用的數(shù)組的長度。length 永遠不能超過 capacity捎稚。
上圖中一個切片變量包含三個域乐横,分別是底層數(shù)組的指針、切片的長度 length 和切片的容量 capacity阳藻。切片支持 append 操作可以將新的內(nèi)容追加到底層數(shù)組晰奖,也就是填充上面的灰色格子。如果格子滿了腥泥,切片就需要擴容,底層的數(shù)組就會更換啃匿。
形象一點說蛔外,切片變量是底層數(shù)組的視圖蛆楞,底層數(shù)組是臥室,切片變量是臥室的窗戶夹厌。通過窗戶我們可以看見底層數(shù)組的一部分或全部豹爹。一個臥室可以有多個窗戶,不同的窗戶能看到臥室的不同部分矛纹。
切片的創(chuàng)建
切片的創(chuàng)建有多種方式臂聋,我們先看切片最通用的創(chuàng)建方法,那就是內(nèi)置的 make
函數(shù)
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 函數(shù)創(chuàng)建切片或南,需要提供三個參數(shù)孩等,分別是切片的類型、切片的長度和容量采够。其中第三個參數(shù)是可選的肄方,如果不提供第三個參數(shù),那么長度和容量相等蹬癌,也就是說切片的滿容的权她。切片和普通變量一樣,也可以使用類型自動推導逝薪,省去類型定義以及 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 函數(shù)創(chuàng)建的切片內(nèi)容是「零值切片」董济,也就是內(nèi)部數(shù)組的元素都是零值拾徙。Go 語言還提供了另一個種創(chuàng)建切片的語法,允許我們給它賦初值感局。使用這種方式創(chuàng)建的切片是滿容的尼啡。
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 語言提供了內(nèi)置函數(shù) len() 和 cap() 可以直接獲得切片的長度和容量屬性。
空切片
在創(chuàng)建切片時询微,還有兩個非常特殊的情況需要考慮崖瞭,那就是容量和長度都是零的切片,叫著「空切片」撑毛,這個不同于前面說的「零值切片」书聚。
package main
import "fmt"
func main() {
var s1 []int
var s2 []int = []int{}
var s2 []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
上面三種形式創(chuàng)建的切片都是「空切片」,不過在內(nèi)部結構上這三種形式是有差異的藻雌,甚至第一種都不叫「空切片」雌续,而是叫著「 nil 切片」。但是在形式上它們幾乎一摸一樣胯杭,用起來差不多沒有區(qū)別驯杜。所以初級用戶可以不必區(qū)分「空切片」和「 nil 切片」,到后續(xù)章節(jié)我們會仔細分析這兩種形式的區(qū)別做个。
切片的賦值
切片的賦值是一次淺拷貝操作鸽心,拷貝的是切片變量的三個域滚局,你可以將切片變量看成長度為 3 的 int 型數(shù)組,數(shù)組的賦值就是淺拷貝顽频√僦拷貝前后兩個變量共享底層數(shù)組,對一個切片的修改會影響另一個切片的內(nèi)容糯景,這點需要特別注意嘁圈。
package main
import "fmt"
func main() {
var s1 = make([]int, 5, 8)
// 切片的訪問和數(shù)組差不多
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))
// 嘗試修改切片內(nèi)容
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]
從上面的輸出中可以看到賦值的兩切片共享了底層數(shù)組。
切片的遍歷
切片在遍歷的語法上和數(shù)組是一樣的蟀淮,除了支持下標遍歷外最住,那就是使用 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
切片的追加
文章開頭提到切片是動態(tài)的數(shù)組,其長度是可以變化的灭贷。什么操作可以改變切片的長度呢温学,這個操作就是追加操作。切片每一次追加后都會形成新的切片變量甚疟,如果底層數(shù)組沒有擴容仗岖,那么追加前后的兩個切片變量共享底層數(shù)組,如果底層數(shù)組擴容了览妖,那么追加前后的底層數(shù)組是分離的不共享的轧拄。如果底層數(shù)組是共享的,一個切片的內(nèi)容變化就會影響到另一個切片讽膏,這點需要特別注意檩电。
package main
import "fmt"
func main() {
var s1 = []int{1,2,3,4,5}
fmt.Println(s1, len(s1), cap(s1))
// 對滿容的切片進行追加會分離底層數(shù)組
var s2 = append(s1, 6)
fmt.Println(s1, len(s1), cap(s1))
fmt.Println(s2, len(s2), cap(s2))
// 對非滿容的切片進行追加會共享底層數(shù)組
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 語言特殊的內(nèi)置變量卓箫,它就像一個黑洞,可以將任意變量賦值給它垄潮,但是卻不能讀取這個特殊變量滥崩。
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]
還需要注意的是追加雖然會導致底層數(shù)組發(fā)生擴容怕磨,更換的新的數(shù)組倍宾,但是舊數(shù)組并不會立即被銷毀被回收赁严,因為老切片還指向這舊數(shù)組。
切片的域是只讀的
我們剛才說切片的長度是可以變化的牡整,為什么又說切片是只讀的呢藐吮?這不是矛盾么。這是為了提醒讀者注意切片追加后形成了一個新的切片變量,而老的切片變量的三個域其實并不會改變炎码,改變的只是底層的數(shù)組盟迟。這里說的是切片的「域」是只讀的秋泳,而不是說切片是只讀的潦闲。切片的「域」就是組成切片變量的三個部分,分別是底層數(shù)組的指針迫皱、切片的長度和切片的容量歉闰。這里讀者需要仔細咀嚼。
切割切割
到目前位置還沒有說明切片名字的由來卓起,既然叫著切片和敬,那總得可以切割吧。切割切割戏阅,有些男娃子聽到這個詞匯時身上會起雞皮疙瘩昼弟。切片的切割可以類比字符串的子串,它并不是要把切片割斷奕筐,而是從母切片中拷貝出一個子切片來舱痘,子切片和母切片共享底層數(shù)組。下面我們來看一下切片究竟是如何切割的离赫。
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
上面的輸出需要特別注意的是,既然切割前后共享底層數(shù)組渊胸,那為什么容量不一樣呢旬盯?解釋它我必須要畫圖了,讀者請務必仔細觀察下面這張圖
我們注意到子切片的內(nèi)部數(shù)據(jù)指針指向了數(shù)組的中間位置翎猛,而不再是數(shù)組的開頭了胖翰。子切片容量的大小是從中間的位置開始直到切片末尾的長度,母子切片依舊共享底層數(shù)組切厘。
子切片語法上要提供起始和結束位置萨咳,這兩個位置都可選的,不提供起始位置迂卢,默認就是從母切片的初始位置開始(不是底層數(shù)組的初始位置)某弦,不提供結束位置,默認就結束到母切片尾部(是長度線而克,不是容量線)靶壮。下面我們看個例子:
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[:] 很特別,它和普通的切片賦值有區(qū)別么员萍?答案是沒區(qū)別腾降,這非常讓人感到意外,同樣的共享底層數(shù)組碎绎,同樣是淺拷貝螃壤。下面我們來驗證一下
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 的同學可能會問抗果,切片支持負數(shù)的位置么,答案是不支持奸晴,下標不可以是負數(shù)冤馏。
數(shù)組變切片
對數(shù)組進行切割可以轉換成切片,切片將原數(shù)組作為內(nèi)部底層數(shù)組寄啼。也就是說修改了原數(shù)組會影響到新切片逮光,對切片的修改也會影響到原數(shù)組。
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 函數(shù)
Go 語言還內(nèi)置了一個 copy 函數(shù)墩划,用來進行切片的深拷貝涕刚。不過其實也沒那么深,只是深到底層的數(shù)組而已乙帮。如果數(shù)組里面裝的是指針杜漠,比如 []*int 類型,那么指針指向的內(nèi)容還是共享的察净。
func copy(dst, src []T) int
copy 函數(shù)不會因為原切片和目標切片的長度問題而額外分配底層數(shù)組的內(nèi)存驾茴,它只負責拷貝數(shù)組的內(nèi)容,從原切片拷貝到目標切片塞绿,拷貝的量是原切片和目標切片長度的較小值 —— min(len(src), len(dst))沟涨,函數(shù)返回的是拷貝的實際長度。我們來看一個例子
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]
切片的擴容點
當比較短的切片擴容時异吻,系統(tǒng)會多分配 100% 的空間裹赴,也就是說分配的數(shù)組容量是切片長度的2倍。但切片長度超過1024時诀浪,擴容策略調(diào)整為多分配 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