Go 部落格

Go Slices:用法和內部機制

Andrew Gerrand
2011 年 1 月 5 日

引言

Go 的 slice 型別提供了一種方便高效的方法來處理帶型別的資料序列。Slices 類似於其他語言中的陣列,但有一些不尋常的屬性。本文將探討 slice 是什麼以及如何使用它們。

陣列

slice 型別是構建在 Go 的陣列型別之上的抽象,因此要理解 slice,我們必須首先理解陣列。

陣列型別定義指定了長度和元素型別。例如,型別 [4]int 表示一個包含四個整數的陣列。陣列的大小是固定的;其長度是其型別的一部分([4]int[5]int 是不同且不相容的型別)。陣列可以像通常一樣進行索引,因此表示式 s[n] 訪問從零開始的第 n 個元素。

var a [4]int
a[0] = 1
i := a[0]
// i == 1

陣列不需要顯式初始化;陣列的零值是一個隨時可用的陣列,其元素本身都是零值

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

[4]int 的記憶體表示就是四個整數值順序排列

Go 的陣列是值。一個數組變量表示整個陣列;它不是指向陣列第一個元素的指標(就像 C 中那樣)。這意味著當你賦值或傳遞陣列值時,你會複製其內容。(為了避免複製,你可以傳遞陣列的 *指標*,但那樣是指向陣列的指標,而不是陣列。)理解陣列的一種方式是將其視為一種結構體,但欄位是透過索引而不是名稱來訪問的:一個固定大小的複合值。

陣列字面量可以這樣指定

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

或者,你可以讓編譯器為你計算陣列元素

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

在這兩種情況下,b 的型別都是 [2]string

Slices

陣列有其用途,但它們有點不靈活,所以在 Go 程式碼中不經常看到它們。然而,slices 隨處可見。它們基於陣列構建,提供了強大的功能和便利性。

slice 的型別規範是 []T,其中 T 是 slice 元素的型別。與陣列型別不同,slice 型別沒有指定長度。

slice 字面量的宣告方式與陣列字面量相同,只是省略了元素計數

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

slice 可以使用內建函式 make 建立,其簽名是

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

其中 T 代表要建立的 slice 的元素型別。make 函式接受一個型別、一個長度和一個可選容量。呼叫時,make 分配一個數組並返回一個引用該陣列的 slice。

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

當容量引數省略時,它預設為指定的長度。這裡是同一程式碼更簡潔的版本

s := make([]byte, 5)

可以使用內建函式 lencap 來檢查 slice 的長度和容量。

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

接下來的兩節討論長度和容量之間的關係。

slice 的零值是 nil。對於 nil slice,lencap 函式都將返回 0。

slice 也可以透過“切分”(slicing)現有 slice 或陣列來形成。切分是透過指定一個半開範圍來實現的,該範圍由兩個索引和一個冒號分隔。例如,表示式 b[1:4] 建立一個包含 b 的元素 1 到 3 的 slice(結果 slice 的索引將是 0 到 2)。

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

slice 表示式的起始索引和結束索引是可選的;它們分別預設為零和 slice 的長度

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

這也是給定陣列建立 slice 的語法

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

Slice 內部機制

slice 是一個數組段的描述符。它包含一個指向陣列的指標、該段的長度和其容量(該段的最大長度)。

我們之前透過 make([]byte, 5) 建立的變數 s 的結構如下

長度是 slice 引用的元素數量。容量是底層陣列中的元素數量(從 slice 指標引用的元素開始)。長度和容量之間的區別將在我們後續的例子中闡明。

當我們切分 s 時,觀察 slice 資料結構的變化及其與底層陣列的關係

s = s[2:4]

切分不會複製 slice 的資料。它建立一個新的 slice 值,指向原始陣列。這使得 slice 操作與運算元組索引一樣高效。因此,修改 re-slice 的*元素*(而不是 slice 本身)會修改原始 slice 的元素

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'}

之前我們將 s 切分到了小於其容量的長度。我們可以透過再次切分它來將其長度增長到其容量

s = s[:cap(s)]

slice 的長度不能超出其容量。嘗試這樣做將導致執行時 panic,就像索引超出 slice 或陣列的邊界一樣。類似地,slice 不能重新切分到小於零的索引以訪問陣列中較早的元素。

增長 slices (copy 和 append 函式)

要增加 slice 的容量,必須建立一個新的、更大的 slice,並將原始 slice 的內容複製到其中。這種技術是其他語言中動態陣列實現在幕後的工作方式。下面的例子透過建立一個新的 slice t,將 s 的內容複製到 t,然後將 slice 值 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

內建的 copy 函式簡化了這種常見操作的迴圈部分。顧名思義,copy 將資料從源 slice 複製到目標 slice。它返回複製的元素數量。

func copy(dst, src []T) int

copy 函式支援在不同長度的 slice 之間複製(它只會複製較短 slice 的元素數量)。此外,copy 可以處理共享相同底層陣列的源 slice 和目標 slice,並正確處理重疊的 slice。

使用 copy,我們可以簡化上面的程式碼片段

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

一種常見的操作是將資料追加到 slice 的末尾。這個函式將位元組元素追加到一個位元組 slice 中,如果需要則增長 slice,並返回更新後的 slice 值。

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 這樣的函式很有用,因為它們提供了對 slice 增長方式的完全控制。根據程式的特點,可能需要以較小或較大的塊分配記憶體,或者對重新分配的大小設定上限。

但大多數程式不需要完全控制,因此 Go 提供了一個適用於大多數情況的內建 append 函式;它的簽名是

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

append 函式將元素 x 追加到 slice s 的末尾,如果需要更大的容量,則增長 slice。

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

要將一個 slice 追加到另一個 slice,使用 ... 將第二個引數展開為引數列表。

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"}

由於 slice 的零值 (nil) 行為類似於零長度 slice,你可以宣告一個 slice 變數,然後在迴圈中向其追加元素

// 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
}

一個可能的“陷阱”

如前所述,re-slice 一個 slice 並不會複製底層陣列。完整的陣列將保留在記憶體中,直到不再被引用。有時這會導致程式在只需要其中一小部分資料時卻將所有資料都保留在記憶體中。

例如,這個 FindDigits 函式將檔案載入到記憶體中,搜尋其中的第一組連續數字,並將它們作為新的 slice 返回。

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

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

這段程式碼按宣傳的那樣工作,但返回的 []byte 指向包含整個檔案的陣列。由於 slice 引用了原始陣列,只要 slice 存在,垃圾回收器就無法釋放陣列;檔案中少量有用的位元組導致整個內容都保留在記憶體中。

為了解決這個問題,可以在返回有趣的資料之前將其複製到一個新的 slice 中

func CopyDigits(filename string) []byte {
    b, _ := ioutil.ReadFile(filename)
    b = digitRegexp.Find(b)
    c := make([]byte, len(b))
    copy(c, b)
    return c
}

可以使用 append 構造這個函式的更簡潔版本。這留給讀者作為練習。

進一步閱讀

Effective Goslicesarrays 進行了深入探討,並且 Go 語言規範 定義了 slices 以及它們的相關輔助函式

下一篇文章:JSON 和 Go
上一篇文章:Go:一年前的今天
部落格索引