切換語言為:簡體
Golang是如何實現動態陣列功能的?Slice切片原理解析

Golang是如何實現動態陣列功能的?Slice切片原理解析

  • 爱糖宝
  • 2024-08-04
  • 2075
  • 0
  • 0

當我們需要使用陣列,但是又不能提前定義陣列大小時,可以使用golang的動態陣列結構,slice切片。在 Go 語言的眾多特性裡,slice 是我們經常用到的數據結構。但您有沒有想過,它在背後是怎麼工作的呢?接下來,咱們就一起仔仔細細地研究研究 slice 的底層到底是咋回事。比如它的底層數據結構是咋樣的,又是怎麼和陣列配合實現動態陣列功能的。把這些弄明白了,咱們寫程式碼不光能更高效,還能躲開不少容易出錯的地方。

1 原理

1.1 數據結構

我們每定義一個slice變數,golang底層都會構建一個slice結構的物件。slice結構體由3個成員變數構成:

  • array表示陣列指標,陣列用於儲存資料。

  • len表示切片長度,也就是陣列index從0到len-1已儲存資料。

  • cap表示切片容量,當切片長度超過最大容量時,需要擴容申請更大長度的陣列。

type slice struct {
    array unsafe.Pointer // 陣列指標
    len   int // 切片長度
    cap   int // 切片容量
}

Golang是如何實現動態陣列功能的?Slice切片原理解析

1.2 切片擴容

當我們往切片中append時,如果新新增資料會導致切片的len>cap,則會觸發擴容。申請容量更大的新陣列,並將舊陣列資料複製到新陣列。

當切片擴容時,新申請的陣列長度要滿足3個需求:

  1. 陣列要能承載本次資料append進去,這是基本要求。

  2. 除了1中的基本要求,可以額外多申請一部分空間,防止後續append頻繁擴容,引起效能問題。

  3. 額外申請的空間不能過大,防止記憶體浪費。

爲了滿足上述的3個要求,golang底層的擴容策略是如果需要的容量比舊容量大很多,則不申請額外的空間;如果需要的容量比舊容量並沒有大很多,則可以多申請一些額外的記憶體空間。具體策略如下:

  1. 如果本次append之後,需要的容量大於舊切片容量*2,則新切片容量等於需要的容量。

  2. 如果舊切片容量<256,則新切片容量為舊切片容量*2。

  3. 否則,以公式newcap += (newcap + 3*threshold) / 4迭代,直到newcap大於需要的容量為止,將newcap作為新切片容量。

// growslice handles slice growth during append.
// It is passed the slice element type, the old slice, and the desired new minimum capacity,
// and it returns a new slice with at least that capacity, with the old data
// copied into it.
// The new slice's length is set to the old slice's length,
// NOT to the new requested capacity.
// This is for codegen convenience. The old slice's length is used immediately
// to calculate where to write new values during an append.
// 引數cap表示本次append之後需要的切片容量
func growslice(et *_type, old slice, cap int) slice {
    // 擴容策略,決定擴容後切片容量newcap,也就是需要申請的新陣列長度。
    newcap := old.cap
    doublecap := newcap + newcap
    if cap > doublecap {
        newcap = cap
    } else {
        const threshold = 256
        if old.cap < threshold {
            newcap = doublecap
        } else {
            // Check 0 < newcap to detect overflow
            // and prevent an infinite loop.
            for 0 < newcap && newcap < cap {
                // Transition from growing 2x for small slices
                // to growing 1.25x for large slices. This formula
                // gives a smooth-ish transition between the two.
                newcap += (newcap + 3*threshold) / 4
            }
            // Set newcap to the requested cap when
            // the newcap calculation overflowed.
            if newcap <= 0 {
                newcap = cap
            }
        }
    }
    
    // 計算新切片的容量、長度
    var overflow bool
    var lenmem, newlenmem, capmem uintptr
    lenmem = uintptr(old.len) * et.size
    newlenmem = uintptr(cap) * et.size
    capmem, overflow = math.MulUintptr(et.size, uintptr(newcap))
    capmem = roundupsize(capmem)
    newcap = int(capmem / et.size)
    
    // 陣列記憶體申請
    var p unsafe.Pointer
    if et.ptrdata == 0 {
        p = mallocgc(capmem, nil, false)
    } else {
        p = mallocgc(capmem, et, true)
    }
    
    // 資料複製
    memmove(p, old.array, lenmem)
    
    // 構建新的切片返回
    return slice{p, old.len, newcap}
}

