【Go】深入剖析 slice 和 array

qiyin發表於2019-01-13

文章來源:https://blog.thinkeridea.com/...


array 和 slice 看似相似,卻有著極大的不同,但他們之間還有著千次萬縷的聯絡 slice 是引用型別、是 array 的引用,相當於動態陣列,
這些都是 slice 的特性,但是 slice 底層如何表現,記憶體中是如何分配的,特別是在程式中大量使用 slice 的情況下,怎樣可以高效使用 slice
今天藉助 Go 的 unsafe 包來探索 array 和 slice 的各種奧妙。

陣列

slice 是在 array 的基礎上實現的,需要先詳細瞭解一下陣列。

維基上如此介紹陣列:

在電腦科學中,陣列資料結構(英語:array data structure),簡稱陣列(英語:Array),是由相同型別的元素(element)的集合所組成的資料結構,分配一塊連續的記憶體來儲存,利用元素的索引(index)可以計算出該元素對應的儲存地址。
陣列設計之初是在形式上依賴記憶體分配而成的,所以必須在使用前預先請求空間。這使得陣列有以下特性:

  1. 請求空間以後大小固定,不能再改變(資料溢位問題);
  2. 在記憶體中有空間連續性的表現,中間不會存在其他程式需要呼叫的資料,為此陣列的專用記憶體空間;
  3. 在舊式程式語言中(如有中階語言之稱的 C),程式不會對陣列的操作做下界判斷,也就有潛在的越界操作的風險(比如會把資料寫在執行中程式需要呼叫的核心部分的記憶體上)。

根據維基的介紹,瞭解到陣列是儲存在一段連續的記憶體中,每個元素的型別相同,即是每個元素的寬度相同,可以根據元素的寬度計算元素儲存的位置。
通過這段介紹總結一下陣列有一下特性:

  • 分配在連續的記憶體地址上
  • 元素型別一致,元素儲存寬度一致
  • 空間大小固定,不能修改
  • 可以通過索引計算出元素對應儲存的位置(只需要知道陣列記憶體的起始位置和資料元素寬度即可)
  • 會出現資料溢位的問題(下標越界)

Go 中的陣列如何實現的呢,恰恰就是這麼實現的,實際上幾乎所有計算機語言,陣列的實現都是相似的,也擁有上面總結的特性。
Go 語言的陣列不同於 C 語言或者其他語言的陣列,C 語言的陣列變數是指向陣列第一個元素的指標;
而 Go 語言的陣列是一個值,Go 語言中的陣列是值型別,一個陣列變數就表示著整個陣列,意味著 Go 語言的陣列在傳遞的時候,傳遞的是原陣列的拷貝。

在程式中陣列的初始化有兩種方法 arr := [10]int{} 或 var arr [10]int,但是不能使用 make 來建立,陣列這節結束時再探討一下這個問題。
使用 unsafe來看一下在記憶體中都是如何儲存的吧:

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    var arr = [3]int{1, 2, 3}

    fmt.Println(unsafe.Sizeof(arr))
    size := unsafe.Sizeof(arr[0])

    // 獲取陣列指定索引元素的值
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&arr[0])) + 1*size)))

    // 設定陣列指定索引元素的值
    *(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&arr[0])) + 1*size)) = 10

    fmt.Println(arr[1])
}

這段程式碼的輸出如下 (Go Playground):

12
2
10

首先說 12 是 fmt.Println(unsafe.Sizeof(arr)) 輸出的,unsafe.Sizeof 用來計算當前變數的值在記憶體中的大小,12 這個代表一個 int 有 4 個位元組,3 * 4 就是 12
這是在 32 位平臺上執行得出的結果, 如果在 64 位平臺上執行陣列的大小是 24。從這裡可以看出 [3]int 在記憶體中由 3 個連續的 int 型別組成,且有 12 個位元組那麼長,這就說明了陣列在記憶體中沒有儲存多餘的資料,只儲存元素本身。

size := unsafe.Sizeof(arr[0]) 用來計算單個元素的寬度,int在 32 位平臺上就是 4 個位元組,uintptr(unsafe.Pointer(&arr[0])) 用來計算陣列起始位置的指標,1*size 用來獲取索引為 1 的元素相對陣列起始位置的偏移,unsafe.Pointer(uintptr(unsafe.Pointer(&arr[0])) + 1*size)) 獲取索引為 1 的元素指標,*(*int) 用來轉換指標位置的資料型別, 因為 int 是 4 個位元組,所以只會讀取 4 個位元組的資料,由元素型別限制資料寬度,來確定元素的結束位置,因此得到的結果是 2

