深刻理解 Go Slice

image

原文地址:深刻理解 Go Slicegit

是什麼

在 Go 中,Slice(切片)是抽象在 Array(數組)之上的特殊類型。爲了更好地瞭解 Slice,第一步須要先對 Array 進行理解。深入瞭解 Slice 與 Array 之間的區別後,就能更好的對其底層一番摸索 😄github

用法

Array

func main() {
    nums := [3]int{}
    nums[0] = 1

    n := nums[0]
    n = 2

    fmt.Printf("nums: %v\n", nums)
    fmt.Printf("n: %d\n", n)
}

咱們可得知在 Go 中,數組類型須要指定長度和元素類型。在上述代碼中,可得知 [3]int{} 表示 3 個整數的數組,並進行了初始化。底層數據存儲爲一段連續的內存空間,經過固定的索引值(下標)進行檢索golang

image

數組在聲明後,其元素的初始值(也就是零值)爲 0。而且該變量能夠直接使用,不須要特殊操做數組

同時數組的長度是固定的,它的長度是類型的一部分,所以 [3]int[4]int 在類型上是不一樣的,不能稱爲 「一個東西」數據結構

輸出結果

nums: [1 0 0] 
n: 2

Slice

func main() {
    nums := [3]int{}
    nums[0] = 1

    dnums := nums[:]

    fmt.Printf("dnums: %v", dnums)
}

Slice 是對 Array 的抽象,類型爲 []T。在上述代碼中,dnums 變量經過 nums[:] 進行賦值。須要注意的是,Slice 和 Array 不同,它不須要指定長度。也更加的靈活,可以自動擴容app

數據結構

image

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

Slice 的底層數據結構共分爲三部分,以下:函數

  • array:指向所引用的數組指針(unsafe.Pointer 能夠表示任何可尋址的值的指針)
  • len:長度,當前引用切片的元素個數
  • cap:容量,當前引用切片的容量(底層數組的元素總數)

在實際使用中,cap 必定是大於或等於 len 的。不然會致使 panicui

示例

爲了更好的理解,咱們回顧上小節的代碼便於演示,以下:spa

func main() {
    nums := [3]int{}
    nums[0] = 1

    dnums := nums[:]

    fmt.Printf("dnums: %v", dnums)
}

image

在代碼中,可觀察到 dnums := nums[:],這段代碼肯定了 Slice 的 Pointer 指向數組,且 len 和 cap 都爲數組的基礎屬性。與圖示表達一致指針

len、cap 不一樣

func main() {
    nums := [3]int{}
    nums[0] = 1

    dnums := nums[0:2]

    fmt.Printf("dnums: %v, len: %d, cap: %d", dnums, len(dnums), cap(dnums))
}

image

輸出結果

dnums: [1 0], len: 2, cap: 3

顯然,在這裏指定了 Slice[0:2],所以 len 爲所引用元素的個數,cap 爲所引用的數組元素總個數。與期待一致 😄

建立

Slice 的建立有兩種方式,以下:

  • var []T[]T{}
  • func make([] T,len,cap)[] T

能夠留意 make 函數,咱們都知道 Slice 須要指向一個 Array。那 make 是怎麼作的呢?

它會在調用 make 的時候,分配一個數組並返回引用該數組的 Slice

func makeslice(et *_type, len, cap int) slice {
    maxElements := maxSliceCap(et.size)
    if len < 0 || uintptr(len) > maxElements {
        panic(errorString("makeslice: len out of range"))
    }

    if cap < len || uintptr(cap) > maxElements {
        panic(errorString("makeslice: cap out of range"))
    }

    p := mallocgc(et.size*uintptr(cap), et, true)
    return slice{p, len, cap}
}
  • 根據傳入的 Slice 類型,獲取其類型可以申請的最大容量大小
  • 判斷 len 是否合規,檢查是否在 0 < x < maxElements 範圍內
  • 判斷 cap 是否合規,檢查是否在 len < x < maxElements 範圍內
  • 申請 Slice 所需的內存空間對象。若爲大型對象(大於 32 KB)則直接從堆中分配
  • 返回申請成功的 Slice 內存地址和相關屬性(默認返回申請到的內存起始地址)

擴容

當使用 Slice 時,若存儲的元素不斷增加(例如經過 append)。當條件知足擴容的策略時,將會觸發自動擴容

那麼分別是什麼規則呢?讓咱們一塊兒看看源碼是怎麼說的 😄

zerobase

func growslice(et *_type, old slice, cap int) slice {
    ...
    if et.size == 0 {
        if cap < old.cap {
            panic(errorString("growslice: cap out of range"))
        }
        
        return slice{unsafe.Pointer(&zerobase), old.len, cap}
    }
    ...
}

