深入理解 Go Slice

image

原文地址:深入理解 Go Slice

是什么

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

用法

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 個整數的數組,并進行了初始化。底層數據存儲為一段連續的內存空間,通過固定的索引值(下標)進行檢索

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 不一樣,它不需要指定長度。也更加的靈活,能夠自動擴容

數據結構

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

Slice 的底層數據結構共分為三部分,如下:

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

在實際使用中,cap 一定是大于或等于 len 的。否則會導致 panic

示例

為了更好的理解,我們回顧上小節的代碼便于演示,如下:

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

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

2、確定新 Slice 容量大于老 Sice,并且新容量內存小于指定的最大內存、沒有溢出。否則拋出異常

3、若元素類型為 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 是未初始化的內存(例如:可重用的內存,一般用于新的內存分配),其可能包含 “垃圾”。因此在這里應當進行 “清理”。便于后面實際使用(擴容)

4、不滿足 3 的情況下,重新申請并初始化一塊內存給新 Slice 用于存儲 Array

5、檢測當前是否正在執行 Write Barrier(寫屏障)。若正在啟用 Write Barrier,則通過 memmove 采取拷貝的方式將 lenmem 個字節從 old.array 拷貝到 ptr。否則使用 typedmemmove 的方式,利用指針循環拷貝。以此達到更高的效率

注:一般會在 GC 標記階段啟用 Write Barrier,并且 Write Barrier 只針對指針啟用。那么在第 5 點中,你就不難理解為什么會有兩種截然不同的處理方式了

小結

這里需要注意的是,擴容時的內存管理的選擇項,如下:

  • 翻新擴展:當前元素為 kindNoPointers,將在老 Slice cap 的地址后繼續申請空間用于擴容
  • 舉家搬遷:重新申請一塊內存地址,整體遷移并擴容

兩個小 “陷阱”

一、同根

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

二、時過境遷

隨著 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 類型的一個極大的補充,也因此在應用中使用的更多、更便捷

雖然有個別要注意的 “坑”,但其實是合理的。你覺得呢???

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,117評論 6 537
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,860評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,128評論 0 381
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,291評論 1 315
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,025評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,421評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,477評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,642評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,177評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,970評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,157評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,717評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,410評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,821評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,053評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,896評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,157評論 2 375

推薦閱讀更多精彩內容