你真的懂Go的切片嗎?

介紹

本文翻譯自:https://blog.golang.org/go-slices-usage-and-internals
Go中的切片提供了一種方便、有效的處理一系列特定類型值的方式。切片在其他語言中和數組是相似的,但是有一些不同的特性。這篇文章將會討論切片,以及如何使用它們。

數組

在go中切片是建立於數組之上的,所以在理解切片之前,我們必須先理解數組。

數組在定義的時候需要明確長度和元素的類型。例如,類型[4]int表示的是一個長度爲4的數組。數組的長度是固定的,長度是數組類型的一部分。也就是說[4]int[5]int是兩個截然不同的類型。數組通過索引的方式取值,表達式s[n]用於獲取數組的第n個元素,數組的索引是從0開始的。

數組不用明顯的初始化(譯者注:不用每個位置的值都初始化),數組的元素的初始值都是該數組對應類型的零值。

// a[2] == 0, the zero value of the int type

內存中[4]int是連續分佈的四個整型值。
在這裏插入圖片描述

go中的數組是值傳遞的。一個數組變量表示的是整個數組;而不是像在C語言中一樣是一個指向數組第一個元素的指針。這意味着當你賦值或者傳遞數組的時候,你使用的是數組的一個copy。(你可以通過傳遞數組指針的方式來進行這種操作)。我們可以認爲數組是一個通過索引而不是使屬性取值的結構體:一個長度固定的符合類型元素。

你可以通過如下方式定義一個數組

b := [2]string{"Penn", "Teller"}

或者,你也可以讓編譯器來爲你計算數組的長度

b := [...]string{"Penn", "Teller"}

上面兩個case中,數組的類型都是[2]string

切片

go中數組是由存在的意義的,但是由於有一點僵化(大小固定),所以你很少在Go的代碼中見到它們。然而,到處都可以見到切片。切片是基於數組構建的,爲開發者提供了巨大的能力和方便。

切片通過[]T來定義,其中T代表的是切片中元素的類型。不需要和數組一樣,切片並不明確的定義一個長度。

切片在字面上的聲明和相似,除了你不需要說明長度:

letters := []string{"a", "b", "c", "d"}

切片也可以通過go內置的函數make來進行創建,其簽名(函數定義的方式???)如下,

func make([]T, len, cap) []T

其中T表示切片中元素的類型。函數make接受的參數包括類型(type)、長度(len)以及可選的容量(cap)。當make被如此調用的時候,就會分配一個數組並返回表示此數組的切片。

var s []byte
s = make([]byte, 5, 5)
// s == []byte{0, 0, 0, 0, 0}

當容量(cap)參數省略的時候,其默認值和長度一樣。下面是和上面一致的更簡明的代碼:

s := make([]byte, 5)

切片的長度和容量可以通過內置函數lencap分別進行檢查

len(s) == 5
cap(s) == 5

下面的兩部分內容會用於討論長度和容量之間的聯繫。

切片的零值是nil,在切片爲零值的時候,調用lencap函數都會返回0.

切片也可以通過對切片或者數組進行切片操作來創建。切片操作通過一個由冒號分隔的半開區間(左邊包括,右邊不包括)完成的。例如,操作表達式b[1:4]創建切片包含b中從1到3的元素(返回的元素的切片範圍爲從0到2)。

b := []byte{'g', 'o', 'l', 'a', 'n', 'g'}
// b[1:4] == []byte{'o', 'l', 'a'}, sharing the same storage as b

切片表達式的初始位置和結束位置都可以可以省略的,初始值默認爲0,結束值默認是切片(或數組)的長度。

// b[:2] == []byte{'g', 'o'}
// b[2:] == []byte{'l', 'a', 'n', 'g'}
// b[:] == b

下面是通過數組的方式來創建切片:

x := [3]string{"Лайка", "Белка", "Стрелка"}
s := x[:] // a slice referencing the storage of x

Slice的內部結構

切片是一個用於表述數組片段的結構。它包含一個指向數組的指針,片段的長度,以及容量(片段的最大長度)。
在這裏插入圖片描述

我們之前通過make([]byte, 5)創建的變量s,其內部是按照如下方式組織的:
在這裏插入圖片描述
長度表示的是slice所代表的元素的個數。容量表示的是切片所在的數組的元素的個數(起始位置爲切片表示)。長度和容量的區別會隨着下面的幾個例子的出現愈加清晰。

當我們對s進行切片操作,可以觀察到切片數據結構的變化,以及和底層數組的關係:

s = s[2:4]

在這裏插入圖片描述
切片操作並不會複製切片的值。它會創建一個新的切片用於指向原來的數組。這樣就會使得切片的操作和通過索引對數組取值一樣搞笑。因此,修改切片中元素的值,也會修改之前切片中的值。因爲他們共享一個數組的空間。

d := []byte{'r', 'o', 'a', 'd'}
e := d[2:] 
// e == []byte{'a', 'd'}
e[1] = 'm'
// e == []byte{'a', 'm'}
// d == []byte{'r', 'o', 'a', 'm'}g

