Go 部落格
Go 切片:用法和內部原理
引言
Go 的切片型別提供了一種方便高效的方式來處理型別化資料序列。切片類似於其他語言中的陣列,但具有一些不尋常的特性。本文將探討切片是什麼以及如何使用它們。
陣列
切片型別是構建在 Go 陣列型別之上的抽象,因此要理解切片,我們必須先理解陣列。
陣列型別定義指定了長度和元素型別。例如,型別 [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
。
切片
陣列有其用武之地,但它們有點不夠靈活,所以在 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)
可以使用內建的 len
和 cap
函式來檢查切片的長度和容量。
len(s) == 5
cap(s) == 5
接下來的兩個部分將討論長度和容量之間的關係。
切片的零值是 nil
。對於 nil 切片,len
和 cap
函式都將返回 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)]

切片不能擴充套件到超出其容量。嘗試這樣做會導致執行時恐慌,就像索引超出切片或陣列邊界時一樣。同樣,切片也不能重新切片到零以下以訪問陣列中較早的元素。
擴充套件切片(複製和附加函式)
要增加切片的容量,必須建立一個新的、更大的切片,並將原始切片的內容複製到其中。這種技術是其他語言中動態陣列實現幕後工作的方式。下一個示例透過建立一個新切片 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
這個常見操作的迴圈部分透過內建的 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
一個常見的操作是向切片末尾新增資料。此函式將位元組元素附加到位元組切片,並在需要時增長切片,然後返回更新後的切片值。
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
來構建此函式的一個更簡潔的版本。留給讀者作為練習。