數組和切片:Append的實現方式

圖文無關

本文翻譯自Rob Pike的文章《Arrays, slices (and strings): The mechanics of 'append'》,原文地址 https://blog.golang.org/slices

原博文中出了一些練習,譯者提出了自己的解法,原文中并不包含這些解法。

前言

數組是面向過程的的編程語言最常見的特性之一。
數組看起來很簡單,不過要在語言中實現這一特性往往要面臨很多問題,比如:

  • 數組應該是固定長度還是是可變長度?
  • 長度應該是數組類型的屬性嗎?
  • 多維數組應該是什么樣子?
  • 如何定義空數組?

對上面問題的解答,關系著數組是否只是一個feature還是語言設計的核心。
在Go的早期開發中,花了大約一年時間來回答上面的問題。切片的引入是解決問題的關鍵,它建立在固定大小的數組上,提供了靈活,可擴展的數據結構。但是時至今日,Go語言新手經常在切片的使用上犯錯,這可能與他們受過其他語言的影響有關。

在這篇博文中,我們將嘗試通過解釋內置append函數的工作原理,以及這個函數的設計思想,來幫助新手消除這些誤解。

數組

數組是Go語言中的一個重要組成部分,但就像建筑的地基一樣,它不容易讓使用者覺察到。在深入了解切片之前,我們必須對數組有一個簡單的了解。
在Go程序中,數組并不常見,因為長度是數組類型的一部分,而這一點限制了數組的表達能力。

下面語句

var buffer [256]byte

聲明了一個占用256個byte,名為buffer的變量,buffer的類型中包含了它的長度,[256]byte。占用512個byte的數組的類型將是[512]byte

buffer在內存中的表示就像下面這樣

buffer: byte byte byte ... 重復256次 ... byte byte byte

buffer包含了256個字節的數據。我們可以通過常見索引的方式來訪問這個數據的元素,buffer[0],buffer[1]直到buffer[255]。訪問超過索引的元素將會導致程序崩潰。

內置的len函數能返回數組和其他幾種類型的長度。對數據來說,len函數的返回時顯而易見的,比如len(buffer)會返回256。

數組時很有用的,它可以用來表示轉換矩陣。但是在Go語言中,數組最常見的作用是存儲切片的數據。

切片

切片頭

切片是本文討論的重點,要想合理地使用它,必須先理解它的工作方式。

切片是描述與切片變量本身分開存儲的數組的連續部分的數據結構。切片不是數組。切片描述了數組的一部分。

我們可以通過下面的方式創建一個切片,這個切片描述了buffer數組第100(閉區間)到150(開區間)之間的元素

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

變量slice的類型是[]byte,它從buffer數組中初始化。更加通用的初始化語句如下:

var slice = buffer[100:150]

在函數內部可以定義的更簡短些

slice := buffer[100:150]

這個slice變量到底是什么呢?現在設想切片是一個包含兩個元素的的數據結構:長度和指向數組某個元素的指針。可以認為就像下面的結構體一樣

type sliceHeader struct {
    Length              int
    ZerothElement       *byte
}

slice := sliceHeader {
    Length:         50
    ZeroElement     &buffer[100],
}

當然,真正的實現肯定不是這樣的。盡管sliceHeader對程序員是不可見的,并且指針和指向的元素類型有關,但是上面的代碼展示了實現的正確思路。

之前我們對一個數據進行了切片操作,其實我們也可以對一個切片進行切片,比如下面這樣

slice2 := slice[5:10]

和之前一樣,這行代碼創建了一個新的切片,指向原來切片的5到9(閉)之間的元素,也就是說指向了buffer數組的105到109之間的元素。slice2sliceHeader結構就像下面這樣

slice := sliceHeader{
    Length:         5,
    ZerothElement       &buffer[105],
}

我們也可以執行reslice操作,即對一個切片進行切片操作,并把返回值傳給之前的切片

slice = slice[5:10]

這時slice就和slice2的結構一樣了。reslice操作對截斷一個切片是很有用的,比如下面的代碼中截掉了第一個和最后一個元素

slice = slice[1:len(slice)-1]

你能經常聽到有經驗的Go程序員談論slice header,因為這就是切片變量存儲的形式。舉個例子,當你調用一個傳入一個切片作為參數的函數,比如bytes.IndexRune,實際上傳入的就是一個slice header

slashPos := bytes.IndexRune(slice, '/')

練習:寫出經過上面操作后slice變量的sliceHeader

slice := sliceHeader{
    Length:         3,
    ZerothElement       &buffer[106],
}

