1. 程式人生 > >Go的切片:長度和容量

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,因為新的切片容量比舊的大,會開闢新的地址,拷貝資料,降低效能。