Go 部落格
陣列、片段(與字串):『append』的運作機制
引言
陣列是程序性程式語言中最常見的功能之一。陣列看似簡單,但在將其加入語言時必須回答許多問題,例如
- 固定大小還是可變大小?
- 大小是否為類型的一部分?
- 多維陣列如何表示?
- 空陣列是否有意義?
這些問題的解答影響著陣列是否僅為語言的功能或其設計的核心部分。
在 Go 的早期開發時期,大約花了 一年 的時間決定這些問題的解答,然後設計才感覺正確。關鍵步驟是導入片段,由固定大小陣列建構而成,以提供彈性、可擴展的資料結構。但直到今天,剛接觸 Go 的程式設計師常常會被片段的運作方式絆倒,這可能是因為其他語言的經驗影響了他們的思考。
我們將嘗試說明如何消除混淆。我們將建立程式碼片段來說明內建函式 append
的工作方式及其運作方式。
陣列
陣列是 Go 中重要的基本組成部分,但就像建築物的基礎,它們通常隱藏在較明顯的組成部分下方。在進一步瞭解更有趣、更強大、更突出的切片概念之前,我們必須簡要討論它們。
在 Go 程式中不常看到陣列,因為陣列大小是其類型的一部分,這限制了其表現力。
宣告
var buffer [256]byte
宣告變數 buffer
,它包含 256 個位元組。buffer
的類型包含其大小,[256]byte
。具有 512 個位元組的陣列將具有不同的類型 [512]byte
。
與陣列關聯的資料僅是:陣列元素。示意上,我們的緩衝區在記憶體中看起來像這樣,
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
,發音為「位元組切片」,並透過切片元素 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
撰寫一個方法,將其中英文字母改成大寫(不理會非英文名稱),這個方法可以是值,因為值接收器仍然指向相同的基礎陣列。
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
}
容量
欄位記錄基礎陣列實際擁有的空間大小;此大小為 長度
可達到的最大值。嘗試將切片增長到超過其容量將超出陣列的限制,並將觸發恐慌
在範例切片由建立後
slice := iBuffer[0:0]
其標題如下所示
slice := sliceHeader{
Length: 0,
Capacity: 10,
ZerothElement: &iBuffer[0],
}
容量
欄位等於基礎陣列的長度,扣除切片的陣列中第一個元素的索引(此處為零)。如果你想要查詢切片的容量,請使用內建函式 cap
if cap(slice) == len(slice) {
fmt.Println("slice is full!")
}
建立
如果我們想要將切片增長到超過其容量,該怎麼辦?不行!根據定義,容量即是增長的上限。但你可以藉由配置一個新陣列、複製資料並修改切片以描述新陣列來達成等效的結果
從配置開始。我們可以利用內建函式 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
複製
在上一個章節中,當我們讓切片容量加倍時,我們寫了一段迴圈將舊資料複製到新切片。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[:]
現在我們把這些解決掉,來執行我們的 `插入` 函數。
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)
增補:一個範例
幾個章節前,我們寫了一個 `延伸` 函數,它會將切片延伸一個元素。不過它有錯誤,因為如果切片容量太小,函數就會崩潰。(我們的 `插入` 範例有相同的問題。)現在我們已經準備好方法來修復它,讓我們為整數切片撰寫一個強固的 `延伸` 執行。
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 大小的初始陣列填滿時發生重新配置。當新陣列配置時,零元素的容量和位址都改變。
有了強固的 `延伸` 函數作為指引,我們可以撰寫一個更棒的函數,讓它能夠將切片延伸多個元素。為此,我們使用 Go 將函數呼叫時函數引數清單轉換為切片的能力。也就是說,我們使用 Go 的可變引數函數設施。
讓我們將函數命名為 `增補`。對於第一個版本,我們可以重複呼叫 `延伸`,讓可變引數函數的機制更清晰。`增補` 函數的簽章如下
func Append(slice []int, items ...int) []int
此話的含義是:Append
有一個參數(一個分片),後面接 0 到多個 int
參數。這些參數確實是 int
的一個分片,就 Append
的實作觀念而言,如下所示。
// 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)
當然,我們可以透過分配不超過一次,建立在 Extend
的內部基礎上,讓 Append
更有效率。
// 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)
花點時間仔細思考該範例的最後單一行,了解分片的設計如何讓這個簡單的呼叫能正確運作。
「分片技巧」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 中切片的主題。如前所述,“切片技巧” Wiki 頁面有很多範例。部落格文章Go Slices以清晰的圖表說明記憶體配置細節。Russ Cox 的Go 資料結構文章討論了切片以及 Go 的其他內部資料結構。
還有更多資料可用,但學習切片最好的方法就是使用它們。
下一篇文章:Go 中的字串、位元組、符文和字元
前一篇文章:第一個 Go 程式
部落格索引