深度解密Go語言之Slice

Go 語言的 slice 很好用,不過也有一些坑。slice 是 Go 語言一個很重要的數據結構。網上已經有不少文章寫過了,彷佛不必再寫。可是每一個人看問題的視角不一樣,寫出來的東西天然也不同。我這篇會從更底層的彙編語言去解讀它。並且在我寫這篇文章的過程當中,發現絕大部分文章都存在一些問題,文章裏會講到,這裏先不展開。html

我但願本文能夠終結這個話題,下次再有人想和你討論 slice,直接把這篇文章的連接丟過去就好了。git

當咱們在說 slice 時,到底在說什麼

slice 翻譯成中文就是切片,它和數組(array)很相似,能夠用下標的方式進行訪問,若是越界,就會產生 panic。可是它比數組更靈活,能夠自動地進行擴容。github

瞭解 slice 的本質,最簡單的方法就是看它的源代碼:golang

// runtime/slice.go
type slice struct {
	array unsafe.Pointer // 元素指針
	len   int // 長度 
	cap   int // 容量
}
複製代碼

看到了嗎,slice 共有三個屬性: 指針,指向底層數組; 長度,表示切片可用元素的個數,也就是說使用下標對 slice 的元素進行訪問時,下標不能超過 slice 的長度; 容量,底層數組的元素個數,容量 >= 長度。在底層數組不進行擴容的狀況下,容量也是 slice 能夠擴張的最大限度。shell

切片數據結構

注意,底層數組是能夠被多個 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比較的結果爲trueapp

這裏比較混淆的是empty slice,它的長度和容量也都爲0,可是全部的空切片的數據指針都指向同一個地址 0xc42003bda0。空切片和 nil 比較的結果爲falseide

它們的內部結構以下圖:函數

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…

相關文章
相關標籤/搜索