【譯】Go 切片:用法和內部實現

ligand發表於2019-04-04

Go 的切片(slice)提供了一種方便、高效的處理特定型別資料序列的方法。切片類似於其他語言中的陣列,但有些特別的地方。本文討論切片是什麼、以及如何使用它。

陣列

Go 中切片是基於陣列的,因此為了理解切片,首先得理解陣列。
一個陣列型別包括元素型別和元素個數。例如,型別 [4]int 表示有 4 個整數元素的陣列。一個陣列的長度是固定的,長度本身也是型別的一部分([4]int[5]int 是兩個不同的型別)。陣列能通過下標訪問,因此表示式 s[n] 表示訪問下標從 0 開始的第 n 個元素。

var a [4]int
a[0] = 1
i := a[0]
// i == 1
複製程式碼

陣列不需要顯示初始化,它會自動初始化為陣列零值(the zero value of an array),這個零值中的所有的元素的值都是該元素型別的零值:

// a[2] == 0, 初始化為 int 型別的零值
複製程式碼

型別 [4]int 的記憶體表示就是 4 個整數順序擺放:

記憶體擺放

Go 中陣列是值型別。一個陣列變數表示整個陣列,而不是指向陣列第一個元素的指標(C 語言是這樣的)。這表明對一個陣列進行賦值或傳遞,會複製整個陣列內容。(你可以傳遞陣列指標來避免內容複製,但這是一個陣列指標,不是陣列)一種理解陣列的方法是把它看成一種 struct,通過下標來使用,而不是成員名,這是一種固定大小的複合值。
陣列字面量(literal)能這樣寫:

b := [2]string{"Penn", "Teller"}
複製程式碼

或者,讓編譯器計算元素的個數

b := [...]string{"Penn", "Teller"}
複製程式碼

上面兩種情況,變數 b 的型別都是 [2]string

切片

陣列用自己的用武之地,但不靈活,所以並不經常在 Go 程式碼中出現。與它對比,切片就常用的多。它基於陣列,但非常方便和強大。
切片型別表示為 []T,其中 T 是切片中元素的型別。與陣列型別不同,切片型別不用指定長度。
一個切片的字面量和陣列類似,除了不能指定元素個數:

letters := []string{"a", "b", "c", "d"}
複製程式碼

可以使用內建函式 make 來建立切片,make 函式簽名如下:

func make([]T, len, cap) []T
複製程式碼

其中 T 表示新切片中元素的型別。make 引數有:切片元素型別、切片長度、切片容量(可選)。呼叫 make 時,它會申請一個陣列,然後返回一個使用該陣列的切片。

var s []byte
s = make([]byte, 5, 5)
// s == []byte{0, 0, 0, 0}
複製程式碼

如果不指定切片容量,預設為與切片長度一樣大小。下面是一個更簡單的版本:

s := make([]byte, 5)
複製程式碼

使用內建函式 lencap 來獲取切片的長度和容量。

len(s) == 5
cap(s) == 5
複製程式碼

接下來兩部分討論切片長度和切片容量的關係。
切片的零值為 nil,這種情況下 lencap 都返回 0。
對一個已有的陣列或切片進行切片操作(譯者:注意切片切片操作的區別),能生成一個新的切片。切片操作通過兩個下標中間加個冒號來指定一個半開的區間。比如,表示式 b[1:4] 建立了一個新切片,新切片包括 b 中下標為 1、2、3 的元素(新切片中對應的下標為0、1、2)。

b := []byte{'g', 'o', 'l', 'a', 'n', 'g'}
// b[1:4] == []byte{'o', 'l', 'a'}, 與b共用同一記憶體空間
複製程式碼

表示式中開始和結束的下標都是可選的,預設分別為 0 和切片的長度。

// b[:2] == []byte{'g', 'o'}
// b[2:] == []byte{'l', 'a', 'n', 'g'}
// b[:] == b
複製程式碼

下面是通過陣列來建立切片:

x := [3]string{"Лайка", "Белка", "Стрелка"}
s := x[:] // 切片 s 使用陣列 x 的記憶體空間
複製程式碼

切片的內部實現

切片是陣列某段的描述符,包括一個指向陣列的指標,當前段的長度(length),還有容量(capacity)(這個段能到達的最大長度)。