在之前,我們創建的切片的長度小於底層數組的長度,我們可以再次通過切片的操作來延長s

s = s[:cap(s)]

在這裏插入圖片描述
切片並不能增長到超過其容量。嘗試進行這種操作會造成一個運行時異常,和對數組或者切片通過索引取值,而索引超過範圍時的異常是一樣的。通過對切片進行小於0的切片來獲取數組之前的元素也會造成同樣的異常。

增長的切片(copy和append函數)

爲了增長一個切片的容量,我們必須創建一個新的並且容量更大的切片並且將原來切片中的數據複製到新的切片之中。這個就是其他語言動態數組背後進行的操作。下面這個例子就會通過創建一個新的切片t,然後把切片s中的數據複製到t之中,再把t賦值給s來對s進行容量翻倍的操作:

t := make([]byte, len(s), (cap(s)+1)*2) // +1 in case cap(s) == 0
for i := range s {
        t[i] = s[i]
}
s = t

像代碼中的for循環來賦值的操作可以通過Go的內置函數copy來操作。正如名稱所表明的一樣,copy從原始的切片中複製元素到目標切片之中,函數返回複製的元素的個數

func copy(dst, src []T) int

copy函數支持兩個不同長度的切片進行復制操作(僅支持複製長度較小的切片的長度元素)。另外,copy可以正確的處理原始切片和目標切片共享一個底層數組的情況。(這個可以試試)

使用copy,我們可以簡化上面的翻倍切片的代碼:

t := make([]byte, len(s), (cap(s)+1)*2)
copy(t, s)
s = t

一個常用的操作就是給一個切片的末尾添加一個元素。下面這個函數就會在一個切片的末尾添加byte元素,並且在必要的時候增長切片的容量,返回一個更新了的切片值:

func AppendByte(slice []byte, data ...byte) []byte {
    m := len(slice)
    n := m + len(data)
    if n > cap(slice) { // if necessary, reallocate
        // allocate double what's needed, for future growth.
        newSlice := make([]byte, (n+1)*2)
        copy(newSlice, slice)
        slice = newSlice
    }
    slice = slice[0:n]
    copy(slice[m:n], data)
    return slice
}

我們可以像下面這樣使用AppendByte函數:

p := []byte{2, 3, 5}
p = AppendByte(p, 7, 11, 13)
// p == []byte{2, 3, 5, 7, 11, 13}

AppendByte這樣的函數是非常有用的,它們在切片增長的時候提供了完全的控制。根據程序的特性,可能需要分配更小的或者更大的塊,或者給出再次分配的最大值。

但是大部分程序都不需要這樣的完全的控制,所以Go提供了一個內置的append函數;函數的簽名如下

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

函數append將元素x添加到切片s的末尾,在需要更多的容量的時候會增大切片的容量。

a := make([]int, 1)
// a == []int{0}
a = append(a, 1, 2, 3)
// a == []int{0, 1, 2, 3}

我們可以通過的操作將一個切片的所有元素添加到另一個切片的尾部。

a := []string{"John", "Paul"}
b := []string{"George", "Ringo", "Pete"}
a = append(a, b...) // equivalent to "append(a, b[0], b[1], b[2])"
// a == []string{"John", "Paul", "George", "Ringo", "Pete"}

由於切片的零值是nil,表現的像一個長度爲0的切片,所以你可以神你幹嘛一個切片變量,然後在一個for循環中給其添加元素:

// Filter returns a new slice holding only
// the elements of s that satisfy fn()
func Filter(s []int, fn func(int) bool) []int {
    var p []int // == nil
    for _, v := range s {
        if fn(v) {
            p = append(p, v)
        }
    }
    return p
}

一個可能的坑

正如之前提到過的一樣,再次切片並不會複製底層數組的元素。底層的數組會一直保存在內存中,知道沒有變量引用到此數組。這會偶爾的造成程序把全部數據保存在內存中,而使用的僅僅是其中的一小部分。

例如,函數FindDigits會把一個文件加載到內存之中,然後在裏面搜索第一個連續的數字,並把它們通過切片的形式返回

var digitRegexp = regexp.MustCompile("[0-9]+")

func FindDigits(filename string) []byte {
    b, _ := ioutil.ReadFile(filename)
    return digitRegexp.Find(b)
}

這個代碼就如之前說到的一樣,會返回一個[]byte指向一個包含整個文件的數組。由於切片指向原始的數組,所以數組會一直保存在內存中,而垃圾回收並不會釋放其內存;僅僅使用的部分bytes,造成整個文件保存在內存中。

爲了解決這個問題,我們可以將需要的值賦值到新建的切片之中然後返回即可:

func CopyDigits(filename string) []byte {
    b, _ := ioutil.ReadFile(filename)
    b = digitRegexp.Find(b)
    c := make([]byte, len(b))
    copy(c, b)
    return c
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章