Go 部落格
陣列、切片(和字串):append
的機制
引言
程序式程式設計語言最常見的特性之一是陣列的概念。陣列看起來很簡單,但在將它們新增到語言中時,需要回答許多問題,例如:
- 固定大小還是可變大小?
- 大小是否是型別的一部分?
- 多維陣列是什麼樣的?
- 空陣列是否有意義?
這些問題的答案會影響陣列是僅僅是語言的一個特性,還是其設計的核心部分。
在 Go 的早期開發中,花費了大約一年的時間來決定這些問題的答案,直到設計感覺對了。關鍵一步是引入了 切片(slices),它在固定大小的 陣列(arrays) 之上構建,提供了一種靈活、可擴充套件的資料結構。然而,直到今天,Go 的新手程式設計師仍然經常在切片的工作方式上遇到困難,也許是因為其他語言的經驗影響了他們的思維方式。
在這篇文章中,我們將嘗試消除這種困惑。我們將透過逐步構建各個部分來解釋內建函式 append
的工作原理以及它為何如此工作。
陣列
陣列是 Go 中的一個重要構建塊,但就像建築物的地基一樣,它們通常隱藏在更顯眼的元件之下。在我們轉向更具趣味、強大且重要的切片概念之前,必須簡要討論它們。
陣列在 Go 程式中不常見,因為陣列的大小是其型別的一部分,這限制了它的表達能力。
宣告
var buffer [256]byte
聲明瞭變數 buffer
,它儲存 256 個位元組。buffer
的型別包含其大小,即 [256]byte
。一個包含 512 個位元組的陣列將是不同的型別 [512]byte
。
與陣列關聯的資料就是:元素的陣列。示意性地,我們的 buffer 在記憶體中看起來像這樣:
buffer: byte byte byte ... 256 times ... byte byte byte
也就是說,該變數儲存了 256 個位元組的資料,沒有其他東西。我們可以使用熟悉的索引語法訪問其元素,例如 buffer[0]
、buffer[1]
,直到 buffer[255]
。(索引範圍 0 到 255 包含了 256 個元素。)嘗試使用超出此範圍的值對 buffer
進行索引將導致程式崩潰。
有一個名為 len
的內建函式,它返回陣列、切片以及其他一些資料型別的元素數量。對於陣列,len
返回什麼顯而易見。在我們的示例中,len(buffer)
返回固定值 256。
陣列有它們的用武之地——例如,它們是變換矩陣的良好表示——但在 Go 中,它們最常見的用途是為切片提供儲存空間。
切片:切片頭
切片是關鍵所在,但要用好它們,必須確切理解它們是什麼以及它們做什麼。
切片是一種資料結構,它描述了儲存在陣列中的連續部分,並與切片變數本身分開。切片不是陣列。切片 描述 陣列的一部分。
給定上一節中的 buffer
陣列變數,我們可以透過對陣列進行 切片(slicing) 來建立一個描述元素 100 到 150(準確地說,包含 100 到 149)的切片:
var slice []byte = buffer[100:150]
在那個程式碼片段中,我們使用了完整的變數宣告以明確表示。變數 slice
的型別是 []byte
,讀作“位元組切片”,它是透過對名為 buffer
的陣列進行切片(包含元素 100,不包含 150)來初始化的。更符合習慣用法的語法會省略型別,因為它由初始化表示式設定:
var slice = buffer[100:150]
在函式內部,我們可以使用短宣告形式:
slice := buffer[100:150]
這個切片變數到底是什麼?這並不是全部細節,但現在可以把切片看作一個包含兩個元素的小資料結構:長度和指向陣列中某個元素的指標。你可以想象它在底層是這樣構建的:
type sliceHeader struct {
Length int
ZerothElement *byte
}
slice := sliceHeader{
Length: 50,
ZerothElement: &buffer[100],
}
當然,這只是一個示意。儘管這段程式碼片段如此說明,但 sliceHeader
結構對程式設計師是不可見的,並且元素指標的型別取決於元素的型別,但這給出了其工作機制的大致概念。
到目前為止,我們已經在陣列上使用了切片操作,但我們也可以對切片進行切片,就像這樣:
slice2 := slice[5:10]
和之前一樣,這個操作建立了一個新的切片,在本例中包含原切片的元素 5 到 9(包含),這意味著包含原陣列的元素 105 到 109。變數 slice2
對應的底層 sliceHeader
結構如下所示:
slice2 := sliceHeader{
Length: 5,
ZerothElement: &buffer[105],
}
注意,這個頭部仍然指向儲存在 buffer
變數中的同一個底層陣列。
我們還可以進行 重新切片(reslice),也就是說,對一個切片進行切片並將結果儲存回原始切片結構中。在執行
slice = slice[5:10]
之後,變數 slice
的 sliceHeader
結構看起來就像 slice2
變數的結構一樣。你會經常看到重新切片的應用,例如用來截斷一個切片。這條語句去掉了我們切片的第一個和最後一個元素:
slice = slice[1:len(slice)-1]
[練習:寫出在這個賦值之後,sliceHeader
結構是什麼樣的。]
你會經常聽到經驗豐富的 Go 程式設計師談論“切片頭”,因為那確實是儲存在切片變數中的內容。例如,當你呼叫一個接收切片作為引數的函式時,比如 bytes.IndexRune,傳遞給函式的正是那個切片頭。在這個呼叫中,
slashPos := bytes.IndexRune(slice, '/')
傳遞給 IndexRune
函式的 slice
引數,實際上是一個“切片頭”。
切片頭中還有一個數據項,我們將在下面討論,但首先讓我們看看切片頭的存在在用切片程式設計時意味著什麼。
將切片傳遞給函式
重要的是要理解,即使切片包含一個指標,它本身也是一個值。在底層,它是一個結構體值,包含了指標和長度。它 不是 一個指向結構體的指標。
這很重要。
當我們在上一個例子中呼叫 IndexRune
時,傳遞給它的是切片頭的一個 副本。這種行為帶來了重要的影響。
考慮這個簡單的函式:
func AddOneToEachElement(slice []byte) { for i := range slice { slice[i]++ } }
它的作用正如其名稱所示,迭代遍歷切片的索引(使用 for
range
迴圈),並增加其元素的值。
試試看:
func main() { slice := buffer[10:20] for i := 0; i < len(slice); i++ { slice[i] = byte(i) } fmt.Println("before", slice) AddOneToEachElement(slice) fmt.Println("after", slice) }
(如果您想進一步探索,可以編輯和重新執行這些可執行的程式碼片段。)
即使切片 頭 是按值傳遞的,但頭中包含一個指向陣列元素的指標,因此原始切片頭和傳遞給函式的頭的副本都描述的是同一個陣列。因此,當函式返回時,可以透過原始切片變數看到被修改的元素。
傳遞給函式的引數確實是副本,如下例所示:
func SubtractOneFromLength(slice []byte) []byte { slice = slice[0 : len(slice)-1] return slice } func main() { fmt.Println("Before: len(slice) =", len(slice)) newSlice := SubtractOneFromLength(slice) fmt.Println("After: len(slice) =", len(slice)) fmt.Println("After: len(newSlice) =", len(newSlice)) }
在這裡我們看到,函式的引數(切片)的 內容 可以被函式修改,但它的 頭部 不能。儲存在 slice
變數中的長度不會因為函式呼叫而改變,因為函式接收到的是切片頭部的副本,而不是原始頭部。因此,如果我們想編寫一個修改切片頭部的函式,必須將其作為結果引數返回,就像我們在這裡所做的那樣。slice
變數本身沒有改變,但返回的值具有新的長度,然後將其儲存到 newSlice
中。
指向切片的指標:方法接收者
讓函式修改切片頭部的另一種方法是傳遞一個指向它的指標。這是我們上一個示例的變體,它實現了這一點:
func PtrSubtractOneFromLength(slicePtr *[]byte) { slice := *slicePtr *slicePtr = slice[0 : len(slice)-1] } func main() { fmt.Println("Before: len(slice) =", len(slice)) PtrSubtractOneFromLength(&slice) fmt.Println("After: len(slice) =", len(slice)) }
在那個例子中看起來有點笨拙,特別是處理額外的間接層(使用臨時變數會有所幫助),但在一種常見情況下你會看到指向切片的指標。對於修改切片的方法,習慣上使用指標接收者。
假設我們想在切片上建立一個方法,使其在最後一個斜槓處截斷。我們可以這樣寫:
type path []byte func (p *path) TruncateAtFinalSlash() { i := bytes.LastIndex(*p, []byte("/")) if i >= 0 { *p = (*p)[0:i] } } func main() { pathName := path("/usr/bin/tso") // Conversion from string to path. pathName.TruncateAtFinalSlash() fmt.Printf("%s\n", pathName) }
如果您執行這個示例,會看到它工作正常,更新了呼叫者中的切片。
[練習:將接收者的型別從指標改為值,然後再次執行。解釋發生了什麼。]
另一方面,如果我們想為 path
編寫一個方法,將其中的 ASCII 字母轉為大寫(區域性地忽略非英文字元),該方法可以使用值接收者,因為值接收者仍然會指向同一個底層陣列。
type path []byte func (p path) ToUpper() { for i, b := range p { if 'a' <= b && b <= 'z' { p[i] = b + 'A' - 'a' } } } func main() { pathName := path("/usr/bin/tso") pathName.ToUpper() fmt.Printf("%s\n", pathName) }
這裡,ToUpper
方法在 for
range
結構中使用了兩個變數來捕獲索引和切片元素。這種迴圈形式避免了在迴圈體中多次書寫 p[i]
。
[練習:將 ToUpper
方法轉換為使用指標接收者,看看其行為是否發生變化。]
[高階練習:將 ToUpper
方法轉換為處理 Unicode 字母,而不僅僅是 ASCII 字母。]
容量
看看下面這個將整型切片引數擴充套件一個元素的函式:
func Extend(slice []int, element int) []int { n := len(slice) slice = slice[0 : n+1] slice[n] = element return slice }
(為什麼它需要返回修改後的切片?)現在執行它:
func main() { var iBuffer [10]int slice := iBuffer[0:0] for i := 0; i < 20; i++ { slice = Extend(slice, i) fmt.Println(slice) } }
看看切片是如何增長的,直到……它不再增長。
是時候討論切片頭的第三個組成部分了:它的 容量(capacity)。除了陣列指標和長度之外,切片頭還儲存其容量:
type sliceHeader struct {
Length int
Capacity int
ZerothElement *byte
}
Capacity
欄位記錄了底層陣列實際擁有的空間大小;它是 Length
可以達到的最大值。試圖將切片擴充套件到超出其容量,將會超出陣列的界限並觸發 panic。
在透過以下方式建立我們的示例切片後:
slice := iBuffer[0:0]
它的頭部看起來像這樣:
slice := sliceHeader{
Length: 0,
Capacity: 10,
ZerothElement: &iBuffer[0],
}
Capacity
欄位等於底層陣列的長度,減去切片中第一個元素在陣列中的索引(在本例中為零)。如果您想查詢切片的容量是多少,可以使用內建函式 cap
:
if cap(slice) == len(slice) {
fmt.Println("slice is full!")
}
make
如果我們想將切片擴充套件到超出其容量怎麼辦?你做不到!根據定義,容量是增長的極限。但你可以透過分配一個新陣列、複製資料,並修改切片來描述這個新陣列來實現等效結果。
讓我們從分配開始。我們可以使用內建函式 new
來分配一個更大的陣列,然後對結果進行切片,但更簡單的方法是使用內建函式 make
。它會一次性分配一個新陣列並建立一個切片頭來描述它。make
函式接受三個引數:切片的型別、其初始長度和容量,容量即 make
為儲存切片資料而分配的陣列的長度。這個呼叫建立了一個長度為 10、還有空間容納 5 個額外元素(15-10)的切片,執行它你就可以看到:
slice := make([]int, 10, 15) fmt.Printf("len: %d, cap: %d\n", len(slice), cap(slice))
這段程式碼將我們的整型切片容量翻倍,但長度保持不變:
slice := make([]int, 10, 15) fmt.Printf("len: %d, cap: %d\n", len(slice), cap(slice)) newSlice := make([]int, len(slice), 2*cap(slice)) for i := range slice { newSlice[i] = slice[i] } slice = newSlice fmt.Printf("len: %d, cap: %d\n", len(slice), cap(slice))
執行這段程式碼後,切片在需要再次重新分配之前有更大的增長空間。
建立切片時,長度和容量通常是相同的。內建函式 make
為這種常見情況提供了一個簡寫。長度引數預設為容量,因此可以省略它來將兩者設定為相同的值。在執行以下程式碼後:
gophers := make([]Gopher, 10)
gophers
切片的長度和容量都設定為 10。
複製
在上一節中將切片容量翻倍時,我們編寫了一個迴圈將舊資料複製到新切片中。Go 提供了一個內建函式 copy
來簡化這個操作。它接受兩個切片作為引數,並將資料從右邊的引數複製到左邊的引數。下面是將我們的示例改寫為使用 copy
的程式碼:
newSlice := make([]int, len(slice), 2*cap(slice)) copy(newSlice, slice)
copy
函式很智慧。它只會複製它能複製的部分,同時考慮兩個引數的長度。換句話說,它複製的元素數量是兩個切片長度的最小值。這可以省去一些簿記工作。此外,copy
返回一個整數值,表示它複製的元素數量,儘管並不總是需要檢查這個返回值。
當源切片和目標切片重疊時,copy
函式也能正確處理,這意味著它可以用來在同一個切片中移動元素。下面是如何使用 copy
將一個值插入到切片的中間:
// Insert inserts the value into the slice at the specified index, // which must be in range. // The slice must have room for the new element. func Insert(slice []int, index, value int) []int { // Grow the slice by one element. slice = slice[0 : len(slice)+1] // Use copy to move the upper part of the slice out of the way and open a hole. copy(slice[index+1:], slice[index:]) // Store the new value. slice[index] = value // Return the result. return slice }
這個函式中有幾點需要注意。首先,當然,它必須返回更新後的切片,因為其長度已經改變。其次,它使用了便捷的簡寫。表示式
slice[i:]
與以下表達式完全相同:
slice[i:len(slice)]
此外,雖然我們還沒有使用這個技巧,但我們也可以省略切片表示式的第一個元素;它預設為零。因此:
slice[:]
僅僅表示切片本身,這在對陣列進行切片時很有用。這個表示式是表示“一個描述陣列所有元素的切片”的最短方式:
array[:]
既然障礙已清除,讓我們執行 Insert
函式吧。
slice := make([]int, 10, 20) // Note capacity > length: room to add element. for i := range slice { slice[i] = i } fmt.Println(slice) slice = Insert(slice, 5, 99) fmt.Println(slice)
append:一個例子
前面幾節中,我們編寫了一個 Extend
函式,它將切片擴充套件一個元素。然而,它有 bug,因為如果切片的容量太小,函式就會崩潰。(我們的 Insert
示例也有同樣的問題。)現在我們已經具備瞭解決這個問題的條件,所以讓我們編寫一個健壯的整型切片 Extend
實現。
func Extend(slice []int, element int) []int { n := len(slice) if n == cap(slice) { // Slice is full; must grow. // We double its size and add 1, so if the size is zero we still grow. newSlice := make([]int, len(slice), 2*len(slice)+1) copy(newSlice, slice) slice = newSlice } slice = slice[0 : n+1] slice[n] = element return slice }
在這種情況下,返回切片尤其重要,因為當它重新分配時,結果切片描述的是一個完全不同的陣列。這是一個小程式碼片段,演示了切片填滿時發生的情況:
slice := make([]int, 0, 5) for i := 0; i < 10; i++ { slice = Extend(slice, i) fmt.Printf("len=%d cap=%d slice=%v\n", len(slice), cap(slice), slice) fmt.Println("address of 0th element:", &slice[0]) }
注意當大小為 5 的初始陣列被填滿時發生的重新分配。新陣列分配時,容量和零號元素的地址都會改變。
以健壯的 Extend
函式為指導,我們可以編寫一個更好的函式,允許我們透過新增多個元素來擴充套件切片。為此,我們利用 Go 在函式呼叫時將函式引數列表轉換為切片的能力。也就是說,我們使用 Go 的可變引數函式特性。
讓我們將函式命名為 Append
。對於第一個版本,我們可以簡單地重複呼叫 Extend
,這樣可變引數函式的機制就很清楚了。Append
的函式簽名如下:
func Append(slice []int, items ...int) []int
這意味著 Append
接受一個引數,即一個切片,後面跟著零個或多個 int
引數。就 Append
的實現而言,這些引數就是一個 int
切片,正如你所看到的:
// Append appends the items to the slice. // First version: just loop calling Extend. func Append(slice []int, items ...int) []int { for _, item := range items { slice = Extend(slice, item) } return slice }
注意 for
range
迴圈遍歷 items
引數的元素,其隱式型別是 []int
。還要注意使用空白識別符號 _
來捨棄迴圈中的索引,在本例中我們不需要它。
試試看:
slice := []int{0, 1, 2, 3, 4} fmt.Println(slice) slice = Append(slice, 5, 6, 7, 8) fmt.Println(slice)
這個例子中的另一個新技術是,我們透過編寫複合字面量來初始化切片,複合字面量由切片型別後跟大括號中的元素組成:
slice := []int{0, 1, 2, 3, 4}
Append
函式之所以有趣,還有另一個原因。我們不僅可以追加元素,還可以透過在呼叫時使用 ...
符號將整個第二個切片“展開”成引數來追加它:
slice1 := []int{0, 1, 2, 3, 4} slice2 := []int{55, 66, 77} fmt.Println(slice1) slice1 = Append(slice1, slice2...) // The '...' is essential! fmt.Println(slice1)
當然,我們可以透過最多隻分配一次記憶體來提高 Append
的效率,借鑑 Extend
函式的內部實現:
// Append appends the elements to the slice. // Efficient version. func Append(slice []int, elements ...int) []int { n := len(slice) total := len(slice) + len(elements) if total > cap(slice) { // Reallocate. Grow to 1.5 times the new size, so we can still grow. newSize := total*3/2 + 1 newSlice := make([]int, total, newSize) copy(newSlice, slice) slice = newSlice } slice = slice[:total] copy(slice[n:], elements) return slice }
在這裡,注意我們如何使用了兩次 copy
:一次是將切片資料移動到新分配的記憶體中,另一次是將要追加的項複製到舊資料的末尾。
試試看;行為和之前一樣:
slice1 := []int{0, 1, 2, 3, 4} slice2 := []int{55, 66, 77} fmt.Println(slice1) slice1 = Append(slice1, slice2...) // The '...' is essential! fmt.Println(slice1)
Append:內建函式
至此,我們明白了內建函式 append
的設計動機。它所做的事情與我們的 Append
示例完全相同,效率也相當,但它可以用於任何切片型別。
Go 的一個不足之處在於,任何泛型型別的操作都必須由執行時提供。將來這可能會改變,但目前,為了使切片操作更簡便,Go 提供了一個內建的泛型 append
函式。它的工作方式與我們的 int
切片版本相同,但適用於 任何 切片型別。
請記住,由於切片頭總是透過呼叫 append
來更新,因此在呼叫後需要儲存返回的切片。實際上,如果您不儲存結果,編譯器甚至不會允許您呼叫 append。
這裡有一些與列印語句穿插在一起的單行程式碼。嘗試它們、編輯它們並探索:
// Create a couple of starter slices. slice := []int{1, 2, 3} slice2 := []int{55, 66, 77} fmt.Println("Start slice: ", slice) fmt.Println("Start slice2:", slice2) // Add an item to a slice. slice = append(slice, 4) fmt.Println("Add one item:", slice) // Add one slice to another. slice = append(slice, slice2...) fmt.Println("Add one slice:", slice) // Make a copy of a slice (of int). slice3 := append([]int(nil), slice...) fmt.Println("Copy a slice:", slice3) // Copy a slice to the end of itself. fmt.Println("Before append to self:", slice) slice = append(slice, slice...) fmt.Println("After append to self:", slice)
值得花點時間詳細思考一下該示例中的最後一行程式碼,以理解切片的設計如何使這個簡單的呼叫能夠正確工作。
在社群構建的“切片技巧”(Slice Tricks)Wiki 頁面上有更多關於 append
、copy
和其他使用切片方法的示例。
零值(Nil)
順便說一句,憑藉我們新獲得的知識,我們可以看看 nil
切片的表示形式。自然地,它是切片頭的零值:
sliceHeader{
Length: 0,
Capacity: 0,
ZerothElement: nil,
}
或者簡單地說:
sliceHeader{}
關鍵細節在於元素指標也是 nil
。透過以下方式建立的切片:
array[0:0]
其長度為零(甚至容量也可能為零),但它的指標不是 nil
,因此它不是一個 nil 切片。
應該清楚的是,空切片可以增長(假設其容量非零),但 nil
切片沒有用於存放值的陣列,因此永遠無法增長到容納哪怕一個元素。
話雖如此,nil
切片在功能上等同於零長度切片,儘管它不指向任何東西。它的長度為零,並且可以透過分配記憶體進行追加操作。例如,看看上面透過向 nil
切片追加來複制切片的那行程式碼。
字串
現在簡要討論一下 Go 中的字串,結合切片的概念。
字串實際上非常簡單:它們只是位元組的只讀切片,並獲得了一些額外的語言語法支援。
因為它們是隻讀的,所以不需要容量(你無法擴充套件它們),但在其他方面,在大多數用途上,你可以將它們視為只讀的位元組切片。
首先,我們可以透過索引訪問單個位元組:
slash := "/usr/ken"[0] // yields the byte value '/'.
我們可以對字串進行切片以獲取子字串:
usr := "/usr/ken"[0:4] // yields the string "/usr"
現在應該很清楚,當我們對字串進行切片時,幕後發生了什麼。
我們也可以拿一個普通的位元組切片,並透過簡單的轉換從中建立一個字串:
str := string(slice)
也可以反過來:
slice := []byte(usr)
字串底層的陣列是隱藏的;除了透過字串本身,沒有其他方式可以訪問其內容。這意味著當我們進行這兩種轉換中的任何一種時,都必須複製陣列。當然,Go 會處理好這一點,所以您不必擔心。在這些轉換中的任何一種之後,對位元組切片底層陣列的修改不會影響相應的字串。
這種類切片設計的字串的一個重要結果是,建立子字串的效率非常高。只需要建立一個雙字(two-word)的字串頭部。由於字串是隻讀的,原始字串和切片操作產生的字串可以安全地共享同一個陣列。
歷史註記:最早的字串實現總是進行記憶體分配,但當切片被新增到語言中時,它們提供了一種高效的字串處理模型。因此,一些基準測試顯示了巨大的速度提升。
當然,關於字串還有很多內容,另一篇部落格文章更深入地介紹了它們。
結論
要理解切片如何工作,有助於理解它們的實現方式。有一個小的資料結構,即切片頭,它與切片變數相關聯,並且這個頭描述了一個單獨分配的陣列中的一個部分。當我們傳遞切片值時,切片頭會被複制,但它指向的陣列始終是共享的。
一旦你理解了它們的工作原理,切片不僅變得易於使用,而且功能強大且富有表現力,尤其是在內建函式 copy
和 append
的幫助下。
更多閱讀
在網際網路上有很多關於 Go 切片的資料。正如前面提到的,“切片技巧”(Slice Tricks)Wiki 頁面包含許多示例。Go 切片這篇部落格文章透過清晰的圖表描述了記憶體佈局細節。Russ Cox 的 Go 資料結構文章討論了切片以及 Go 的其他一些內部資料結構。
還有很多其他資料可用,但學習切片的最好方法是使用它們。
下一篇文章:Go 中的字串、位元組、rune 和字元
上一篇文章:第一個 Go 程式
部落格索引