Go的切片:長度和容量

雖然說 Go 的語法在很大程度上和 PHP 很像,但 PHP 中卻是沒有“切片”這個概念的,在學習的過程中也遇到了一些困惑,遂做此筆記。
困惑1:使用 append 函數爲切片追加元素後,切片的容量時變時不變,其擴容機制是什麼?
困惑2:更改切片的元素會修改其底層數組中對應的元素。爲什麼有些情況下更改了切片元素,其底層數組元素沒有更改?

一、切片的聲明

切片可以看成是數組的引用。在 Go 中,每個數組的大小是固定的,不能隨意改變大小,切片可以爲數組提供動態增長和縮小的需求,但其本身並不存儲任何數據。

/*
 * 這是一個數組的聲明
 */
var a [5]int //只指定長度,元素初始化爲默認值0
var a [5]int{1,2,3,4,5}

/* 
 * 這是一個切片的聲明:即聲明一個沒有長度的數組
 */
// 數組未創建
// 方法1:直接初始化
var s []int //聲明一個長度和容量爲 0 的 nil 切片
var s []int{1,2,3,4,5} // 同時創建一個長度爲5的數組
// 方法2:用make()函數來創建切片:var 變量名 = make([]變量類型,長度,容量)
var s = make([]int, 0, 5)
// 數組已創建
// 切分數組:var 變量名 []變量類型 = arr[low, high],low和high爲數組的索引。
var arr = [5]int{1,2,3,4,5}
var slice []int = arr[1:4] // [2,3,4]

二、切片的長度和容量

切片的長度是它所包含的元素個數。
切片的容量是從它的第一個元素到其底層數組元素末尾的個數。
切片 s 的長度和容量可通過表達式 len(s)cap(s) 來獲取。

s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} // [0 1 2 3 4 5 6 7 8 9] len=10,cap=10
s1 := s[0:5] // [0 1 2 3 4] len=5,cap=10
s2 := s[5:] // [5 6 7 8 9] len=5,cap=5

三、切片追加元素後長度和容量的變化

1.append 函數

Go 提供了內建的 append 函數,爲切片追加新的元素。

func append(s []T, vs ...T) []T

append 的結果是一個包含原切片所有元素加上新添加元素的切片。

下面分兩種情況描述了向切片追加新元素後切片長度和容量的變化。
Example 1:

package main

import "fmt"

func main() {
    arr := [5]int{1,2,3,4,5} // [1 2 3 4 5]
    fmt.Println(arr)
    
    s1 := arr[0:3] // [1 2 3]
    printSlice(s1)
    s1 = append(s1, 6)
    printSlice(s1)
    fmt.Println(arr)
}

func printSlice(s []int) {
    fmt.Printf("len=%d cap=%d %p %v\n", len(s), cap(s), s, s)
}

執行結果如下:

[1 2 3 4 5]
len=3 cap=5 0xc000082030 [1 2 3]
len=4 cap=5 0xc000082030 [1 2 3 6]
[1 2 3 6 5]

可以看到切片在追加元素後,其容量和指針地址沒有變化,但底層數組發生了變化,下標 3 對應的 4 變成了 6。

Example 2:

package main

import "fmt"

func main() {
    arr := [5]int{1,2,3,4} // [1 2 3 4 0]
    fmt.Println(arr)
    
    s2 := arr[2:] // [3 4 0]
    printSlice(s2)
    s2 = append(s2, 5)
    printSlice(s2)
    fmt.Println(arr)
}

func printSlice(s []int) {
    fmt.Printf("len=%d cap=%d %p %v\n", len(s), cap(s), s, s)
}

執行結果如下:

[1 2 3 4 0]
len=3 cap=3 0xc00001c130 [3 4 0]
len=4 cap=6 0xc00001c180 [3 4 0 5]
[1 2 3 4 0]

而這個切片在追加元素後,其容量和指針地址發生了變化,但底層數組未變。

當切片的底層數組不足以容納所有給定值時,它就會分配一個更大的數組。返回的切片會指向這個新分配的數組

2.切片的源代碼學習

Go 中切片的數據結構可以在源碼下的 src/runtime/slice.go 查看。

// go 1.3.16 src/runtime/slice.go:13
type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

可以看到,切片作爲數組的引用,有三個屬性字段:長度、容量和指向數組的指針。
向 slice 追加元素的時候,若容量不夠,會調用 growslice 函數,

// go 1.3.16 src/runtime/slice.go:76
func growslice(et *_type, old slice, cap int) slice {
    //...code
    
    newcap := old.cap
    doublecap := newcap + newcap
    if cap > doublecap {
        newcap = cap
    } else {
        if old.len < 1024 {
            newcap = doublecap
        } else {
            // Check 0 < newcap to detect overflow
            // and prevent an infinite loop.
            for 0 < newcap && newcap < cap {
                newcap += newcap / 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
    switch {
        // ...code
    }
    
    // ...code...
    
    // 將舊切片的數據搬到新切片開闢的地址中
    memmove(p, old.array, lenmem)
    
    return slice{p, old.len, newcap}
}

從上面的源碼,在對 slice 進行 append 等操作時,可能會造成 slice 的自動擴容。其擴容時的大小增長規則是:

  • 如果切片的容量小於 1024,則擴容時其容量大小乘以2;一旦容量大小超過 1024,則增長因子變成 1.25,即每次增加原來容量的四分之一。
  • 如果擴容之後,還沒有觸及原數組的容量,則切片中的指針指向的還是原數組,如果擴容後超過了原數組的容量,則開闢一塊新的內存,把原來的值拷貝過來,這種情況絲毫不會影響到原數組。

上面的兩個例子中,切片的容量均小於 1024 個元素,所以擴容的時候增長因子爲 2,每增加一個元素,其容量翻番。
Example2 中,因爲切片的底層數組沒有足夠的可用容量,append() 函數會創建一個新的底層數組,將被引用的現有的值複製到新數組裏,再追加新的值,所以原數組沒有變化,不是我想象中的[1 2 3 4 5],

3.切片擴容的內部實現

擴容1:切片擴容後其容量不變

slice := []int{1,2,3,4,5}
// 創建新的切片,其長度爲 2 個元素,容量爲 4 個元素
mySlice := slice[1:3]
// 使用原有的容量來分配一個新元素,將新元素賦值爲 40
mySlice = append(mySlice, 40)

執行上面代碼後的底層數據結構如下圖所示:

擴容2:切片擴容後其容量變化

// 創建一個長度和容量都爲 5 的切片
mySlice := []int{1,2,3,4,5}
// 向切片追加一個新元素,將新元素賦值爲 6
mySlice = append(mySlice, 6)

執行上面代碼後的底層數據結構如下圖所示:

四、小結

  1. 切片是一個結構體,保存着切片的容量,長度以及指向數組的指針(數組的地址)。
  2. 儘量對切片設置初始容量值,以避免 append 調用 growslice,因爲新的切片容量比舊的大,會開闢新的地址,拷貝數據,降低性能。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章