將切片作為參數傳入

有必要知道,即使切片包含了一個指針,它本身也是有值的,它是一個包含一個指針和一個長度數值的結構體實例,并不是一個指向這個結構體實例的指針。

在上面的例子中,indexRune函數傳入了一個slice header的拷貝。這很重要。

考慮下面的函數。

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

函數功能就是遍歷切片中的元素,每個元素加1.

實際跑跑看:

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

// before [0 1 2 3 4 5 6 7 8 9]
// after [1 2 3 4 5 6 7 8 9 10]

盡管slice header是通過傳值的方式傳給函數,但是因為header中包含了指向某個數組的指針,所以原始的header和作為參數傳入函數header的拷貝其實描述的都是同一個數組。因此當函數返回時,可以通過原始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))
}

// Before: len(slice) = 50
// After:  len(slice) = 50
// After:  len(newSlice) = 49

我們可以看到切片指向的內容能夠被函數改變,而切片的header不能被函數改變。slice變量中長度屬性不會被函數修改,因為傳入函數的是切片header的一份拷貝,并不是header本身。因此。如果想寫一個函數修改header,就必須將修改后的header作為結果返回。在上面的例子中,slice變量本身并沒有被改變,但是返回的切片有了新的長度,這個返回存在了newSlice中。

指向切片的指針

另外一種可以在函數中改變切片header的方式就是傳入指向切片的指針。下面是一個例子。

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

// Before: len(slice) = 50
// After:  len(slice) = 49

上面的例子看起來有點傻,特別是增加了一個臨時變量來改變切片的長度。處理切片指針是很常見的情況,通常,在修改切片的函數中,都會使用一個指針接收器。

假設我們想實現一個方法,截斷切片中最后一個'/'及其后面的元素,可以這樣寫

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")
    pathName.TruncateAtFinalSlash()
    fmt.Println("%s\n", pathName)
}

// /usr/bin

練習:將接收器的類型由指針修改為值,然后再跑一遍
修改后的函數為

func (p path) TruncateAtFinalSlash() {
    i := bytes.LastIndex(p, []byte("/"))
    if i >= 0 {
        p = p[0:i]
    }
}

// /usr/bin/tso
// 因為傳入的是pathName的拷貝,main函數中的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.Println("%s\n", pathName)
}

// /USR/BIN/TSO

練習: 修改ToUpper方法,使用指針接收器,看看結果會不會有變化

func (p *path) ToUpper() {
    for i, b := range *p {
        if 'a' <= b && b <= 'z' {
            (*p)[i] = b + 'A' - 'a'
        }
    }
}

// /USR/BIN/TSO
結果沒有變化

容量

下面這個函數每次都會為一個切片添加一個元素。

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

// [0]
// [0 1]
// [0 1 2]
// [0 1 2 3]
// [0 1 2 3 4]
// [0 1 2 3 4 5]
// [0 1 2 3 4 5 6]
// [0 1 2 3 4 5 6 7]
// [0 1 2 3 4 5 6 7 8]
// [0 1 2 3 4 5 6 7 8 9]
// panic: runtime error: slice bounds out of range

// goroutine 1 [running]:
// panic(0x1022c0, 0x1040a018)
//  /usr/local/go/src/runtime/panic.go:500 +0x720
// main.main()
//  /tmp/sandbox219409371/main.go:27 +0x1e0

現在該聊一聊slice header的第三個屬性--容量了。除了數組指針和長度,slice header也存儲了容量。

type sliceHeader struct {
    Length          int
    Capacity        int
    ZerothElement   *byte
}

Capacity屬性記錄了切片指向的數組實際占有的空間,它是Length的最大值。如果切片的長度超過了容量,會導致程序的崩潰。

在執行下面的語句后

slice := iBuffer[0:0]

slice的header結構如下

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,容量為15的切片。

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

// len: 10, cap: 15

下面的代碼會讓slice的容量加倍,但是長度不變