1.3 for 迴圈的坑

在for和for range迴圈中,對於迴圈迭代變數,它的作用域是整個迴圈。在迴圈時,會建立一個變數,每次迭代都會把值賦給同一個地址的變數。如果我們的程式碼有引用這個變數,可能會出現不符合預期的結果。

比如下面對for迴圈迭代變數i的使用,會輸出不符合預期的結果。

func main() {
    var out []*int
    for i := 0; i < 3; i++ {
        out = append(out, &i)
    }
    fmt.Println("Values:", *out[0], *out[1], *out[2]) // 輸出 Values: 3 3 3
    fmt.Println("Addresses:", out[0], out[1], out[2]) // 輸出 Addresses: 0x40e020 0x40e020 0x40e020
}

原因是在每次迭代中,我們將變數 i 的地址附加到 out 切片,但由於它是同一個變數,因此我們附加相同的地址,該地址最終包含分配給 i 的最後一個值。解決方案之一是將迴圈變數複製到新變數中:

for i := 0; i < 3; i++ {
+   i := i // Copy i into a new variable.
    out = append(out, &i)
 }

改正之後輸出符合預期的結果:

Values: 0 1 2
Addresses: 0x40e024 0x40e028 0x40e032

又比如下面for range迴圈,對迭代變數v的使用,也會輸出不符合預期的結果。

package main

import "fmt"

type User struct {
    Name string
    Age  int
}

func main() {
    userMap := make(map[string]*User)
    users := []User{
        {Name: "a", Age: 22},
        {Name: "b", Age: 23},
        {Name: "c", Age: 21},
    }

    for _, v := range users {
        userMap[v.Name] = &v
    }

    for name, user := range userMap {
        fmt.Println(name, "=>", user.Age, ",Addresses:", &user) // 輸出一下user的地址
    }
}

上面的程式碼輸出不符合預期的結果,三個人的年齡和資料地址變成了相同值:

a => 21 ,Addresses: 0xc000012028
b => 21 ,Addresses: 0xc000012028
c => 21 ,Addresses: 0xc000012028

原因跟for迴圈一樣,在迴圈時,建立了變數v,後續每次迭代都把值複製給變數v,導致迴圈結束後,複製的是最後一個,因此輸出的age都是21。解決方案之一也是將迴圈變數複製到新變數中:

for _, v := range users {
+       temp := v
        userMap[v.Name] = &temp
}

**爲了防止迴圈迭代變數誤用導致bug,在Go 1.22中,迴圈迭代變數的作用域,不再是迴圈作用域,而是迭代作用域,每次迭代都會申請一個新變數。**Fixing For Loops in Go 1.22

2 實踐經驗

  1. 切片容量預分配。如果提前能預估切片容量,最好提前在make時就分配好容量,避免後續go底層的再次擴容,在一定程度上能提升程式碼執行效率。

  2. 注意對slice的迴圈,看是否需要將迴圈迭代變數賦值到一個臨時變數使用,防止bug。

3 高頻面試題

  1. 陣列和切片的區別 (基本必問)

  2. 切片的擴容策略是怎樣的?

  3. for range 的時候它的地址會發生變化麼?for 迴圈遍歷 slice 有什麼問題?

  4. 複製大切片一定比小切片代價大嗎?

對於淺複製,比如下面的切片賦值,複製大切片和小切片代價一樣。原因是淺複製a和b共用底層陣列,不需要重新申請陣列空間,做陣列資料遷移,而只需要將a的slice數據結構array、len、cap原樣賦值給b的slice。

a:=[]int{1,2}
b:=a

Golang是如何實現動態陣列功能的?Slice切片原理解析

對於深複製,比如呼叫copy函式複製,複製大切片比小切片代價大。原因是深複製a和b底層陣列不共用,需要重新申請陣列空間,並將a中陣列元素複製到b,大切片的資料量大,因此陣列申請和資料複製的代價也高一些。

a:=[]int{1,2}
b:=make([]int,0)
copy(b,a)

Golang是如何實現動態陣列功能的?Slice切片原理解析


0則評論

您的電子郵件等資訊不會被公開,以下所有項目均必填

OK! You can skip this field.