上一個步驟獲取元素的值,其中先獲取了元素的指標,賦值的時候只需要對這個指標位置設定值就可以了, *(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&arr[0])) + 1*size)) = 10 就是用來給指定下標元素賦值。

陣列在記憶體中的結構

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    n:= 10
    var arr = [n]int{}
    fmt.Println(arr)
}

如上程式碼,動態的給陣列設定長度,會導致編譯錯誤 non-constant array bound n, 由此推導陣列的所有操作都是編譯時完成的,會轉成對應的指令,通過這個特性知道陣列的長度是陣列型別不可或缺的一部分,並且必須在編寫程式時確定。
可以通過 GOOS=linux GOARCH=amd64 go tool compile -S array.go 來獲取對應的彙編程式碼,在 array.go 中做一些陣列相關的操作,檢視轉換對應的指令。

之前的疑問,為什麼陣列不能用 make 建立? 上面分析瞭解到陣列操作是在編譯時轉換成對應指令的,而 make 是在執行時處理(特殊狀態下會做編譯器優化,make 可以被優化,下面 slice 分析時來講)。

slice

因為陣列是固定長度且是值傳遞,很不靈活,所以在 Go 程式中很少看到陣列的影子。然而 slice 無處不在,slice 以陣列為基礎,提供強大的功能和遍歷性。
slice 的型別規範是 [] T,slice T 元素的型別。與陣列型別不同,slice 型別沒有指定的長度。

slice 申明的幾種方法:

s := []int{1, 2, 3} 簡短的賦值語句
var s []int var 申明
make([]int, 3, 8) 或 make([]int, 3) make 內建方法建立
s := ss[:5] 從切片或者陣列建立

slice 有兩個內建函式來獲取其屬性:

len 獲取 slice 的長度
cap 獲取 slice 的容量

slice 的屬性,這東西是什麼,還需藉助 unsafe 來探究一下。

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    s := make([]int, 10, 20)

    s[2] = 100
    s[9] = 200

    size := unsafe.Sizeof(0)
    fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size*2)))

    fmt.Println(*(*[20]int)(unsafe.Pointer(*(*uintptr)(unsafe.Pointer(&s)))))
}

這段程式碼的輸出如下 (Go Playground):

c00007ce90
10
20
[0 0 100 0 0 0 0 0 0 200 0 0 0 0 0 0 0 0 0 0]

這段輸出除了第一個,剩餘三個好像都能看出點什麼, 10 不是建立 slice 的長度嗎,20 不就是指定的容量嗎, 最後這個看起來有點像 slice 裡面的資料,但是數量貌似有點多,從第三個元素和第十個元素來看,正好是給 slice 索引 2 和 10 指定的值,但是切片不是長度是 10 個嗎,難道這個是容量,容量剛好是 20個。

第二和第三個輸出很好弄明白,就是 slice 的長度和容量, 最後一個其實是 slice 引用底層陣列的資料,因為建立容量為 20,所以底層陣列的長度就是 20,從這裡瞭解到切片是引用底層陣列上的一段資料,底層陣列的長度就是 slice 的容量,由於陣列長度不可變的特性,當 slice 的長度達到容量大小之後就需要考慮擴容,不是說陣列長度不能變嗎,那 slice 怎麼實現擴容呢, 其實就是在記憶體上分配一個更大的陣列,把當前陣列上的內容拷貝到新的陣列上, slice 來引用新的陣列,這樣就實現擴容了。

