Go 部落格
陣列、切片(和字串):'append' 的工作原理
引言
程序式程式設計語言中最常見的特性之一是陣列的概念。陣列似乎是簡單的事物,但在向語言中新增陣列時,必須回答許多問題,例如:
- 固定大小還是可變大小?
- 大小是型別的一部分嗎?
- 多維陣列是什麼樣的?
- 空陣列有意義嗎?
這些問題的答案會影響陣列僅僅是語言的特性,還是其設計的核心部分。
在 Go 語言的早期開發中,花了一年左右的時間來決定這些問題的答案,然後設計才感覺恰當。關鍵一步是引入了 切片,它建立在固定大小的 陣列 之上,提供了一種靈活、可擴充套件的資料結構。然而,直到今天,Go 的新手程式設計師仍然常常在理解切片的工作方式上 stumbling over,這可能是因為其他語言的經驗影響了他們的思維。
在這篇文章中,我們將嘗試澄清這些困惑。我們將透過構建各個部分來解釋 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
陣列變數,我們可以建立一個描述元素 100 到 150(精確地說,是 100 到 149,包括)的切片,方法是切片陣列:
var slice []byte = buffer[100:150]
在此程式碼片段中,我們使用了完整的變數宣告以明確。變數 slice
的型別是 []byte
,發音為“byte 切片”,並透過切片元素 100(含)到 150(不含)從名為 buffer
的陣列初始化。更慣用的語法會省略型別,型別由初始化表示式確定:
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
變數中的同一個底層陣列。
我們也可以重新切片,也就是說,對一個切片進行切片並將結果儲存回原始切片結構中。在
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。]
容量
看下面的函式,它透過一個元素擴充套件其 ints
引數切片:
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) } }
看看切片是如何增長的,直到……它停止增長。
是時候討論切片頭的第三個組成部分了:它的容量。除了陣列指標和長度之外,切片頭還儲存其容量:
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))
此程式碼片段將我們 int
切片的容量加倍,但保持其長度不變:
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。
copy
當我們上一節將切片容量加倍時,我們編寫了一個迴圈將舊資料複製到新切片。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
函式,它將切片擴充套件一個元素。然而,它是錯誤的,因為如果切片的容量太小,函式就會崩潰。(我們的 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 會處理這個,當然,所以您不必擔心。在這兩種轉換之後,對位元組切片底層陣列的修改不會影響相應的字串。
字串這種類似切片的設計帶來的一個重要結果是建立子字串非常高效。所有需要做的就是建立一個兩字字串頭。由於字串是隻讀的,原始字串和切片操作產生的字串可以安全地共享同一個陣列。
歷史說明:字串的最早實現總是進行分配,但當切片被新增到語言中時,它們為高效的字串處理提供了模型。結果,一些基準測試看到了巨大的速度提升。
當然,字串還有更多內容,並且有一篇單獨的部落格文章對此進行了更深入的介紹。
結論
要理解切片是如何工作的,理解它們的實現方式很有幫助。有一個小資料結構,即切片頭,它是與切片變數關聯的項,該頭描述了單獨分配的陣列的一個段。當我們傳遞切片值時,會複製切片頭,但它指向的陣列始終是共享的。
一旦您理解了它們的工作原理,切片不僅易於使用,而且強大且富有表現力,尤其是在 copy
和 append
內建函式的幫助下。
更多閱讀
您可以在網上找到很多關於 Go 中切片的資訊。如前所述,“Slice Tricks” Wiki 頁面有許多示例。Go 切片部落格文章使用清晰的圖表描述了記憶體佈局細節。Russ Cox 的Go 資料結構文章討論了切片以及 Go 的其他一些內部資料結構。
還有很多其他材料可用,但學習切片的最好方法是使用它們。
下一篇文章:Go 中的字串、位元組、rune 和字元
上一篇文章:第一個 Go 程式
部落格索引