當 Slice size 爲 0 時,若將要擴容的容量比本來的容量小,則拋出異常(也就是不支持縮容操做)。不然,將從新生成一個新的 Slice 返回,其 Pointer 指向一個 0 byte 地址(不會保留老的 Array 指向)

擴容 - 計算策略

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 0 < newcap && newcap < cap {
                newcap += newcap / 4
            }
            ...
        }
    }
    ...
}
  • 若 Slice cap 大於 doublecap,則擴容後容量大小爲 新 Slice 的容量(超了基準值,我就只給你須要的容量大小)
  • 若 Slice len 小於 1024 個,在擴容時,增加因子爲 1(也就是 3 個變 6 個)
  • 若 Slice len 大於 1024 個,在擴容時,增加因子爲 0.25(本來容量的四分之一)

注:也就是小於 1024 個時,增加 2 倍。大於 1024 個時,增加 1.25 倍

擴容 - 內存策略

func growslice(et *_type, old slice, cap int) slice {
    ...
    var overflow bool
    var lenmem, newlenmem, capmem uintptr
    const ptrSize = unsafe.Sizeof((*byte)(nil))
    switch et.size {
    case 1:
        lenmem = uintptr(old.len)
        newlenmem = uintptr(cap)
        capmem = roundupsize(uintptr(newcap))
        overflow = uintptr(newcap) > _MaxMem
        newcap = int(capmem)
        ...
    }

    if cap < old.cap || overflow || capmem > _MaxMem {
        panic(errorString("growslice: cap out of range"))
    }

    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))
            }
        }
    }
    ...
}

一、獲取老 Slice 長度和計算假定擴容後的新 Slice 元素長度、容量大小以及指針地址(用於後續操做內存的一系列操做)

二、肯定新 Slice 容量大於老 Sice,而且新容量內存小於指定的最大內存、沒有溢出。不然拋出異常

三、若元素類型爲 kindNoPointers,也就是非指針類型。則在老 Slice 後繼續擴容

  • 第一步:根據先前計算的 capmem,在老 Slice cap 後繼續申請內存空間,其後用於擴容
  • 第二步:將 old.array 上的 n 個 bytes(根據 lenmem)拷貝到新的內存空間上
  • 第三步:新內存空間(p)加上新 Slice cap 的容量地址。最終獲得完整的新 Slice cap 內存地址 add(p, newlenmem) (ptr)
  • 第四步:從 ptr 開始從新初始化 n 個 bytes(capmem-newlenmem)

注:那麼問題來了,爲何要從新初始化這塊內存呢?這是由於 ptr 是未初始化的內存(例如:可重用的內存,通常用於新的內存分配),其可能包含 「垃圾」。所以在這裏應當進行 「清理」。便於後面實際使用(擴容)

四、不知足 3 的狀況下,從新申請並初始化一塊內存給新 Slice 用於存儲 Array

五、檢測當前是否正在執行 GC,也就是當前是否啓用 Write Barrier(寫屏障),若啓用則經過 typedmemmove 方法,利用指針運算循環拷貝。不然經過 memmove 方法採起總體拷貝的方式將 lenmem 個字節從 old.array 拷貝到 ptr,以此達到更高的效率

注:通常會在 GC 標記階段啓用 Write Barrier,而且 Write Barrier 只針對指針啓用。那麼在第 5 點中,你就不難理解爲何會有兩種大相徑庭的處理方式了

小結

這裏須要注意的是,擴容時的內存管理的選擇項,以下:

  • 翻新擴展:當前元素爲 kindNoPointers,將在老 Slice cap 的地址後繼續申請空間用於擴容
  • 舉家搬遷:從新申請一塊內存地址,總體遷移並擴容

兩個小 「陷阱」

1、同根

func main() {
    nums := [3]int{}
    nums[0] = 1

    fmt.Printf("nums: %v , len: %d, cap: %d\n", nums, len(nums), cap(nums))

    dnums := nums[0:2]
    dnums[0] = 5

    fmt.Printf("nums: %v ,len: %d, cap: %d\n", nums, len(nums), cap(nums))
    fmt.Printf("dnums: %v, len: %d, cap: %d\n", dnums, len(dnums), cap(dnums))
}

輸出結果:

nums: [1 0 0] , len: 3, cap: 3
nums: [5 0 0] ,len: 3, cap: 3
dnums: [5 0], len: 2, cap: 3

未擴容前,Slice array 指向所引用的 Array。所以在 Slice 上的變動。會直接修改到原始 Array 上(二者所引用的是同一個)