說了這麼多,還是沒有看出來 slice 是如何引用陣列的,額…… 之前的程式還有一個輸出沒有搞懂是什麼,難道這個就是底層陣列的引用。

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    arr := [10]int{1, 2, 3}
    arr[7] = 100
    arr[9] = 200

    fmt.Println(arr)

    s1 := arr[:]
    s2 := arr[2:8]

    size := unsafe.Sizeof(0)
    fmt.Println("----------s1---------")
    fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s1)))
    fmt.Printf("%x\n", uintptr(unsafe.Pointer(&arr[0])))

    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size*2)))

    fmt.Println(s1)
    fmt.Println(*(*[10]int)(unsafe.Pointer(*(*uintptr)(unsafe.Pointer(&s1)))))

    fmt.Println("----------s2---------")
    fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s2)))
    fmt.Printf("%x\n", uintptr(unsafe.Pointer(&arr[0]))+size*2)

    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s2)) + size)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s2)) + size*2)))

    fmt.Println(s2)
    fmt.Println(*(*[8]int)(unsafe.Pointer(*(*uintptr)(unsafe.Pointer(&s2)))))
}

以上程式碼輸出如下 (Go Playground):

[1 2 3 0 0 0 0 100 0 200]
----------s1---------
c00001c0a0
c00001c0a0
10
10
[1 2 3 0 0 0 0 100 0 200]
[1 2 3 0 0 0 0 100 0 200]
----------s2---------
c00001c0b0
c00001c0b0
6
8
[3 0 0 0 0 100]
[3 0 0 0 0 100 0 200]

這段輸出看起來有點小複雜,第一行輸出就不用說了吧,這個是列印整個陣列的資料。先分析一下 s1 變數的下面的輸出吧,s1 := arr[:] 引用了整個陣列,所以在第 5、6 行輸出都是 10,因為陣列長度為 10,所有 s1 的長度和容量都為 10,那第 3、4 行輸出是什麼呢,他們怎麼都一樣呢,之前分析陣列的時候 通過 uintptr(unsafe.Pointer(&arr[0])) 來獲取陣列起始位置的指標的,那麼第 4 行列印的就是陣列的指標,這麼就瞭解了第三行輸出的是上面了吧,就是陣列起始位置的指標,所以 *(*uintptr)(unsafe.Pointer(&s1)) 獲取的就是引用陣列的指標,但是這個並不是陣列起始位置的指標,而是 slice 引用陣列元素的指標,為什麼這麼說呢?

接著看 s2 變數下面的輸出吧,s2 := arr[2:8] 引用陣列第 3~8 的元素,那麼 s2 的長度就是 6。 根據經驗可以知道 s2 變數輸出下面第 3 行就是 slice 的長度,但是為啥第 4 行是 8 呢,slice 應用陣列的指定索引起始位置到陣列結尾就是 slice 的容量, 所以 所以從第 3 個位置到末尾,就是 8 個容量。在看第 1 行和第 2 行的輸出,之前分析陣列的時候通過 uintptr(unsafe.Pointer(&arr[0]))+size*2 來獲取陣列指定索引位置的指標,那麼這段第 2 行就是陣列索引為 2 的元素指標,*(*uintptr)(unsafe.Pointer(&s2)) 是獲取切片的指標,第 1 行和第 2 行輸出一致,所以 slice 實際是引用陣列元素位置的指標,並不是陣列起始位置的指標。

總結:

  • slice 是的起始位置是引用陣列元素位置的指標。
  • slice 的長度是引用陣列元素起始位置到結束位置的長度。
  • slice 的容量是引用陣列元素起始位置到陣列末尾的長度。

經過上面一輪分析瞭解到 slice 有三個屬性,引用陣列元素位置指標、長度和容量。實際上 slice 的結構像下圖一樣:

slice

slice 增長

slice 是如何增長的,用 unsafe 分析一下看看:

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    s := make([]int, 9, 10)

    // 引用底層的陣列地址
    fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s)))

    s = append(s, 1)

    // 引用底層的陣列地址
    fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s)))

    s = append(s, 1)

    // 引用底層的陣列地址
    fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s)))
}

以上程式碼的輸出 (Go Playground):

c000082e90
9 10
c000082e90
10 10
c00009a000
11 20

從結果上看前兩次地址是一樣的,初始化一個長度為 9,容量為 10 的 slice,當第一次 append 的時候容量是足夠的,所以底層引用陣列地址未發生變化,此時 slice 的長度和容量都為 10,之後再次 append 的時候發現底層陣列的地址不一樣了,因為 slice 的長度超過了容量,但是新的 slice 容量並不是 11 而是 20,這要說 slice 的機制了,因為陣列長度不可變,想擴容 slice就必須分配一個更大的陣列,並把之前的資料拷貝到新陣列,如果一次只增加 1 個長度,那就會那發生大量的記憶體分配和資料拷貝,這個成本是很大的,所以 slice 是有一個增長策略的。