slice := make([]int, 10 ,15)
fmt.Println("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.Println("len: %d, cap: %d", len(slice), cap(slice))

// len: 10, cap: 15
// len: 10, cap: 30

當創建切片時,經常需要長度和容量一致,在這種情況下,可以不傳入容量,容量值默認為長度值。

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來進行移位操作。下面是一個使用copy來在切片中間插入一個元素的例子。

// 在指定的index處插入一個值
// 這個index必須在容量范圍內
func Insert(slice []int, index, value int) []int {
    slice = slice[0 : len(slice)+1]
    copy(slice[index+1:], slice[index:])
    slice[index] = value
    return slice
}

上面的函數中有一些需要注意的。首先,它返回了一個長度被改變的切片;其次,上面例子中使用了一個簡寫。表達式

slice[i:]

和表達式

slice[i:len(slice)]

產生的效果是一樣的。同樣的,也可以把冒號左邊的值留空,它默認為0。
所以slice[:]就是他本身。

現在將Insert函數跑起來

slice := make([]int, 10, 20)
for i := range slice {
    slice[i] = i
}
fmt.Println(slice)
slice = Insert(slice, 5, 99)
fmt.Println(slice)

// [0 1 2 3 4 5 6 7 8 9]
// [0 1 2 3 4 99 5 6 7 8 9]

Append

上面的例子中,我們寫一個Extend函數將一個元素添加到切片。這個函數是有bug的,當切片的容量太小時,程序會崩潰掉(Insert函數也有同樣的問題)。現在來寫一個更加健壯的函數來實現向[]int類型的切片添加元素的功能

func Extend(slice []int, element int) []int {
    n := len(slice)
    if n == cap(slice) {
        newSlice := make([]int, len(slice), 2*len(slice)+1)
        copy(newSlice, slice)
        slice = newSlice
    }
    slice = slice[0 : n+1]
    slice[n] = element
    return slice
}

上面的函數重新分配了一個數組,所以必須將新生成切片返回。下面的代碼會調用Extend函數

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

// len=1 cap=5 slice=[0]
// address of 0th element: 0x10432200
// len=2 cap=5 slice=[0 1]
// address of 0th element: 0x10432200
// len=3 cap=5 slice=[0 1 2]
// address of 0th element: 0x10432200
// len=4 cap=5 slice=[0 1 2 3]
// address of 0th element: 0x10432200
// len=5 cap=5 slice=[0 1 2 3 4]
// address of 0th element: 0x10432200
// len=6 cap=11 slice=[0 1 2 3 4 5]
// address of 0th element: 0x10436120
// len=7 cap=11 slice=[0 1 2 3 4 5 6]
// address of 0th element: 0x10436120
// len=8 cap=11 slice=[0 1 2 3 4 5 6 7]
// address of 0th element: 0x10436120
// len=9 cap=11 slice=[0 1 2 3 4 5 6 7 8]
// address of 0th element: 0x10436120
// len=10 cap=11 slice=[0 1 2 3 4 5 6 7 8 9]
// address of 0th element: 0x10436120

可以看到,在初始的容量5被占滿后,新切片的容量,和指向的第一個元素的地址都變化了。

繼承上面Extend函數的思路,我們甚至可以實現一個方法來在切片添加多個元素。要實現這樣的功能,需要用到Go語言講參數列表轉換為切片的特性。也就是Go語言的可變參數特性。

新的函數叫做Append。在第一個版本中,我們直接多次調用Extend來實現相應的功能。Append函數的聲明如下:

func Append(slice []int, items ...int) []int

實現如下

func Append(slice []int, items ...int) []int {
    for _, item := range items {
        slice = Extend(slice, item)
    }
}

實際跑一跑

slice := []int{0, 1, 2, 3, 4}
fmt.Println(slice)
slice = Append(slice, 5, 6, 7, 8)
fmt.Println(slice)

// [0 1 2 3 4]
// [0 1 2 3 4 5 6 7 8]

Append函數還有另外一個有意思的特性。我們不僅可以追加單個元素,還能夠追加另外一個切片。

slice1 := []int{0, 1, 2, 3, 4}
slice2 := []int{55, 66, 77}
fmt.Println(slice1)
slice1 = Append(slice1, slice2...)
fmt.Println(slice1)

// [0 1 2 3 4]
// [0 1 2 3 4 55 66 77]

當然,可以在不調用Extend的情況下,實現Append

func Append(slice []int, elements ...int) []int {
    n := len(slice)
    total := len(slice) + len(elements)
    if total > cap(slice) {
        newSize := total*3/2 + 1
        newSlice := make([]int, total, newSize)
        copy(newSlice, slice)
        slice = newSlice
    }
    slice = slice[:total]
    copy(slice[n:], elements)
    return slice
}

注意新版的Append調用了兩次copy,第一次將舊切片的內容復制到新切片,第二次將要加入的元素復制到新切片。

slice1 := []int{0, 1, 2, 3, 4}
slice2 := []int{55, 66, 77}
fmt.Println(slice1)
slice1 = Append(slice1, slice2...)
fmt.Println(slice1)

// [0 1 2 3 4]
// [0 1 2 3 4 55 66 77]

內置的Append函數

現在終于談到內置的Append函數了。內置的Append和上面的示例實現一樣的功能,并且對任何類型都適用。

要記住的是,在調用Append后切片的header會改變,所以需要保存返回的切片。事實上,如果調用了Append而沒有保存結果的話,編譯的時候就會報錯。

下面是一些使用的例子

slice := []int{1, 2, 3}
slice2 := []int{55, 66, 77}
fmt.Println("Start slice: ", slice)
fmt.Println("Start slice2: ", slice2)

// Start slice: [1, 2, 3]
// Start slice2: [55, 66, 77]

slice = append(slice, 4)
fmt.Println("Add one item:", slice)

// Add one item: [1, 2, 3, 4]

slice = append(slice, slice2...)
fmt.Println("Add one slice:", slice)

// Add one slice: [1, 2, 3, 4, 55, 66, 77]

slice3 := append([]int(nil), slice...)
fmt.Println("Copy a slice:", slice3)

// Copy a slice: [1, 2, 3, 4, 55, 66, 77]

fmt.Println("Before append to self:", slice)
slice = append(slice, slice...)
fmt.Println("After append to self:", slice)

// After append to self: [1 2 3 4 55 66 77 1 2 3 4 55 66 77]

Nil

現在來看值為nil的切片。很自然的,nil是slice header的零值

sliceHeader{
    Length:     0,
    Capacity:       0,
    ZerothElement: nil,
}

或者

sliceHeader{}

元素指針也是nil

由數組array[0:0]創建的切片,長度為0(甚至容量也是0),但是元素指針不是nil,因此它不是nil切片,容量可以擴大。而值為nil的切片容量不可能擴大,因為它沒有指向任何數組元素。

這也就是說,nil切片在功能上等同于零長度的切片,即使它沒有指向任何數組。它的長度為0,可以被通過重新分配來擴展。

字符串

在了解了切片的基礎上,我們來聊聊字符串。
字符串實際上非常簡單:它是只讀的切片,類型為byte,并且有著語法層面上的一些特性。

因為字符串是只讀的,不能被修改,所以沒必要考慮容量。

可以通過索引的方式訪問其中的元素

slash := "/usr/ken"[0]
// /

可以通過切片來獲取子串

usr := "/usr/ken"[0:4]
// /user

可以通過byte切片來創建一個字符串

str := string(slice)

或者通過字符串來創建一個切片

slice += []byte(usr)

對使用者來說,字符串對應的數組是不可見的,只能操作字符串來訪問其中的元素。這意味著,由字符串轉切片或者由切片轉字符串,必須創建一份數組的拷貝。當然,Go語言已經處理好了這一切,使用者不用再操心。在轉換完成后,修改切片指向的數組不會影響到原始的字符串。

使用類似切片的方式來構建字符串又一個很明顯的好處,就是創建子字符串的操作非常高效。并且由于字符串是只讀的,字符串和子串可以安全地共享共同的數組。

結語

理解切片的實現方式,對理解切片是如何工作是非常有幫助的。當理解了切片的工作方式,切片可以在使用者手里變的簡單又高效。

“本譯文僅供個人研習、欣賞語言之用,謝絕任何轉載及用于任何商業用途。本譯文所涉法律后果均由本人承擔。本人同意簡書平臺在接獲有關著作權人的通知后,刪除文章。”

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • Slice模型 切片(Slice)可以看作是對數組的一種包裝形式.也就是說,切片的實現還是數組.讓我們從創建將起:...
    ywhu閱讀 1,467評論 0 1
  • 切片(slice)是 Golang 中一種比較特殊的數據結構,這種數據結構更便于使用和管理數據集合。切片是圍繞動態...
    小孩真笨閱讀 1,122評論 0 1
  • 線性結構是計算機最常用的數據結構之一。無論是數組(arrary)還是鏈表(list),在編程中不可或缺。golan...
    _二少爺閱讀 6,646評論 5 13
  • 切片(slice)是 Golang 中一種比較特殊的數據結構,這種數據結構更便于使用和管理數據集合。切片是圍繞動態...
    51reboot閱讀 28,687評論 2 10
  • 一大早被外面的咒罵聲驚醒,看一下表5:58分。可能是昨晚喝了太多的乳酸菌飲料,扁桃體有些發炎,嗓子疼得直冒火。起來...
    落肥肥閱讀 493評論 0 0