序言
?在許多開發語言中,動態數組是必不可少的一個組成部分。在實際的開發中很少會使用到數組,因為對于數組的大小大多數情況下我們是不能事先就確定好的,所以他不夠靈活。動態數組通過提供自動擴容的機制,極大地提升了開發效率。這篇文章將介紹 Go 語言中的動態數組 — slice(切片)
。
1. 數據結構
?切片的組成如下, 每一個字段的含義如下:
Data
:指向存儲元素數組的指針;Len
:該數組中元素的個數Cap
:該數組的容量大小
type SliceHeader struct {Data uintptrLen intCap int
}
如果你之前了解過 C++ 中的 vector 你會發現其實他們的思路是一樣的。一個實際存儲元素的切片如下:
2. 切片的初始化
a. 聲明但不初始化
?在 Go 語言中,如果你聲明一個切片但不初始化它,它的默認值是 nil
。這意味著該切片沒有指向任何底層數組,長度和容量都為 0:
func main() {var slice []intsliceHeader := (*reflect.SliceHeader)(unsafe.Pointer(&slice))dataPtr := unsafe.Pointer(sliceHeader.Data)fmt.Printf("data = %v, len = %d, cap = %d\n", dataPtr, len(slice), cap(slice))
}
這里程序的輸出是:
data = , len = 0, cap = 0
b. 帶初始值初始化
?比起第一種方式,這個會在聲明的時候帶上字面值來初始化一個切片:
slice := []int{1, 2, 3}
fmt.Printf("data = %v, len = %d, cap = %d\n", slice, len(slice), cap(slice))
此時,該切片的 len
和 cap
會和元素數量保持一致,程序輸出:
data = [1 2 3], len = 3, cap = 3
c. 使用 make 初始化
?使用 make
來初始化一個切片也有兩者方式,首先是第一種:
slice := make([]int, 5)
fmt.Printf("data = %v, len = %d, cap = %d\n", slice, len(slice), cap(slice))
這代表創建一個切片,并且切片的 len
和 cap
都是 5,切片的元素的值采用該類型的默認值:
data = [0 0 0 0 0], len = 5, cap = 5
第二種是將 len
和 cap
分別賦值:
slice := make([]int, 2, 4)
fmt.Printf("data = %v, len = %d, cap = %d\n", slice, len(slice), cap(slice))
這代表創建一個切片,并且切片的len
是 2,cap
是 4:
data = [0 0], len = 2, cap = 4
這也是最常用的方式,使用 make
來預先分配內存大小可以避免后續添加元素時頻繁進行擴容操作!
d. 下標索引初始化
?Go 支持指定一個索引范圍來初始化一個切片,這是 C++ 的 vector 所不具備的能力,舉個例子:
arr := [5]int{1, 2, 3, 4, 5}
slice := arr[1:4]
fmt.Println(slice) // [2 3 4]
這里有一個長度為 5 的數組,現在使用索引范圍 [1, 4) 「左閉右開」
來初始化一個切片,甚至還可以這樣表達:
slice = arr[:4] // 等價于 [0:4]
slice = arr[1:] // 等價于 [1:len(arr) - 1]
現在有一個問題,使用索引初始化的切片和原數組是什么關系呢?換句話說這里是否涉及到了深拷貝呢?上代碼:
arr := [5]int{1, 2, 3, 4, 5}
slice := arr[1:4] // [2, 3, 4]
slice[0] = 0 // 修改值
fmt.Printf(“arr=%v\n”, arr)
fmt.Printf(“slice=%v\n”, slice)
輸出結果是:
arr=[1 0 3 4 5]
slice=[0 3 4]
上文中我們了解到了一個 slice
的結構是怎么樣的,結合輸出的結果,不難推斷出 data
指針指向了該數組的第二個位置,如下:
這里 cap
的大小為什么是 4 怎么得到的呢 — cap = cap(arr) - 1
。
3. 切片的追加和擴容
a. 元素追加
?我們可以通過 append
操作來在切片最后追加元素,追加方式也有多種,舉個栗子:
slice := []int{1, 2}
slice = append(slice, 3) // 追加一個元素
slice = append(slice, 4, 5, 6) // 追加多個元素
slice = append(slice, []int{7, 8}...) // 追加一個切片,...表示解包,不能省略
對于追加的操作,大家是否存在疑惑的點呢?我剛開始就不理解為什么在追加操作后對 slice
進行賦值的操作。這是因為 append
函數有一個重要的特性需要特別注意:它可能會返回一個新的底層數組(取決于是否進行擴容操作)。如果沒有進行賦值操作,那么 slice
還是指向原來的數組,舉個栗子:
b. 切片擴容
?當切片的 len
等于 cap
時,在下一次 append
操作前就會進行一次擴容操作,擴容的邏輯如下:
func growslice(et *_type, old slice, cap int) slice {...newcap := old.capdoublecap := newcap + newcapif cap > doublecap {newcap = cap} else {if old.len < 1024 {newcap = doublecap} else {for 0 < newcap && newcap < cap {newcap += newcap / 4}if newcap <= 0 {newcap = cap}}}...
}
擴容的策略總結如下:
可以看到 Go 語言增長容量的策略還是比較緩和的。
4. 切片易踩的坑
a. 參數傳遞類型傻傻分不清
?首先,我們先聊聊 C++
當中的值傳遞和引用傳遞,就比如:
int main() {vector<int> vec = { 1, 2, 3, 4, 5 }funcJustForRead(vec)return 0;
}void funcJustForRead(vector<int> &vec) {...
}
對于某些只讀的場景,我們一般會傳引用,這樣就大大減少了拷貝帶來的開銷。在 Go 語言中好像并沒有 引用
的概念?但是仔細思考一下,Go 真的需要嗎:
func main() {slice := []int{ 1, 2, 3, 4, 5 }funcJustForRead(slice)
}func funcJustForRead(slice []int) {...
}
形參是實參的拷貝,slice
中指向元素的是 data
指針,即使形參和實參的 data
不一樣,但是兩者是指向的同一個數組,所以不需要引用。
?現在,這里有一個函數會對切片進行追加操作,我依然是值傳遞是否還是可行呢?舉個栗子(假設這里不涉及擴容操作):
func main() {slice := []int{1, 2}fmt.Println(slice)funcForAppend(slice)fmt.Println(slice)
}func funcForAppend(slice []int) {slice = append(slice, 3)
}
輸出結果是:
[1 2]
[1 2]
并沒有預想的新增一個值,為什么?上面我們介紹了,append
會返回一個新的切片,我們在 main
中使用的還是原來的切片。怎么解決呢?傳遞指針:
func main() {slice := make([]int, 0, 2)fmt.Println(slice)funcForAppend(&slice)fmt.Println(slice)
}func funcForAppend(slice *[]int) {*slice = append(*slice, 3)
}
b. len 和 cap 傻傻分不清
?之前我們談到過,可以預先分配好空間,可以避免后續的頻繁擴容操作,但是是否會有以下的誤解呢:
func main() {slice := make([]int, 5)slice = append(slice, 1)slice = append(slice, 1)slice = append(slice, 1)slice = append(slice, 1)fmt.Println(slice) // [0 0 0 0 0 1 1 1 1]
}
這里代表預先分配好 5 個空間,并且每一個空間使用該類型的默認值填充,當我們新加入元素時,是在已有的基礎上往后添加而不是從前開始覆蓋。正確的姿勢應該是這樣子的:
func main() {slice := make([]int, 0, 5)slice = append(slice, 1)slice = append(slice, 1)slice = append(slice, 1)slice = append(slice, 1)fmt.Println(slice) // [1 1 1 1]
}
5. 總結
?不僅只是會使用,并且知其所以然。我自認為這是非常重要的,這不僅能夠很大程度上減小我們在開發中犯錯的概念,還能夠有效提升代碼的質量。所以通過這篇 silce
帶我們走入 Go 的世界吧。