Go 標準庫 runtime/slice.go 當中有詳細的 slice 增長策略的邏輯:

func growslice(et *_type, old slice, cap int) slice {
    .....
    
    // 計算新的容量,核心演算法用來決定slice容量增長
    newcap := old.cap
    doublecap := newcap + newcap
    if 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
            }
        }
    }

    // 根據et.size調整新的容量
    var overflow bool
    var lenmem, newlenmem, capmem uintptr
    switch {
    case et.size == 1:
        lenmem = uintptr(old.len)
        newlenmem = uintptr(cap)
        capmem = roundupsize(uintptr(newcap))
        overflow = uintptr(newcap) > maxAlloc
        newcap = int(capmem)
    case et.size == sys.PtrSize:
        lenmem = uintptr(old.len) * sys.PtrSize
        newlenmem = uintptr(cap) * sys.PtrSize
        capmem = roundupsize(uintptr(newcap) * sys.PtrSize)
        overflow = uintptr(newcap) > maxAlloc/sys.PtrSize
        newcap = int(capmem / sys.PtrSize)
    case isPowerOfTwo(et.size):
        var shift uintptr
        if sys.PtrSize == 8 {
            // Mask shift for better code generation.
            shift = uintptr(sys.Ctz64(uint64(et.size))) & 63
        } else {
            shift = uintptr(sys.Ctz32(uint32(et.size))) & 31
        }
        lenmem = uintptr(old.len) << shift
        newlenmem = uintptr(cap) << shift
        capmem = roundupsize(uintptr(newcap) << shift)
        overflow = uintptr(newcap) > (maxAlloc >> shift)
        newcap = int(capmem >> shift)
    default:
        lenmem = uintptr(old.len) * et.size
        newlenmem = uintptr(cap) * et.size
        capmem = roundupsize(uintptr(newcap) * et.size)
        overflow = uintptr(newcap) > maxSliceCap(et.size)
        newcap = int(capmem / et.size)
    }

    ......
    
    var p unsafe.Pointer
    if et.kind&kindNoPointers != 0 {
        p = mallocgc(capmem, nil, false)  // 分配新的記憶體
        memmove(p, old.array, lenmem) // 拷貝資料
        memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
    } else {
        p = mallocgc(capmem, et, true) // 分配新的記憶體
        if !writeBarrier.enabled {
            memmove(p, old.array, lenmem)
        } else {
            for i := uintptr(0); i < lenmem; i += et.size {
                typedmemmove(et, add(p, i), add(old.array, i)) // 拷貝資料
            }
        }
    }

    return slice{p, old.len, newcap} // 新slice引用新的陣列,長度為舊陣列的長度,容量為新陣列的容量
}

基本呢就三個步驟,計算新的容量、分配新的陣列、拷貝資料到新陣列,社群很多人分享 slice 的增長方法,實際都不是很精確,因為大家只分析了計算 newcap 的那一段,也就是上面註釋的第一部分,下面的 switch 根據 et.size 來調整 newcap 一段被直接忽略,社群的結論是:"如果 selic 的容量小於 1024 個元素,那麼擴容的時候 slice 的 cap 就翻番,乘以 2;一旦元素個數超過 1024 個元素,增長因子就變成 1.25,即每次增加原來容量的四分之一" 大多數情況也確實如此,但是根據 newcap 的計算規則,如果新的容量超過舊的容量 2 倍時會直接按新的容量分配,真的是這樣嗎?

package main

import (
    "fmt"
)

func main() {
    s := make([]int, 10, 10)
    fmt.Println(len(s), cap(s))
    s2 := make([]int, 40)

    s = append(s, s2...)
    fmt.Println(len(s), cap(s))

}

以上程式碼的輸出 (Go Playground):

10 10
50 52

這個結果有點出人意料, 如果是 2 倍增長應該是 10 * 2 * 2 * 2 結果應該是 80, 如果說新的容量高於舊容量的兩倍但結果也不是 50,實際上 newcap 的結果就是 50,那段邏輯很好理解,但是switch 根據 et.size 來調整 newcap 後就是 52 了,這段邏輯走到了 case et.size == sys.PtrSize 這段,詳細的以後做原始碼分析再說。

