Go 語言的 slice
很好用,不過也有一些坑。slice
是 Go 語言一個很重要的資料結構。網上已經有很多文章寫過了,似乎沒必要再寫。但是每個人看問題的視角不同,寫出來的東西自然也不一樣。我這篇會從更底層的組合語言去解讀它。而且在我寫這篇文章的過程中,發現絕大部分文章都存在一些問題,文章裡會講到,這裡先不展開。
我希望本文可以終結這個話題,下次再有人想和你討論 slice
,直接把這篇文章的連結丟過去就行了。
當我們在說 slice 時,到底在說什麼
slice
翻譯成中文就是切片
,它和陣列(array)
很類似,可以用下標的方式進行訪問,如果越界,就會產生 panic。但是它比陣列更靈活,可以自動地進行擴容。
瞭解 slice 的本質,最簡單的方法就是看它的原始碼:
// runtime/slice.go
type slice struct {
array unsafe.Pointer // 元素指標
len int // 長度
cap int // 容量
}
複製程式碼
看到了嗎,slice
共有三個屬性:
指標
,指向底層陣列;
長度
,表示切片可用元素的個數,也就是說使用下標對 slice 的元素進行訪問時,下標不能超過 slice 的長度;
容量
,底層陣列的元素個數,容量 >= 長度。在底層陣列不進行擴容的情況下,容量也是 slice 可以擴張的最大限度。
注意,底層陣列是可以被多個 slice 同時指向的,因此對一個 slice 的元素進行操作是有可能影響到其他 slice 的。
slice 的建立
建立 slice 的方式有以下幾種:
序號 | 方式 | 程式碼示例 |
---|---|---|
1 | 直接宣告 | var slice []int |
2 | new | slice := *new([]int) |
3 | 字面量 | slice := []int{1,2,3,4,5} |
4 | make | slice := make([]int, 5, 10) |
5 | 從切片或陣列“擷取” | slice := array[1:5] 或 slice := sourceSlice[1:5] |
直接宣告
第一種建立出來的 slice 其實是一個 nil slice
。它的長度和容量都為0。和nil
比較的結果為true
。
這裡比較混淆的是empty slice
,它的長度和容量也都為0,但是所有的空切片的資料指標都指向同一個地址 0xc42003bda0
。空切片和 nil
比較的結果為false
。
它們的內部結構如下圖:
建立方式 | nil切片 | 空切片 |
---|---|---|
方式一 | var s1 []int | var s2 = []int{} |
方式二 | var s4 = *new([]int) | var s3 = make([]int, 0) |
長度 | 0 | 0 |
容量 | 0 | 0 |
和 nil 比較 |
true |
false |
nil
切片和空切片很相似,長度和容量都是0,官方建議儘量使用 nil
切片。
關於nil slice
和empty slice
的探索可以參考公眾號“碼洞”作者老錢寫的一篇文章《深度解析 Go 語言中「切片」的三種特殊狀態》,地址附在了參考資料部分。
字面量
比較簡單,直接用初始化表示式
建立。
package main
import "fmt"
func main() {
s1 := []int{0, 1, 2, 3, 8: 100}
fmt.Println(s1, len(s1), cap(s1))
}
複製程式碼
執行結果:
[0 1 2 3 0 0 0 0 100] 9 9
複製程式碼
唯一值得注意的是上面的程式碼例子中使用了索引號,直接賦值,這樣,其他未註明的元素則預設 0 值
。
make
make
函式需要傳入三個引數:切片型別,長度,容量。當然,容量可以不傳,預設和長度相等。
上篇文章《走進Go的底層》中,我們學到了彙編這個工具,這次我們再次請出彙編來更深入地看看slice
。如果沒看過上篇文章,建議先回去看完,再繼續閱讀本文效果更佳。
先來一小段玩具程式碼,使用 make
關鍵字建立 slice
:
package main
import "fmt"
func main() {
slice := make([]int, 5, 10) // 長度為5,容量為10
slice[2] = 2 // 索引為2的元素賦值為2
fmt.Println(slice)
}
複製程式碼
執行如下命令,得到 Go 彙編程式碼:
go tool compile -S main.go
複製程式碼
我們只關注main函式:
0x0000 00000 (main.go:5)TEXT "".main(SB), $96-0
0x0000 00000 (main.go:5)MOVQ (TLS), CX
0x0009 00009 (main.go:5)CMPQ SP, 16(CX)
0x000d 00013 (main.go:5)JLS 228
0x0013 00019 (main.go:5)SUBQ $96, SP
0x0017 00023 (main.go:5)MOVQ BP, 88(SP)
0x001c 00028 (main.go:5)LEAQ 88(SP), BP
0x0021 00033 (main.go:5)FUNCDATA $0, gclocals·69c1753bd5f81501d95132d08af04464(SB)
0x0021 00033 (main.go:5)FUNCDATA $1, gclocals·57cc5e9a024203768cbab1c731570886(SB)
0x0021 00033 (main.go:5)LEAQ type.int(SB), AX
0x0028 00040 (main.go:6)MOVQ AX, (SP)
0x002c 00044 (main.go:6)MOVQ $5, 8(SP)
0x0035 00053 (main.go:6)MOVQ $10, 16(SP)
0x003e 00062 (main.go:6)PCDATA $0, $0
0x003e 00062 (main.go:6)CALL runtime.makeslice(SB)
0x0043 00067 (main.go:6)MOVQ 24(SP), AX
0x0048 00072 (main.go:6)MOVQ 32(SP), CX
0x004d 00077 (main.go:6)MOVQ 40(SP), DX
0x0052 00082 (main.go:7)CMPQ CX, $2
0x0056 00086 (main.go:7)JLS 221
0x005c 00092 (main.go:7)MOVQ $2, 16(AX)
0x0064 00100 (main.go:8)MOVQ AX, ""..autotmp_2+64(SP)
0x0069 00105 (main.go:8)MOVQ CX, ""..autotmp_2+72(SP)
0x006e 00110 (main.go:8)MOVQ DX, ""..autotmp_2+80(SP)
0x0073 00115 (main.go:8)MOVQ $0, ""..autotmp_1+48(SP)
0x007c 00124 (main.go:8)MOVQ $0, ""..autotmp_1+56(SP)
0x0085 00133 (main.go:8)LEAQ type.[]int(SB), AX
0x008c 00140 (main.go:8)MOVQ AX, (SP)
0x0090 00144 (main.go:8)LEAQ ""..autotmp_2+64(SP), AX
0x0095 00149 (main.go:8)MOVQ AX, 8(SP)
0x009a 00154 (main.go:8)PCDATA $0, $1
0x009a 00154 (main.go:8)CALL runtime.convT2Eslice(SB)
0x009f 00159 (main.go:8)MOVQ 16(SP), AX
0x00a4 00164 (main.go:8)MOVQ 24(SP), CX
0x00a9 00169 (main.go:8)MOVQ AX, ""..autotmp_1+48(SP)
0x00ae 00174 (main.go:8)MOVQ CX, ""..autotmp_1+56(SP)
0x00b3 00179 (main.go:8)LEAQ ""..autotmp_1+48(SP), AX
0x00b8 00184 (main.go:8)MOVQ AX, (SP)
0x00bc 00188 (main.go:8)MOVQ $1, 8(SP)
0x00c5 00197 (main.go:8)MOVQ $1, 16(SP)
0x00ce 00206 (main.go:8)PCDATA $0, $1
0x00ce 00206 (main.go:8)CALL fmt.Println(SB)
0x00d3 00211 (main.go:9)MOVQ 88(SP), BP
0x00d8 00216 (main.go:9)ADDQ $96, SP
0x00dc 00220 (main.go:9)RET
0x00dd 00221 (main.go:7)PCDATA $0, $0
0x00dd 00221 (main.go:7)CALL runtime.panicindex(SB)
0x00e2 00226 (main.go:7)UNDEF
0x00e4 00228 (main.go:7)NOP
0x00e4 00228 (main.go:5)PCDATA $0, $-1
0x00e4 00228 (main.go:5)CALL runtime.morestack_noctxt(SB)
0x00e9 00233 (main.go:5)JMP 0
複製程式碼
先說明一下,Go 語言彙編 FUNCDATA
和 PCDATA
是編譯器產生的,用於儲存一些和垃圾收集相關的資訊,我們先不用 care。
以上彙編程式碼行數比較多,沒關係,因為命令都比較簡單,而且我們的 Go 原始碼也足夠簡單,沒有理由看不明白。
我們先從上到下掃一眼,看到幾個關鍵函式:
CALL runtime.makeslice(SB)
CALL runtime.convT2Eslice(SB)
CALL fmt.Println(SB)
CALL runtime.morestack_noctxt(SB)
複製程式碼
序號 | 功能 |
---|---|
1 | 建立slice |
2 | 型別轉換 |
3 | 列印函式 |
4 | 棧空間擴容 |
1
是建立 slice 相關的;2
是型別轉換;呼叫 fmt.Println
需要將 slice 作一個轉換; 3
是列印語句;4
是棧空間擴容函式,在函式開始處,會檢查當前棧空間是否足夠,不夠的話需要呼叫它來進行擴容。暫時可以忽略。
呼叫了函式就會涉及到引數傳遞,Go 的引數傳遞都是通過 棧空間完成的。接下來,我們詳細分析這整個過程。
行數 | 作用 |
---|---|
1 | main 函式定義,棧幀大小為 96B |
2-4 | 判斷棧是否需要進行擴容,如果需要則跳到 228 ,這裡會呼叫 runtime.morestack_noctxt(SB) 進行棧擴容操作。具體細節後續還會有文章來講 |
5-9 | 將 caller BP 壓棧,具體細節後面會講到 |
10-15 | 呼叫 runtime.makeslice(SB) 函式及準備工作。*_type表示的是 int ,也就是 slice 元素的型別。這裡對應的原始碼是第6行,也就是呼叫 make 建立 slice 的那一行。5 和 10 分別代表長度和容量,函式引數會在棧頂準備好,之後執行函式呼叫命令 CALL ,進入到被呼叫函式的棧幀,就會按順序從 caller 的棧頂取函式引數 |
16-18 | 接收 makeslice 的返回值,通過 move 移動到暫存器中 |
19-21 | 給陣列索引值為 2 的元素賦上值 2 ,因為是 int 型的 slice ,元素大小為8位元組,所以 MOVQ $2, 16(AX) 此命令就是將 2 搬到索引為 2 的位置。這裡還會對索引值的大小進行檢查,如果越界,則會跳轉到 221 ,執行 panic 函式 |
22-26 | 分別通過暫存器 AX,CX,DX 將 makeslice 的返回值 move 到記憶體的其他位置,也稱為區域性變數,這樣就構造出了 slice |
左邊是棧上的資料,右邊是堆上的資料。array
指向 slice
的底層資料,被分配到堆上了。注意,棧上的地址是從高向低增長;堆則從低向高增長。棧左邊的數字表示對應的彙編程式碼的行數,棧右邊箭頭則表示棧地址。(48)SP、(56)SP 表示的內容接著往下看。
注意,在圖中,棧地址是從下往上增長,所以 SP 表示的是圖中 *_type
所在的位置,其它的依此類推。
行數 | 作用 |
---|---|
27-32 | 準備呼叫 runtime.convT2Eslice(SB) 的函式引數 |
33-36 | 接收返回值,通過AX,CX暫存器 move 到(48)SP、(56)SP |
convT2Eslice
的函式宣告如下:
func convT2Eslice(t *_type, elem unsafe.Pointer) (e eface)
複製程式碼
第一個引數是指標 *_type
,_type
是一個表示型別的結構體,這裡傳入的就是 slice
的型別 []int
;第二個引數則是元素的指標,這裡傳入的就是 slice
底層陣列的首地址。
返回值 eface
的結構體定義如下:
type eface struct {
_type *_type
data unsafe.Pointer
}
複製程式碼
由於我們會呼叫 fmt.Println(slice)
,看下函式原型:
func Println(a ...interface{}) (n int, err error)
複製程式碼
Println
接收 interface 型別,因此我們需要將 slice
轉換成 interface 型別。由於 slice
沒有方法,是個“空 interface
”。因此會呼叫 convT2Eslice
完成這一轉換過程。
convT2Eslice
函式返回的是型別指標和資料地址。原始碼就不貼了,大體流程是:呼叫 mallocgc
分配一塊記憶體,把資料 copy
進到新的記憶體,然後返回這塊記憶體的地址,*_type
則直接返回傳入的引數。
32(SP)
和 40(SP)
其實是 makeslice
函式的返回值,這裡可以忽略。
還剩 fmt.Println(slice)
最後一個函式呼叫了,我們繼續。
行數 | 作用 |
---|---|
37-40 | 準備 Println 函式引數。共3個引數,第一個是型別地址,還有兩個 1 ,這塊暫時還不知道為什麼要傳,有了解的同學可以在文章後面留言 |
所以呼叫 fmt.Println(slice)
時,實際是傳入了一個 slice型別的eface地址
。這樣,Println
就可以訪問型別中的資料,最終給“列印”出來。
最後,我們看下 main
函式棧幀的開始和收尾部分。
0x0013 00019 (main.go:5)SUBQ $96, SP
0x0017 00023 (main.go:5)MOVQ BP, 88(SP)
0x001c 00028 (main.go:5)LEAQ 88(SP), BP
…………………………
0x00d3 00211 (main.go:9)MOVQ 88(SP), BP
0x00d8 00216 (main.go:9)ADDQ $96, SP
RET
複製程式碼
BP
可以理解為儲存了當前函式棧幀棧底的地址,SP
則儲存棧頂的地址。
初始,BP
和 SP
分別有一個初始狀態。
main
函式執行的時候,先根據 main
函式棧幀大小確定 SP
的新指向,使得 main
函式棧幀大小達到 96B
。之後把老的 BP
儲存到 main
函式棧幀的底部,並使 BP
暫存器重新指向新的棧底,也就是 main
函式棧幀的棧底。
最後,當 main
函式執行完畢,把它棧底的 BP
給回彈回到 BP
暫存器,恢復呼叫前的初始狀態。一切都像是沒有發生一樣,完美的現場。
這部分,又詳細地分析了一遍函式呼叫的過程。一方面,讓大家複習一下上一篇文章講的內容;另一方面,向大家展示如何找到 Go 中的一個函式背後真實呼叫了哪些函式。像例子中,我們就看到了 make
函式背後,實際上是呼叫了 makeslice
函式;還有一點,讓大家對彙編不那麼“懼怕”,可以輕鬆地分析一些東西。
擷取
擷取也是比較常見的一種建立 slice 的方法,可以從陣列或者 slice 直接擷取,當然需要指定起止索引位置。
基於已有 slice 建立新 slice 物件,被稱為 reslice
。新 slice 和老 slice 共用底層陣列,新老 slice 對底層陣列的更改都會影響到彼此。基於陣列建立的新 slice 物件也是同樣的效果:對陣列或 slice 元素作的更改都會影響到彼此。
值得注意的是,新老 slice 或者新 slice 老陣列互相影響的前提是兩者共用底層陣列,如果因為執行 append
操作使得新 slice 底層陣列擴容,移動到了新的位置,兩者就不會相互影響了。所以,問題的關鍵在於兩者是否會共用底層陣列
。
擷取操作採用如下方式:
data := [...]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
slice := data[2:4:6] // data[low, high, max]
複製程式碼
對 data
使用3個索引值,擷取出新的 slice
。這裡 data
可以是陣列或者 slice
。low
是最低索引值,這裡是閉區間,也就是說第一個元素是 data
位於 low
索引處的元素;而 high
和 max
則是開區間,表示最後一個元素只能是索引 high-1
處的元素,而最大容量則只能是索引 max-1
處的元素。
max >= high >= low
複製程式碼
當 high == low
時,新 slice
為空。
還有一點,high
和 max
必須在老陣列或者老 slice
的容量(cap
)範圍內。
來看一個例子,來自雨痕大佬《Go學習筆記》第四版,P43頁,參考資料裡有開源書籍地址。這裡我會進行擴充套件,並會作詳細說明:
package main
import "fmt"
func main() {
slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
s1 := slice[2:5]
s2 := s1[2:6:7]
s2 = append(s2, 100)
s2 = append(s2, 200)
s1[2] = 20
fmt.Println(s1)
fmt.Println(s2)
fmt.Println(slice)
}
複製程式碼
先看下程式碼執行的結果:
[2 3 20]
[4 5 6 7 100 200]
[0 1 2 3 20 5 6 7 100 9]
複製程式碼
我們來走一遍程式碼,初始狀態如下:
slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
s1 := slice[2:5]
s2 := s1[2:6:7]
複製程式碼
s1
從 slice
索引2(閉區間)到索引5(開區間,元素真正取到索引4),長度為3,容量預設到陣列結尾,為8。
s2
從 s1
的索引2(閉區間)到索引6(開區間,元素真正取到索引5),容量到索引7(開區間,真正到索引6),為5。
接著,向 s2
尾部追加一個元素 100:
s2 = append(s2, 100)
複製程式碼
s2
容量剛好夠,直接追加。不過,這會修改原始陣列對應位置的元素。這一改動,陣列和 s1
都可以看得到。
再次向 s2
追加元素200:
s2 = append(s2, 100)
複製程式碼
這時,s2
的容量不夠用,該擴容了。於是,s2
另起爐灶,將原來的元素複製新的位置,擴大自己的容量。並且為了應對未來可能的 append
帶來的再一次擴容,s2
會在此次擴容的時候多留一些 buffer
,將新的容量將擴大為原始容量的2倍,也就是10了。
最後,修改 s1
索引為2位置的元素:
s1[2] = 20
複製程式碼
這次只會影響原始陣列相應位置的元素。它影響不到 s2
了,人家已經遠走高飛了。
再提一點,列印 s1
的時候,只會列印出 s1
長度以內的元素。所以,只會列印出3個元素,雖然它的底層陣列不止3個元素。
至於,我們想在彙編層面看看到底它們是如何共享底層陣列的,限於篇幅,這裡不再展開。感興趣的同學可以在公眾號後臺回覆:切片擷取
。
我會給你詳細分析函式呼叫關係,對共享底層陣列的行為也會一目瞭然。二維碼見文章底部。
slice 和陣列的區別在哪
slice 的底層資料是陣列,slice 是對陣列的封裝,它描述一個陣列的片段。兩者都可以通過下標來訪問單個元素。
陣列是定長的,長度定義好之後,不能再更改。在 Go 中,陣列是不常見的,因為其長度是型別的一部分,限制了它的表達能力,比如 [3]int
和 [4]int
就是不同的型別。
而切片則非常靈活,它可以動態地擴容。切片的型別和長度無關。
append 到底做了什麼
先來看看 append
函式的原型:
func append(slice []Type, elems ...Type) []Type
複製程式碼
append 函式的引數長度可變,因此可以追加多個值到 slice 中,還可以用 ...
傳入 slice,直接追加一個切片。
slice = append(slice, elem1, elem2)
slice = append(slice, anotherSlice...)
複製程式碼
append
函式返回值是一個新的slice,Go編譯器不允許呼叫了 append 函式後不使用返回值。
append(slice, elem1, elem2)
append(slice, anotherSlice...)
複製程式碼
所以上面的用法是錯的,不能編譯通過。
使用 append 可以向 slice 追加元素,實際上是往底層陣列新增元素。但是底層陣列的長度是固定的,如果索引 len-1
所指向的元素已經是底層陣列的最後一個元素,就沒法再新增了。
這時,slice 會遷移到新的記憶體位置,新底層陣列的長度也會增加,這樣就可以放置新增的元素。同時,為了應對未來可能再次發生的 append 操作,新的底層陣列的長度,也就是新 slice
的容量是留了一定的 buffer
的。否則,每次新增元素的時候,都會發生遷移,成本太高。
新 slice 預留的 buffer
大小是有一定規律的。網上大多數的文章都是這樣描述的:
當原 slice 容量小於
1024
的時候,新 slice 容量變成原來的2
倍;原 slice 容量超過1024
,新 slice 容量變成原來的1.25
倍。
我在這裡先說結論:以上描述是錯誤的。
為了說明上面的規律是錯誤的,我寫了一小段玩具程式碼:
package main
import "fmt"
func main() {
s := make([]int, 0)
oldCap := cap(s)
for i := 0; i < 2048; i++ {
s = append(s, i)
newCap := cap(s)
if newCap != oldCap {
fmt.Printf("[%d -> %4d] cap = %-4d | after append %-4d cap = %-4d\n", 0, i-1, oldCap, i, newCap)
oldCap = newCap
}
}
}
複製程式碼
我先建立了一個空的 slice
,然後,在一個迴圈裡不斷往裡面 append
新的元素。然後記錄容量的變化,並且每當容量發生變化的時候,記錄下老的容量,以及新增完元素之後的容量,同時記下此時 slice
裡的元素。這樣,我就可以觀察,新老 slice
的容量變化情況,從而找出規律。
執行結果:
[0 -> -1] cap = 0 | after append 0 cap = 1
[0 -> 0] cap = 1 | after append 1 cap = 2
[0 -> 1] cap = 2 | after append 2 cap = 4
[0 -> 3] cap = 4 | after append 4 cap = 8
[0 -> 7] cap = 8 | after append 8 cap = 16
[0 -> 15] cap = 16 | after append 16 cap = 32
[0 -> 31] cap = 32 | after append 32 cap = 64
[0 -> 63] cap = 64 | after append 64 cap = 128
[0 -> 127] cap = 128 | after append 128 cap = 256
[0 -> 255] cap = 256 | after append 256 cap = 512
[0 -> 511] cap = 512 | after append 512 cap = 1024
[0 -> 1023] cap = 1024 | after append 1024 cap = 1280
[0 -> 1279] cap = 1280 | after append 1280 cap = 1696
[0 -> 1695] cap = 1696 | after append 1696 cap = 2304
複製程式碼
在老 slice 容量小於1024的時候,新 slice 的容量的確是老 slice 的2倍。目前還算正確。
但是,當老 slice 容量大於等於 1024
的時候,情況就有變化了。當向 slice 中新增元素 1280
的時候,老 slice 的容量為 1280
,之後變成了 1696
,兩者並不是 1.25
倍的關係(1696/1280=1.325)。新增完 1696
後,新的容量 2304
當然也不是 1696
的 1.25
倍。
可見,現在網上各種文章中的擴容策略並不正確。我們直接搬出原始碼:原始碼面前,了無祕密。
從前面彙編程式碼我們也看到了,向 slice 追加元素的時候,若容量不夠,會呼叫 growslice
函式,所以我們直接看它的程式碼。
// go 1.9.5 src/runtime/slice.go:82
func growslice(et *_type, old slice, cap int) slice {
// ……
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
if old.len < 1024 {
newcap = doublecap
} else {
for newcap < cap {
newcap += newcap / 4
}
}
}
// ……
capmem = roundupsize(uintptr(newcap) * ptrSize)
newcap = int(capmem / ptrSize)
}
複製程式碼
看到了嗎?如果只看前半部分,現在網上各種文章裡說的 newcap
的規律是對的。現實是,後半部分還對 newcap
作了一個記憶體對齊
,這個和記憶體分配策略相關。進行記憶體對齊之後,新 slice 的容量是要 大於等於
老 slice 容量的 2倍
或者1.25倍
。
之後,向 Go 記憶體管理器申請記憶體,將老 slice 中的資料複製過去,並且將 append 的元素新增到新的底層陣列中。
最後,向 growslice
函式呼叫者返回一個新的 slice,這個 slice 的長度並沒有變化,而容量卻增大了。
關於 append
,我們最後來看一個例子,來源於參考資料部分的【Golang Slice的擴容規則】。
package main
import "fmt"
func main() {
s := []int{1,2}
s = append(s,4,5,6)
fmt.Printf("len=%d, cap=%d",len(s),cap(s))
}
複製程式碼
執行結果是:
len=5, cap=6
複製程式碼
如果按網上各種文章中總結的那樣:小於原 slice 長度小於 1024 的時候,容量每次增加 1 倍。新增元素 4 的時候,容量變為4;新增元素 5 的時候不變;新增元素 6 的時候容量增加 1 倍,變成 8。
那上面程式碼的執行結果就是:
len=5, cap=8
複製程式碼
這是錯誤的!我們來仔細看看,為什麼會這樣,再次搬出程式碼:
// go 1.9.5 src/runtime/slice.go:82
func growslice(et *_type, old slice, cap int) slice {
// ……
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
// ……
}
// ……
capmem = roundupsize(uintptr(newcap) * ptrSize)
newcap = int(capmem / ptrSize)
}
複製程式碼
這個函式的引數依次是 元素的型別,老的 slice,新 slice 最小求的容量
。
例子中 s
原來只有 2 個元素,len
和 cap
都為 2,append
了三個元素後,長度變為 3,容量最小要變成 5,即呼叫 growslice
函式時,傳入的第三個引數應該為 5。即 cap=5
。而一方面,doublecap
是原 slice
容量的 2 倍,等於 4。滿足第一個 if
條件,所以 newcap
變成了 5。
接著呼叫了 roundupsize
函式,傳入 40。(程式碼中ptrSize是指一個指標的大小,在64位機上是8)
我們再看記憶體對齊,搬出 roundupsize
函式的程式碼:
// src/runtime/msize.go:13
func roundupsize(size uintptr) uintptr {
if size < _MaxSmallSize {
if size <= smallSizeMax-8 {
return uintptr(class_to_size[size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv]])
} else {
//……
}
}
//……
}
const _MaxSmallSize = 32768
const smallSizeMax = 1024
const smallSizeDiv = 8
複製程式碼
很明顯,我們最終將返回這個式子的結果:
class_to_size[size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv]]
複製程式碼
這是 Go
原始碼中有關記憶體分配的兩個 slice
。class_to_size
通過 spanClass
獲取 span
劃分的 object
大小。而 size_to_class8
表示通過 size
獲取它的 spanClass
。
var size_to_class8 = [smallSizeMax/smallSizeDiv + 1]uint8{0, 1, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13, 14, 14, 15, 15, 16, 16, 17, 17, 18, 18, 18, 18, 19, 19, 19, 19, 20, 20, 20, 20, 21, 21, 21, 21, 22, 22, 22, 22, 23, 23, 23, 23, 24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 26, 26, 26, 26, 27, 27, 27, 27, 27, 27, 27, 27, 28, 28, 28, 28, 28, 28, 28, 28, 29, 29, 29, 29, 29, 29, 29, 29, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31}
var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 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
等於 40。所以 (size+smallSizeDiv-1)/smallSizeDiv = 5
;獲取 size_to_class8
陣列中索引為 5
的元素為 4
;獲取 class_to_size
中索引為 4
的元素為 48
。
最終,新的 slice 的容量為 6
:
newcap = int(capmem / ptrSize) // 6
複製程式碼
至於,上面的兩個魔法陣列
的由來,暫時就不展開了。
為什麼 nil slice 可以直接 append
其實 nil slice
或者 empty slice
都是可以通過呼叫 append 函式來獲得底層陣列的擴容。最終都是呼叫 mallocgc
來向 Go 的記憶體管理器申請到一塊記憶體,然後再賦給原來的nil slice
或 empty slice
,然後搖身一變,成為“真正”的 slice
了。
傳 slice 和 slice 指標有什麼區別
前面我們說到,slice 其實是一個結構體,包含了三個成員:len, cap, array。分別表示切片長度,容量,底層資料的地址。
當 slice 作為函式引數時,就是一個普通的結構體。其實很好理解:若直接傳 slice,在呼叫者看來,實參 slice 並不會被函式中的操作改變;若傳的是 slice 的指標,在呼叫者看來,是會被改變原 slice 的。
值的注意的是,不管傳的是 slice 還是 slice 指標,如果改變了 slice 底層陣列的資料,會反應到實參 slice 的底層資料。為什麼能改變底層陣列的資料?很好理解:底層資料在 slice 結構體裡是一個指標,僅管 slice 結構體自身不會被改變,也就是說底層資料地址不會被改變。 但是通過指向底層資料的指標,可以改變切片的底層資料,沒有問題。
通過 slice 的 array 欄位就可以拿到陣列的地址。在程式碼裡,是直接通過類似 s[i]=10
這種操作改變 slice 底層陣列元素值。
另外,囉嗦一句,Go 語言的函式引數傳遞,只有值傳遞,沒有引用傳遞。後面會再寫一篇相關的文章,敬請期待。
再來看一個年幼無知的程式碼片段:
package main
func main() {
s := []int{1, 1, 1}
f(s)
fmt.Println(s)
}
func f(s []int) {
// i只是一個副本,不能改變s中元素的值
/*for _, i := range s {
i++
}
*/
for i := range s {
s[i] += 1
}
}
複製程式碼
執行一下,程式輸出:
[2 2 2]
複製程式碼
果真改變了原始 slice 的底層資料。這裡傳遞的是一個 slice 的副本,在 f
函式中,s
只是 main
函式中 s
的一個拷貝。在f
函式內部,對 s
的作用並不會改變外層 main
函式的 s
。
要想真的改變外層 slice
,只有將返回的新的 slice 賦值到原始 slice,或者向函式傳遞一個指向 slice 的指標。我們再來看一個例子:
package main
import "fmt"
func myAppend(s []int) []int {
// 這裡 s 雖然改變了,但並不會影響外層函式的 s
s = append(s, 100)
return s
}
func myAppendPtr(s *[]int) {
// 會改變外層 s 本身
*s = append(*s, 100)
return
}
func main() {
s := []int{1, 1, 1}
newS := myAppend(s)
fmt.Println(s)
fmt.Println(newS)
s = newS
myAppendPtr(&s)
fmt.Println(s)
}
複製程式碼
執行結果:
[1 1 1]
[1 1 1 100]
[1 1 1 100 100]
複製程式碼
myAppend
函式裡,雖然改變了 s
,但它只是一個值傳遞,並不會影響外層的 s
,因此第一行列印出來的結果仍然是 [1 1 1]
。
而 newS
是一個新的 slice
,它是基於 s
得到的。因此它列印的是追加了一個 100
之後的結果: [1 1 1 100]
。
最後,將 newS
賦值給了 s
,s
這時才真正變成了一個新的slice。之後,再給 myAppendPtr
函式傳入一個 s 指標
,這回它真的被改變了:[1 1 1 100 100]
。
總結
到此,關於 slice
的部分就講完了,不知大家有沒有看過癮。我們最後來總結一下:
- 切片是對底層陣列的一個抽象,描述了它的一個片段。
- 切片實際上是一個結構體,它有三個欄位:長度,容量,底層資料的地址。
- 多個切片可能共享同一個底層陣列,這種情況下,對其中一個切片或者底層陣列的更改,會影響到其他切片。
append
函式會在切片容量不夠的情況下,呼叫growslice
函式獲取所需要的記憶體,這稱為擴容,擴容會改變元素原來的位置。- 擴容策略並不是簡單的擴為原切片容量的
2
倍或1.25
倍,還有記憶體對齊的操作。擴容後的容量 >= 原容量的2
倍或1.25
倍。 - 當直接用切片作為函式引數時,可以改變切片的元素,不能改變切片本身;想要改變切片本身,可以將改變後的切片返回,函式呼叫者接收改變後的切片或者將切片指標作為函式引數。
最後,如果你覺得本文對你有幫助的話,幫我點一下右下角的“推薦”吧,感謝!
參考資料
【碼洞《深度解析 Go 語言中「切片」的三種特殊狀態》】juejin.im/post/5bea58… 【老錢 陣列】juejin.im/post/5be53b… 【老錢 切片】juejin.im/post/5be8e0… 【golang interface原始碼】i6448038.github.io/2018/10/01/… 【golang interface原始碼】legendtkl.com/2017/07/01/… 【interface】www.jishuwen.com/d/2C9z#tuit 【雨痕開源Go學習筆記】github.com/qyuhen/book 【slice 圖很漂亮】halfrost.com/go_slice/ 【Golang Slice的擴容規則】jodezer.github.io/2017/05/gol… 【slice作為引數】www.cnblogs.com/fwdqxl/p/93… 【原始碼】ictar.xyz/2018/10/25/… 【append機制 譯文】brantou.github.io/2017/05/24/… 【slice 彙編】xargin.com/go-slice/ 【slice tricks】colobu.com/2017/03/22/… 【有圖】i6448038.github.io/2018/08/11/… 【slice的本質】www.flysnow.org/2018/12/21/… 【slice使用技巧】blog.thinkeridea.com/201901/go/s… 【slice/array、記憶體增長】blog.thinkeridea.com/201901/go/s…