image

2、時過境遷

隨着 Slice 不斷 append,內在的元素愈來愈多,終於觸發了擴容。以下代碼:

func main() {
    nums := [3]int{}
    nums[0] = 1

    fmt.Printf("nums: %v , len: %d, cap: %d\n", nums, len(nums), cap(nums))

    dnums := nums[0:2]
    dnums = append(dnums, []int{2, 3}...)
    dnums[1] = 1

    fmt.Printf("nums: %v ,len: %d, cap: %d\n", nums, len(nums), cap(nums))
    fmt.Printf("dnums: %v, len: %d, cap: %d\n", dnums, len(dnums), cap(dnums))
}

輸出結果:

nums: [1 0 0] , len: 3, cap: 3
nums: [1 0 0] ,len: 3, cap: 3
dnums: [1 1 2 3], len: 4, cap: 6

往 Slice append 元素時,若知足擴容策略,也就是假設插入後,本來數組的容量就超過最大值了

這時候內部就會從新申請一塊內存空間,將本來的元素拷貝一份到新的內存空間上。此時其與本來的數組就沒有任何關聯關係了,再進行修改值也不會變更到原始數組。這是須要注意的

image

複製

原型

func copy(dst,src [] T)int

copy 函數將數據從源 Slice複製到目標 Slice。它返回複製的元素數。

示例

func main() {
    dst := []int{1, 2, 3}
    src := []int{4, 5, 6, 7, 8}
    n := copy(dst, src)

    fmt.Printf("dst: %v, n: %d", dst, n)
}

copy 函數支持在不一樣長度的 Slice 之間進行復制,若出現長度不一致,在複製時會按照最少的 Slice 元素個數進行復制

那麼在源碼中是如何完成複製這一個行爲的呢?咱們來一塊兒看看源碼的實現,以下:

func slicecopy(to, fm slice, width uintptr) int {
    if fm.len == 0 || to.len == 0 {
        return 0
    }

    n := fm.len
    if to.len < n {
        n = to.len
    }

    if width == 0 {
        return n
    }

    ...

    size := uintptr(n) * width
    if size == 1 {
        *(*byte)(to.array) = *(*byte)(fm.array) // known to be a byte pointer
    } else {
        memmove(to.array, fm.array, size)
    }
    return n
}
  • 若源 Slice 或目標 Slice 存在長度爲 0 的狀況,則直接返回 0(由於壓根不須要執行復制行爲)
  • 經過對比兩個 Slice,獲取最小的 Slice 長度。便於後續操做
  • 若 Slice 只有一個元素,則直接利用指針的特性進行轉換
  • 若 Slice 大於一個元素,則從 fm.array 複製 size 個字節到 to.array 的地址處(會覆蓋原有的值)

"奇特"的初始化

在 Slice 中流傳着兩個傳說,分別是 Empty 和 Nil Slice,接下來讓咱們看看它們的小區別 🤓

Empty

func main() {
    nums := []int{}
    renums := make([]int, 0)
    
    fmt.Printf("nums: %v, len: %d, cap: %d\n", nums, len(nums), cap(nums))
    fmt.Printf("renums: %v, len: %d, cap: %d\n", renums, len(renums), cap(renums))
}

輸出結果:

nums: [], len: 0, cap: 0
renums: [], len: 0, cap: 0

Nil

func main() {
    var nums []int
}

輸出結果:

nums: [], len: 0, cap: 0

想想

乍一看,Empty Slice 和 Nil Slice 好像如出一轍?不論是 len,仍是 cap 都爲 0。好像沒區別?咱們再看看以下代碼:

func main() {
    var nums []int
    renums := make([]int, 0)
    if nums == nil {
        fmt.Println("nums is nil.")
    }
    if renums == nil {
        fmt.Println("renums is nil.")
    }
}

你以爲輸出結果是什麼呢?你可能已經想到了,最終的輸出結果:

nums is nil.

爲何

Empty

image

Nil

image

從圖示中能夠看出來,二者有本質上的區別。其底層數組的指向指針是不同的,Nil Slice 指向的是 nil,Empty Slice 指向的是實際存在的空數組地址

你能夠認爲,Nil Slice 代指不存在的 Slice,Empty Slice 代指空集合。二者所表明的意義是徹底不一樣的

總結

經過本文,可得知 Go Slice 至關靈活。不須要你手動擴容,也不須要你關注加多少減多少。對 Array 是動態引用,是 Go 類型的一個極大的補充,也所以在應用中使用的更多、更便捷

雖然有個別要注意的 「坑」,但實際上是合理的。你以爲呢?😄

相關文章
相關標籤/搜索