總結

  • 當 slice 的長度超過其容量,會分配新的陣列,並把舊陣列上的值拷貝到新的陣列
  • 逐個元素新增到 slice 並操過其容量, 如果 selic 的容量小於 1024 個元素,那麼擴容的時候 slice 的 cap 就翻番,乘以 2;一旦元素個數超過 1024 個元素,增長因子就變成 1.25,即每次增加原來容量的四分之一。
  • 批量新增元素,當新的容量高於舊容量的兩倍,就會分配比新容量稍大一些,並不會按上面第二條的規則擴容。
  • 當 slice 發生擴容,引用新陣列後,slice 操作不會再影響舊的陣列,而是新的陣列(社群經常討論的傳遞 slice 容量超出後,修改資料不會作用到舊的資料上),所以往往設計函式如果會對長度調整都會返回新的 slice,例如 append 方法。

slice 是引用型別?

slice 不發生擴容,所有的修改都會作用在原陣列上,那如果把 slice 傳遞給一個函式或者賦值給另一個變數會發生什麼呢,slice 是引用型別,會有新的記憶體被分配嗎。

package main

import (
    "fmt"
    "strings"
    "unsafe"
)

func main() {
    s := make([]int, 10, 20)

    size := unsafe.Sizeof(0)
    fmt.Printf("%p\n", &s)
    fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size*2)))

    slice(s)

    s1 := s
    fmt.Printf("%p\n", &s1)
    fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s1)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size*2)))

    fmt.Println(strings.Repeat("-", 50))

    *(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size)) = 20

    fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size*2)))

    fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s1)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size*2)))

    fmt.Println(s)
    fmt.Println(s1)
    fmt.Println(strings.Repeat("-", 50))

    s2 := s
    s2 = append(s2, 1)

    fmt.Println(len(s), cap(s), s)
    fmt.Println(len(s1), cap(s1), s1)
    fmt.Println(len(s2), cap(s2), s2)

}

func slice(s []int) {
    size := unsafe.Sizeof(0)
    fmt.Printf("%p\n", &s)
    fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size*2)))
}

這個例子 (Go Playground) 比較長就不逐一分析了,在這個例子裡面呼叫函式傳遞 slice 其變數的地址發生了變化, 但是引用陣列的地址,slice 的長度和容量都沒有變化, 這說明是對 slice 的淺拷貝,拷貝 slice 的三個屬性建立一個新的變數,雖然引用底層陣列還是一個,但是變數並不是一個。

第二個建立 s1 變數,使用 s 為其賦值,發現 s1 和函式呼叫一樣也是 s 的淺拷貝,之後修改 s1 的長度發現 s1 的長度發生變化,但是 s 的長度保持不變, 這也說明 s1 就是 s 的淺拷貝。

這樣設計有什麼優勢呢,第三步建立 s2 變數, 並且 append 一個元素, 發現 s2 的長度發生變化了, s 並沒有,雖然這個資料就在底層陣列上,但是用常規的方法 s 是看不到第 11 個位置上的資料的, s1 因為長度覆蓋到第 11 個元素,所有能夠看到這個資料的變化。這裡能看到採用淺拷貝的方式可以使得切片的屬性各自獨立,而不會相互影響,這樣可以有一定的隔離性,缺點也很明顯,如果兩個變數都引用同一個陣列,同時 append, 在不發生擴容的情況下,總是最後一個 append 的結果被保留,可能引起一些程式設計上疑惑。

總結

slice 是引用型別,但是和 C 傳引用是有區別的, C 裡面的傳引用是在編譯器對原變數資料引用, 並不會發生記憶體分配,而 Go 裡面的引用型別傳遞和賦值會進行淺拷貝,在 32 位平臺上有 12 個位元組的記憶體分配, 在 64 位上有 24 位元組的記憶體分配。

傳引用和引用型別是有區別的, slice 是引用型別。

slice 的三種狀態

slice 有三種狀態:零切片、空切片、nil 切片。

零切片

所有的型別都有零值,如果 slice 所引用陣列元素都沒有賦值,就是所有元素都是型別零值,那這就是零切片。

package main

import "fmt"

func main() {
    var s = make([]int, 10)
    fmt.Println(s)

    var s1 = make([]*int, 10)
    fmt.Println(s1)

    var s2 = make([]string, 10)
    fmt.Println(s2)
}

