定義
Array 是 Golang 的原生型別,其長度固定。
Slice 是 Golang 中長度可以變動的資料型別,也就是可以隨意修改長度的 Array。
其中,Slice 會包含 Pointer
、Capacity
、Length
,Pointer 負責指向實際底層的 Array 位址;Capacity 則是目前 Slice 的容量;Length 則是目前 Slice 內有多少元素在裏頭的長度。
1 | type slice struct { |
宣告方式
1 | []T |
- 普遍使用
make([]type, len, cap)
,cap
通常會在宣告的時候被忽略。 - 直接定義明確資料,
[]string{"Alice", "Bob"}
。 - 宣告一個空 Slice,
var member []string
。
如果
Capacity
沒有被定義,那麼它會與Length
一樣,並且會先給定初始值。
可以使用len()
來查看 Slice 內的長度,cap()
則是查看 Slice 內的容量。
1 | // 法一 make([]type, len, cap) |
基本操作
長度調整
使用 slice[start:end:max]
的方式來去對 Slice 長度進行修改,範圍將會是 [start,end)
,end
則不會被納入範圍內,並且通常不會用到 max
,除非你想對 Capacity
進行調整。
一旦 Slice 被修改,底層的 Array 元素也會一同被修改。
1 | arr := [4]string{"a","b","c","d"} // len=4, cap=4, value=[a b f d] |
上述的 s2
、s3
變數可以看到為什麼明明都是取一樣的長度,可是容量卻不一樣呢?
因為 Slice 是以 start
開始到 原陣列長度
作為其容量大小的預留記憶體,也就是說如果今天有一個 [8]int
要取 array[4:6]
那麼到最後,len
會是 2
,則 cap
會是 4
。
Append
1 | func append(s []T, x ...T) []T |
對 Slice 進行元素的向後新增,一旦 容量小於 append 後整體的長度
,Slice 就會自行擴容,重新宣告一個原本 2 倍
容量大小,將原本的空間 copy
至新的 Slice。
1 | s := make([]int, 0, 1) // [] len=0 cap=1 |
Copy
1 | func copy(dst, src []T) int |
將原本 Slice 的值,深拷貝到另外一個 Slice,return 為已被複製的數量。
如果當
cloneScores
容量大於scores
複製過來的元素長度,剩下多的空間則是會以 0 來補。
如果當cloneScores
容量小於scores
複製過來的元素長度,最多就是當前容量
而不會擴容
,多的元素會被忽略。
1 | func main() { |
Range
用來遍歷 Slice 所使用的關鍵字,提供 key
和 value
,如果某一個值不需要使用到,可以使用 _
來代替。
1 | func main() { |
growslice 擴容
為了更好地去了解 Slice 的擴容原理,我寫了一個 for 迴圈在 Go Playground ,來看看到底是不是原始碼所寫的那樣,測試程式碼邏輯是每次只要 Slice 長度等於容量時,就會去打印當前的長度與容量。
1 | func main() { |
結果非常出乎意料,竟然在容量 512
要進行擴容時,擴充成 848
,於是我去翻找了原始碼中的 runtime.growslice 函數做了什麼:
1 | // oldPtr = array 的指標位址 |
發現在進行擴容時會有三大步驟:
- 初步確認擴容大小
- 記憶體對齊
- 重新分配新的記憶體,並將新舊 Slice 資料複製
初步確認擴容大小
1 | func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice { |
這邊我舉出兩個資料型別大小為 0 範例,空 Slice
和 struct 型別的 Slice
:
1 | type empty struct{} |
根據 1.22 版本的 runtime.nextslicecap:
1 | // newLen = 新的 Slice 長度 (= oldLen + num) |
而在 1.18 版本以前計算方式則不太一樣:
1 | newcap := old.cap |
1.18 版本以前
的邏輯:
新增元素後的長度 > 2 倍原始容量
,那麼就會使用該長度
。- 擴容在
少量元素
的情況下,會以2 倍大小
作為新的容量,但是為了避免後續容量增加而未使用
造成的空間浪費,所以只要是容量大小 >= 1024 的情況,就只會每次增加原本容量的 25%
。
1.18 版本以後
的邏輯,則是 修改閥值大小
把 1024
降低到 256
,並且每次新增除了 25% 的容量,還會新增 192
的空間,會這樣更動的原因可以查看當時的 Commit 紀錄,其實就是想要讓 擴容曲線更加圓滑
。
starting cap | growth factor |
---|---|
256 | 2.0 |
512 | 1.63 |
1024 | 1.44 |
2048 | 1.35 |
4096 | 1.30 |
那麼根據 1.22 版本的規則 for 迴圈的範例,從容量 512 擴容時,newcap
應該要是 512 * 1.25 + 192 = 832
,為什麼打印出來的容量是 848
呢?那是因為還要對 記憶體空間
進行 對齊
。
記憶體對齊
關於記憶體對齊,jserv 在 你所不知道的 C 語言
的講座有講到,大概意思就是每次 CPU 都會抓固定的大小 (32 位元就是 4 bytes、64 位元就是 8 bytes) 進行運算。
假定我現在是 64 位元的系統,我每次以 8 bytes 為單位的方式抓資料,如果我現在要抓的資料位址在第 1~8 byte,那麼就要抓兩次分別是:
- 取 0-7 byte,把第 0 byte 的資料清除,留下 1-7 byte
- 取 8-15 byte,把 9-15 byte 的資料清除,留下第 8 byte
- 再把抓出來的兩個記憶體位址進行拼接
同樣是取資料,但是由於
記憶體的資料不對齊
,所以操作更加複雜導致效率低落
所以我們在擴容的時候,當然也要根據 Slice 的型別佔用的空間
去申請 相對應大小的空間
。
1 | func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice { |
因為範例中是使用 int 型別
,在 64 位元下佔用 8 bytes
,且 goarch.PtrSize
也是 8 bytes
。如果將數字帶入的話:
1 | lenmem = uintptr(512) * 8 // = 4096 |
後面計算到一半使用 roundupsize()
,那麼我們再去看runtime.roundupsize做了什麼運算:
1 | // _MaxSmallSize = 32 << 10 = 32768 |
我們因為會使用到 6656 bytes
的空間,所以要被 divRoundUp()
運算,runtime.divRoundUp:
1 | // divRoundUp returns ceil(n / a). |
運算結果為 (5632 + 128 - 1) / 128 = 44
,就會變成 class_to_size[size_to_class128[44]]
,找出runtime.size_to_class8 和 runtime.size_to_class128:
size_to_class128
:代表要多少 128 bytes 來對齊記憶體class_to_size
:代表對應記憶體對其的字節大小
1 | var class_to_size = [0, 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536, 1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768] |
所以 size_to_class128[44] = 49
,接續 class_to_size[49] = 6784
。最後得知要申請的新記憶體空間大小就會是 6784 bytes
,而容量就會是 6784 / 8 = 848
。
1 | newcap = int(capmem / 8) // = int(6784/8) = 848 |
重新分配新的記憶體,並將新舊 Slice 資料複製
最後一步,也就是根據需要的空間大小去 申請記憶體空間
,根據把舊資料 移植
到新的記憶體位址,返回新的 Slice
。
1 | func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice { |
小試身手
請都以 1.18 以後的版本邏輯,回答以下會輸出什麼!
問題一
1 | func main() { |
答案一
1 | lenth: 1 |
問題二
1 | func main() { |
答案二
1 | len(pollorder) = 5 |
問題三
1 | func main() { |
答案三
1 | cap of s1:6 |
結論
- Slice 使用
- 建立 Slice 可以根據實際需求預先劃分好
需要的容量
以及初始值
。 - Slice 在進行
copy
時,要判斷是否目的 Slice 是否為需求所要。 - Slice 指向同一個 Array,請避免多次操作同一個 Slice。
- 使用
len()
、cap()
可以獲取 Slice 的長度和容量。 - Slice 在賦值時不會複製整個 Slice,而是獲取其
Array 位址
、長度
和容量
。 - 使用
append
可能會觸發擴容,若是想要避免,可以提前宣告足夠的容量。
- 建立 Slice 可以根據實際需求預先劃分好
- growslice
- 1.18 前後版本的擴容閥值不同,原本由
1024
降至256
。 - 如果
新增元素後長度 > 原本容量 2 倍
,就會使用該長度
。 - 如果要
原本容量 < 閥值
,就會以2 倍
進行擴容。 - 如果
原本容量 >= 閥值
,則是根據原本容量的 25%
進行擴容,新版本還會增加 192
。 - 計算好預期容量後,還要讓
記憶體對齊
,才能夠申請新的 Slice。
- 1.18 前後版本的擴容閥值不同,原本由
本篇文章的程式碼範例。
參考資料
- https://juejin.cn/post/6997212552241348616
- https://pjchender.dev/golang/slice-and-array/
- https://www.joxrays.com/go-slice/
- https://golang.design/go-questions/slice/grow/
- https://github.com/golang/go