切片結構

上面使用 make([]byte, 5) 得到的變數 s ,它的結構如下:
變數s的結構

其中的長度表示當前切片中元素的個數。容量表示底層陣列的元素個數(該陣列的起始地址為切片中的指標值)。下面的幾個例子能幫你更佳清晰理解長度容量的區別。
對上面變數 s 進行切片操作,觀察它的結構變化,還有和底下陣列的關係:

s = s[2:4]
複製程式碼

變數s的結構改變

切片操作並不會複製原始切片或陣列的資料,而是將新切片的資料指標指向原始資料。這使得切片操作能像運算元組索引一樣高效。也因此,修改新切片的元素,原始切片也會被修改:

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 = s[:cap(s)]
複製程式碼

恢復s的長度

切片的長度不能超過其容量,如果嘗試這麼做會到造成一個執行時錯誤(runtime panic),就如同陣列或切片下標越界一樣。同樣的,對切片進行切片操作時,引數不能小於0(想要訪問底下陣列之前的元素)。

切片增長(複製和新增元素)

想要增加切片的容量,只能建立一個新的,容量更大的切片,然後把原始資料複製過去。其他語言的動態陣列的實現也是使用的這種幕後技術。下面程式碼的操作:建立一個容量為 s 的兩倍的新切片 t,複製 s 的內容到 t,最後將 t 賦值給 s

t := make([]byte, len(s), (cap(s)+1)*2) // +1 防止 cap(s) == 0 的情況
for i := range s {
  t[i] = s[i]
}
s = t
複製程式碼

內建函式 copy 實現了上面程式碼中迴圈的功能。它將要複製的內容從原始切片拷貝到目的切片,返回拷貝元素的個數。

func copy(dst, src []T) int
複製程式碼

copy 支援不同長度的切片間的拷貝(拷貝長度為兩切片中長度小的那個)。而且,它還能正確處理源切片和目的切片處於同一個陣列上的情況。
使用 copy 上面的程式碼簡化為:

t = make([]byte, len(s), (cap(s)+1)*2)
copy(t, s)
s = t
複製程式碼

將資料新增到切片的末尾是很常用的操作。下面這個函式會將一個 byte 元素新增到元素型別為 byte 的切片中。如果有必要,它會增加切片的大小。最後返回新增後的切片。

func AppendByte(slice []byte, data ...byte) []byte {
  m := len(slice)
  n := m + len(data)
  if n > cap(slice) { //重新申請空間
    // 申請兩倍的空間,以備後用
    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 提供了一個內建函式實現這個功能。太部分情況下都很好用,函式簽名如下:

func append(s []T, X ...T) []T
複製程式碼

apppend 將元素 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...) // 等同於 ”append(a, b[0], b[1], b[2])"
// a == []string{"John", "Paul", "George", "Ringo", "Pete"}
複製程式碼

因為切片的零值(nil)有類似於長度為零的切片的屬性,因此可以直接宣告一個變數,向其新增元素:

// Filter 函式返回一個包含滿足 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
}
複製程式碼

一個可能的陷阱(A possible "gotcha")

前面提到,對切片進行切片操作不會複製切片結構裡面的陣列資料。整個陣列會一直佔用記憶體空間,直到引用數為零。有些情況下,這會造成一個問題:程式只需要用到一塊資料中一小段,卻得把整個資料塊保留在記憶體中。
例如,下面的函式載入一個檔案進記憶體,查詢第一組連續數字的序列作為新的切片返回。

var digitRegexp = regexp.MustCompile("[0-9]+")
func FindDigits(filename string) []byte {
  b, _ := ioutil.ReadFile(filename)
  return digitRegexp.Find(b)
}
複製程式碼

這段程式碼可以正確執行,但是有個問題:返回的切片使用了包含整個檔案的陣列。因為返回的切片引用了原始陣列,只要切片還在,原始陣列就不能被垃圾回收 -- 對檔案某一小段的使用使得整個檔案都必須佔用記憶體。
為了解決這個問題,可以在返回之前將目標資料複製到一個新切片中:

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 Go 包含了對切片陣列的更深入的討論,Go language specification 定義了切片以及相關的輔助函式

(原文完)

譯者總結

只用切片不用陣列 : )

相關文章