以上程式碼輸出 (Go Playground):

[0 0 0 0 0 0 0 0 0 0]
[<nil> <nil> <nil> <nil> <nil> <nil> <nil> <nil> <nil> <nil>]
[ ]

零切片很好理解,陣列元素都為型別零值即為零切片,這種狀態下的 slice 和正常的 slice 操作沒有任何區別。

空切片

空切片可以理解就是切片的長度為 0,就是說 slice 沒有元素。 社群大多數解釋空切片為引用底層陣列為 zerobase 這個特殊的指標。但是從操作上看空切片所有的表現就是切片長度為 0,如果容量也為零底層陣列就會指向 zerobase ,這樣就不會發生記憶體分配, 如果容量不會零就會指向底層資料,會有記憶體分配。

package main

import (
    "fmt"
    "reflect"
    "strings"
    "unsafe"
)

func main() {
    var s []int
    s1 := make([]int, 0)
    s2 := make([]int, 0, 0)
    s3 := make([]int, 0, 100)

    arr := [10]int{}
    s4 := arr[:0]

    fmt.Println(strings.Repeat("--s--", 10))
    fmt.Println(*(*reflect.SliceHeader)(unsafe.Pointer(&s)))
    fmt.Println(s)
    fmt.Println(s == nil)

    fmt.Println(strings.Repeat("--s1--", 10))
    fmt.Println(*(*reflect.SliceHeader)(unsafe.Pointer(&s1)))
    fmt.Println(s1)
    fmt.Println(s1 == nil)

    fmt.Println(strings.Repeat("--s2--", 10))
    fmt.Println(*(*reflect.SliceHeader)(unsafe.Pointer(&s2)))
    fmt.Println(s2)
    fmt.Println(s2 == nil)

    fmt.Println(strings.Repeat("--s3--", 10))
    fmt.Println(*(*reflect.SliceHeader)(unsafe.Pointer(&s3)))
    fmt.Println(s3)
    fmt.Println(s3 == nil)

    fmt.Println(strings.Repeat("--s4--", 10))
    fmt.Println(*(*reflect.SliceHeader)(unsafe.Pointer(&s4)))
    fmt.Println(s4)
    fmt.Println(s4 == nil)
}

以上程式碼輸出 (Go Playground):

--s----s----s----s----s----s----s----s----s----s--
{0 0 0}
[]
--s1----s1----s1----s1----s1----s1----s1----s1----s1----s1--
{18349960 0 0}
[]
--s2----s2----s2----s2----s2----s2----s2----s2----s2----s2--
{18349960 0 0}
[]
--s3----s3----s3----s3----s3----s3----s3----s3----s3----s3--
{824634269696 0 100}
[]
--s4----s4----s4----s4----s4----s4----s4----s4----s4----s4--
{824633835680 0 10}
[]

以上示例中除了 s 其它的 slice 都是空切片,列印出來全部都是 []s 是 nil 切片下一小節說。要注意 s1 和 s2 的長度和容量都為 0,且引用陣列指標都是 18349960, 這點太重要了,因為他們都指向 zerobase 這個特殊的指標,是沒有記憶體分配的。

slice

nil 切片

什麼是 nil 切片,這個名字說明 nil 切片沒有引用任何底層陣列,底層陣列的地址為 nil 就是 nil 切片。上一小節中的 s 就是一個 nil 切片,它的底層陣列指標為 0,代表是一個 nil 指標。

slice

總結

零切片就是其元素值都是元素型別的零值的切片。
空切片就是陣列指標不為 nil,且 slice 的長度為 0。
nil 切片就是引用底層陣列指標為 nil 的 slice

操作上零切片、空切片和正常的切片都沒有任何區別,但是 nil 切片會多兩個特性,一個 nil 切片等於 nil 值,且進行 json 序列化時其值為 null,nil 切片還可以通過賦值為 nil 獲得。

陣列與 slice 大比拼

對陣列和 slice 做了效能測試,原始碼在 GitHub

對不同容量和陣列和切片做效能測試,程式碼如下,分為:100、1000、10000、100000、1000000、10000000

func BenchmarkSlice100(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := make([]int, 100)
        for i, v := range s {
            s[i] = 1 + i
            _ = v
        }
    }
}

