Go 部落格

陣列、切片(和字串):append 的機制

Rob Pike
2013 年 9 月 26 日

引言

程序式程式設計語言最常見的特性之一是陣列的概念。陣列看起來很簡單,但在將它們新增到語言中時,需要回答許多問題,例如:

  • 固定大小還是可變大小?
  • 大小是否是型別的一部分?
  • 多維陣列是什麼樣的?
  • 空陣列是否有意義?

這些問題的答案會影響陣列是僅僅是語言的一個特性,還是其設計的核心部分。

在 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]

之後,變數 slicesliceHeader 結構看起來就像 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 迴圈),並增加其元素的值。

試試看:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

var buffer [256]byte
var slice []byte = buffer[100:150]

func AddOneToEachElement(slice []byte) {
    for i := range slice {
        slice[i]++
    }
}


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)
}

(如果您想進一步探索,可以編輯和重新執行這些可執行的程式碼片段。)

即使切片 是按值傳遞的,但頭中包含一個指向陣列元素的指標,因此原始切片頭和傳遞給函式的頭的副本都描述的是同一個陣列。因此,當函式返回時,可以透過原始切片變數看到被修改的元素。

傳遞給函式的引數確實是副本,如下例所示:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

var buffer [256]byte
var slice []byte = buffer[100:150]


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 中。

指向切片的指標:方法接收者

讓函式修改切片頭部的另一種方法是傳遞一個指向它的指標。這是我們上一個示例的變體,它實現了這一點:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

var buffer [256]byte
var slice []byte = buffer[100:150]


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))
}

在那個例子中看起來有點笨拙,特別是處理額外的間接層(使用臨時變數會有所幫助),但在一種常見情況下你會看到指向切片的指標。對於修改切片的方法,習慣上使用指標接收者。

假設我們想在切片上建立一個方法,使其在最後一個斜槓處截斷。我們可以這樣寫:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "bytes"
    "fmt"
)


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 字母轉為大寫(區域性地忽略非英文字元),該方法可以使用值接收者,因為值接收者仍然會指向同一個底層陣列。


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)


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
}

(為什麼它需要返回修改後的切片?)現在執行它:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

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)的切片,執行它你就可以看到:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

func main() {

    slice := make([]int, 10, 15)
    fmt.Printf("len: %d, cap: %d\n", len(slice), cap(slice))
}

這段程式碼將我們的整型切片容量翻倍,但長度保持不變:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

func main() {

    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 的程式碼:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

func main() {
    slice := make([]int, 10, 15)
    fmt.Printf("len: %d, cap: %d\n", len(slice), cap(slice))

    newSlice := make([]int, len(slice), 2*cap(slice))
    copy(newSlice, slice)
   slice = newSlice
    fmt.Printf("len: %d, cap: %d\n", len(slice), cap(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 函式吧。


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

// 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
}

func main() {

    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
}

在這種情況下,返回切片尤其重要,因為當它重新分配時,結果切片描述的是一個完全不同的陣列。這是一個小程式碼片段,演示了切片填滿時發生的情況:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

// Extend extends the slice by adding the element to the end.
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
}

func main() {

    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。還要注意使用空白識別符號 _ 來捨棄迴圈中的索引,在本例中我們不需要它。

試試看:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

// Extend extends the slice by adding the element to the end.
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
}

// 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
}

func main() {

    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 函式之所以有趣,還有另一個原因。我們不僅可以追加元素,還可以透過在呼叫時使用 ... 符號將整個第二個切片“展開”成引數來追加它:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

// Extend extends the slice by adding the element to the end.
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
}

// 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
}

func main() {

    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:一次是將切片資料移動到新分配的記憶體中,另一次是將要追加的項複製到舊資料的末尾。

試試看;行為和之前一樣:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

// 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
}

func main() {

    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。

這裡有一些與列印語句穿插在一起的單行程式碼。嘗試它們、編輯它們並探索:


// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
    "fmt"
)

func main() {

    // 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 頁面上有更多關於 appendcopy 和其他使用切片方法的示例。

零值(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)的字串頭部。由於字串是隻讀的,原始字串和切片操作產生的字串可以安全地共享同一個陣列。

歷史註記:最早的字串實現總是進行記憶體分配,但當切片被新增到語言中時,它們提供了一種高效的字串處理模型。因此,一些基準測試顯示了巨大的速度提升。

當然,關於字串還有很多內容,另一篇部落格文章更深入地介紹了它們。

結論

要理解切片如何工作,有助於理解它們的實現方式。有一個小的資料結構,即切片頭,它與切片變數相關聯,並且這個頭描述了一個單獨分配的陣列中的一個部分。當我們傳遞切片值時,切片頭會被複制,但它指向的陣列始終是共享的。

一旦你理解了它們的工作原理,切片不僅變得易於使用,而且功能強大且富有表現力,尤其是在內建函式 copyappend 的幫助下。

更多閱讀

在網際網路上有很多關於 Go 切片的資料。正如前面提到的,“切片技巧”(Slice Tricks)Wiki 頁面包含許多示例。Go 切片這篇部落格文章透過清晰的圖表描述了記憶體佈局細節。Russ Cox 的 Go 資料結構文章討論了切片以及 Go 的其他一些內部資料結構。

還有很多其他資料可用,但學習切片的最好方法是使用它們。

下一篇文章:Go 中的字串、位元組、rune 和字元
上一篇文章:第一個 Go 程式
部落格索引