Go 部落格

Go 切片:用法和內部

Andrew Gerrand
2011 年 1 月 5 日

簡介

Go 的切片類型提供在序列資料類型的環境中工作時的方便和有效方法。切片類似其他語言的陣列,但有一些不尋常的特性。本文將說明切片是什麼以及如何使用。

陣列

切片類型建立在 Go 的陣列類型之上,因此要了解切片,首先必須了解陣列。

陣列類型定義會指定元素類型和長度。例如,類型 [4]int 表示一個有 4 個整數的陣列。陣列大小是固定的;長度是其類型的一部分([4]int[5]int 是相異的不相容類型)。陣列可以使用平常的方法索引,因此,表示式 s[n] 可以從 0 開始存取第 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 中一樣)。這表示當你指定或傳遞陣列值時,你會製作其內容的副本。(若要避免副本,你可以傳遞陣列的指標,但那對陣列來說是指標,而不是陣列。)有一種看待陣列的方法就像一個 struct,但使用的是編號欄位,而不是命名欄位:一種固定大小的複合值。

陣列文字常數可以這樣指定

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

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

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

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

切片

陣列有他們的位置,但他們有點不靈活,因此在 Go 程式碼中不太常見。然而,切片無所不在。他們建立在陣列之上,用於提供卓越的效能和便利性。

切片的類型規格是 []T,其中 T 是切片元素的類型。與陣列類型不同,切片類型沒有指定長度。

切片文字常數的宣告方式就像陣列文字常數,只是你省去了元素計數

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

切片可以使用稱作 make 的內建函數建立,其特徵是,

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

其中 T 代表要建立的切片元素的類型。make 函數使用類型、長度和一個可選容量。呼叫 make 時,會配置一個陣列並傳回切片,以參照該陣列。

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

如果省略容量引數,就會預設為指定長度。這是相同程式碼的更簡潔版本

s := make([]byte, 5)

切片的長度和容量可以使用內建的 lencap 函數檢查。

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

接下來的兩個區段探討長度和容量之間的關係。

切片的零值是 nillencap 函數會為 nil 切片傳回 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

切片表達式的開始和結束索引是可選的;它們分別預設為零和切片的長度

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

切片內部

切片是陣列區段的描述。它包含指向陣列的指標、區段的長度及其容量(區段的最大長度)。

先前由 make([]byte, 5) 建立的可變數 s 結構如下

長度是指區段所描述的元素數量。容量是基礎陣列中的元素數量(從區段指標所描述的元素開始)。在我們瀏覽接下來的幾個範例時,長度和容量之間的差別會很清楚。

我們區段 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'}

先前我們把 s 區段成一個長度比容量短的區段。我們可以再區段一次來將 s 擴充為其容量

s = s[:cap(s)]

區段無法擴充超過其容量。嘗試這麼做會導致執行時期驚慌,就像在區段或陣列邊界之外建立索引時一樣。類似地,區段無法重新區段為低於 0 的值,以存取陣列中早期的元素。

擴充區段(複製和附加函式)

若要增加區段的容量,必須建立一個新的較大區段,並將原始區段的內容複製到此新區段。此技術說明其他語言的動態陣列實作如何幕後運作。下一個範例透過建立一個新的區段 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

內建複製函式讓此常見操作的迴圈部分變得更簡單。如同名稱所建議,複製會將資料從來源區段複製到目標區段。它傳回已複製的元素數量。

func copy(dst, src []T) int

copy 函式支援不同長度區段之間的複製(它將只複製較小的元素數量)。此外,copy 能處理共用相同基礎陣列的來源區段和目標區段,正確處理重疊的區段。

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

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

一個常見的操作是附加資料到區段的結尾。此函式會將位元組元素附加到位元組區段,必要時會擴充區段,並傳回更新的區段值

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) 會像一個零長度的切片一樣運作,因此可以宣告一個切片變數,然後在迴圈中附加到它

// 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 會指向一個包含整個檔案的陣列。由於切片會參考原始陣列,因此只要切片持續存在,垃圾回收器就無法釋放陣列;檔案中少數幾個有用的位元會將整個內容保留在記憶體中。

若要修復此問題,可以將感興趣的資料複製到一個新的切片,然後再傳回它

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

可以透過使用 append 來建立這個函數的更精簡版本。這留作讀者的練習。

進一步閱讀

有效的 Go 深入探討 切片陣列,而且 Go 語言規格 定義了 切片 和其 相關 協助函式

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