func BenchmarkArray100(b *testing.B) {
    for i := 0; i < b.N; i++ {
        a := [100]int{}
        for i, v := range a {
            a[i] = 1 + i
            _ = v
        }
    }
}

測試結果如下:

goos: darwin
goarch: amd64
pkg: github.com/thinkeridea/example/array_slice/test
BenchmarkSlice100-8 20000000 69.8 ns/op 0 B/op 0 allocs/op
BenchmarkArray100-8 20000000 69.0 ns/op 0 B/op 0 allocs/op
BenchmarkSlice1000-8 5000000 318 ns/op 0 B/op 0 allocs/op
BenchmarkArray1000-8 5000000 316 ns/op 0 B/op 0 allocs/op
BenchmarkSlice10000-8 200000 9024 ns/op 81920 B/op 1 allocs/op
BenchmarkArray10000-8 500000 3143 ns/op 0 B/op 0 allocs/op
BenchmarkSlice100000-8 10000 114398 ns/op 802816 B/op 1 allocs/op
BenchmarkArray100000-8 20000 61856 ns/op 0 B/op 0 allocs/op
BenchmarkSlice1000000-8 2000 927946 ns/op 8003584 B/op 1 allocs/op
BenchmarkArray1000000-8 5000 342442 ns/op 0 B/op 0 allocs/op
BenchmarkSlice10000000-8 100 10555770 ns/op 80003072 B/op 1 allocs/op
BenchmarkArray10000000-8 50 22918998 ns/op 80003072 B/op 1 allocs/op
PASS
ok github.com/thinkeridea/example/array_slice/test 23.333s

從上面的結果可以發現陣列和 slice 在 1000 以內的容量上時效能機會一致,而且都沒有記憶體分配,這應該是編譯器對 slice 的特殊優化。
從 10000~1000000 容量時陣列的效率就比slice好了一倍有餘,主要原因是陣列在沒有記憶體分配做了編譯優化,而 slice 有記憶體分配。
但是 10000000 容量往後陣列效能大幅度下降,slice 是陣列效能的兩倍,兩個都在執行時做了記憶體分配,其實這麼大的陣列還真是不常見,也沒有比較做編譯器優化了。

slice 與陣列的應用場景總結

slice 和陣列有些差別,特別是應用層上,特性差別很大,那什麼時間使用陣列,什麼時間使用切片呢。
之前做了效能測試,在 1000 以內效能幾乎一致,只有 10000~1000000 時才會出現陣列效能好於 slice,由於陣列在編譯時確定長度,也就是再編寫程式時必須確認長度,所有往常不會用到更大的陣列,大多數都在 1000 以內的長度。我認為如果在編寫程式是就已經確定資料長度,建議用陣列,而且竟可能是區域性使用的位置建議用陣列(避免傳遞產生值拷貝),比如一天 24 小時,一小時 60 分鐘,ip 是 4 個 byte這種情況是可以用時陣列的。

為什麼推薦用陣列,只要能在編寫程式是確定資料長度我都會用陣列,因為其型別會幫助閱讀理解程式,dayHour := [24]Data 一眼就知道是按小時切分資料儲存的,如要傳遞陣列時可以考慮傳遞陣列的指標,當然會帶來一些操作不方便,往常我使用陣列都是不需要傳遞給其它函式的,可能會在 struct 裡面儲存陣列,然後傳遞 struct 的指標,或者用 unsafe 來反解析陣列指標到新的陣列,也不會產生資料拷貝,並且只增加一句轉換語句。slice 會比陣列多儲存三個 int 的屬性,而且指標引用會增加 GC 掃描的成本,每次傳遞都會對這三個屬性進行拷貝,如果可以也可以考慮傳遞 slice 的指標,指標只有一個 int 的大小。

對於不確定大小的資料只能用 slice,否則就要自己做擴容很麻煩, 對於確定大小的集合建議使用陣列。

轉載:

本文作者: 戚銀(thinkeridea)
本文連結: https://blog.thinkeridea.com/...
版權宣告: 本部落格所有文章除特別宣告外,均採用 CC BY 4.0 CN 協議 許可協議。轉載請註明出處!

更多原創文章乾貨分享,請關注公眾號
  • 【Go】深入剖析 slice 和 array
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章