深度解密Go語言之Slice

Stefno發表於2019-04-02

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 slice 與 empty slice

建立方式 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 sliceempty 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 語言彙編 FUNCDATAPCDATA 是編譯器產生的,用於儲存一些和垃圾收集相關的資訊,我們先不用 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 的那一行。510 分別代表長度和容量,函式引數會在棧頂準備好,之後執行函式呼叫命令 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,DXmakeslice 的返回值 move 到記憶體的其他位置,也稱為區域性變數,這樣就構造出了 slice

makeslice 棧幀

左邊是棧上的資料,右邊是堆上的資料。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 則直接返回傳入的引數。

convT2Eslice 棧幀

32(SP)40(SP) 其實是 makeslice 函式的返回值,這裡可以忽略。

還剩 fmt.Println(slice) 最後一個函式呼叫了,我們繼續。

行數 作用
37-40 準備 Println 函式引數。共3個引數,第一個是型別地址,還有兩個 1,這塊暫時還不知道為什麼要傳,有了解的同學可以在文章後面留言

所以呼叫 fmt.Println(slice) 時,實際是傳入了一個 slice型別的eface地址。這樣,Println就可以訪問型別中的資料,最終給“列印”出來。

fmt.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則儲存棧頂的地址。

初始,BPSP 分別有一個初始狀態。

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 可以是陣列或者 slicelow 是最低索引值,這裡是閉區間,也就是說第一個元素是 data 位於 low 索引處的元素;而 highmax 則是開區間,表示最後一個元素只能是索引 high-1 處的元素,而最大容量則只能是索引 max-1 處的元素。

max >= high >= low
複製程式碼

high == low 時,新 slice 為空。

還有一點,highmax 必須在老陣列或者老 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]
複製程式碼

s1slice 索引2(閉區間)到索引5(開區間,元素真正取到索引4),長度為3,容量預設到陣列結尾,為8。 s2s1 的索引2(閉區間)到索引6(開區間,元素真正取到索引5),容量到索引7(開區間,真正到索引6),為5。

slice origin

接著,向 s2 尾部追加一個元素 100:

s2 = append(s2, 100)
複製程式碼

s2 容量剛好夠,直接追加。不過,這會修改原始陣列對應位置的元素。這一改動,陣列和 s1 都可以看得到。

append 100

再次向 s2 追加元素200:

s2 = append(s2, 100)
複製程式碼

這時,s2 的容量不夠用,該擴容了。於是,s2 另起爐灶,將原來的元素複製新的位置,擴大自己的容量。並且為了應對未來可能的 append 帶來的再一次擴容,s2 會在此次擴容的時候多留一些 buffer,將新的容量將擴大為原始容量的2倍,也就是10了。

append 200

最後,修改 s1 索引為2位置的元素:

s1[2] = 20
複製程式碼

這次只會影響原始陣列相應位置的元素。它影響不到 s2 了,人家已經遠走高飛了。

s1[2]=20

再提一點,列印 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 當然也不是 16961.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 個元素,lencap 都為 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 原始碼中有關記憶體分配的兩個 sliceclass_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 sliceempty 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 賦值給了 ss 這時才真正變成了一個新的slice。之後,再給 myAppendPtr 函式傳入一個 s 指標,這回它真的被改變了:[1 1 1 100 100]

總結

到此,關於 slice 的部分就講完了,不知大家有沒有看過癮。我們最後來總結一下:

  • 切片是對底層陣列的一個抽象,描述了它的一個片段。
  • 切片實際上是一個結構體,它有三個欄位:長度,容量,底層資料的地址。
  • 多個切片可能共享同一個底層陣列,這種情況下,對其中一個切片或者底層陣列的更改,會影響到其他切片。
  • append 函式會在切片容量不夠的情況下,呼叫 growslice 函式獲取所需要的記憶體,這稱為擴容,擴容會改變元素原來的位置。
  • 擴容策略並不是簡單的擴為原切片容量的 2 倍或 1.25 倍,還有記憶體對齊的操作。擴容後的容量 >= 原容量的 2 倍或 1.25 倍。
  • 當直接用切片作為函式引數時,可以改變切片的元素,不能改變切片本身;想要改變切片本身,可以將改變後的切片返回,函式呼叫者接收改變後的切片或者將切片指標作為函式引數。

最後,如果你覺得本文對你有幫助的話,幫我點一下右下角的“推薦”吧,感謝!

QR

參考資料

【碼洞《深度解析 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…

相關文章