你不知的 Go 之 slice

簡介

切片(slice)是 Go 語言提供的一種數據結構,使用很是簡單、便捷。可是因爲實現層面的緣由,切片也常常會產生讓人疑惑的結果。掌握切片的底層結構和原理,能夠避免不少常見的使用誤區。git

底層結構

切片結構定義在源碼runtime包下的 slice.go 文件中:github

// src/runtime/slice.go
type slice struct {
  array unsafe.Pointer
  len int
  cap int
}
  • array:一個指針,指向底層存儲數據的數組
  • len:切片的長度,在代碼中咱們可使用len()函數獲取這個值
  • cap:切片的容量,即在不擴容的狀況下,最多能容納多少元素。在代碼中咱們可使用cap()函數獲取這個值

咱們能夠經過下面的代碼輸出切片的底層結構:golang

type slice struct {
  array unsafe.Pointer
  len   int
  cap   int
}

func printSlice() {
  s := make([]uint32, 1, 10)
  fmt.Printf("%#v\n", *(*slice)(unsafe.Pointer(&s)))
}

func main() {
  printSlice()
}

運行輸出:編程

main.slice{array:(unsafe.Pointer)(0xc0000d6030), len:1, cap:10}

這裏注意一個細節,因爲runtime.slice結構是非導出的,咱們不能直接使用。因此我在代碼中手動定義了一個slice結構體,字段與runtime.slice結構相同。數組

咱們結合切片的底層結構,先回顧一下切片的基礎知識,而後再逐一看看切片的常見問題。微信

基礎知識

建立切片

建立切片有 4 種方式:數據結構

  1. var

var聲明切片類型的變量,這時切片值爲nilapp

var s []uint32

這種方式建立的切片,array字段爲空指針,lencap字段都等於 0。函數

  1. 切片字面量

使用切片字面量將全部元素都列舉出來,這時切片長度和容量都等於指定元素的個數。學習

s := []uint32{1, 2, 3}

建立以後s的底層結構以下:

lencap字段都等於 3。

  1. make

使用make建立,能夠指定長度和容量。格式爲make([]type, len[, cap]),能夠只指定長度,也能夠長度容量同時指定:

s1 := make([]uint32)
s2 := make([]uint32, 1)
s3 := make([]uint32, 1, 10)
  1. 切片操做符

使用切片操做符能夠從現有的切片或數組中切取一部分,建立一個新的切片。切片操做符格式爲[low:high],例如:

var arr [10]uint32
s1 := arr[0:5]
s2 := arr[:5]
s3 := arr[5:]
s4 := arr[:]

區間是左開右閉的,即[low, high),包括索引low,不包括high。切取生成的切片長度爲high-low

另外lowhigh都有默認值。low默認爲 0,high默認爲原切片或數組的長度。它們均可以省略,省略時,至關於取默認值。

使用這種方式建立的切片底層共享相同的數據空間,在進行切片操做時可能會形成數據覆蓋,要格外當心。

添加元素

可使用append()函數向切片中添加元素,能夠一次添加 0 個或多個元素。若是剩餘空間(即cap-len)足夠存放元素則直接將元素添加到後面,而後增長字段len的值便可。反之,則須要擴容,分配一個更大的數組空間,將舊數組中的元素複製過去,再執行添加操做。

package main

import "fmt"

func main() {
  s := make([]uint32, 0, 4)

  s = append(s, 1, 2, 3)
  fmt.Println(len(s), cap(s)) // 3 4

  s = append(s, 4, 5, 6)
  fmt.Println(len(s), cap(s)) // 6 8
}

你不知道的 slice

  1. 空切片等於nil嗎?

下面代碼的輸出什麼?

func main() {
  var s1 []uint32
  s2 := make([]uint32, 0)

  fmt.Println(s1 == nil)
  fmt.Println(s2 == nil)
  fmt.Println("nil slice:", len(s1), cap(s1))
  fmt.Println("cap slice:", len(s2), cap(s2))
}

分析:

首先s1s2的長度和容量都爲 0,這很好理解。比較切片與nil是否相等,實際上要檢查slice結構中的array字段是不是空指針。顯然s1 == nil返回trues2 == nil返回false。儘管s2長度爲 0,可是make()爲它分配了空間。因此,通常定義長度爲 0 的切片使用var的形式

  1. 傳值仍是傳引用?

下面代碼的輸出什麼?

func main() {
  s1 := []uint32{1, 2, 3}
  s2 := append(s1, 4)

  fmt.Println(s1)
  fmt.Println(s2)
}

分析:

爲何append()函數要有返回值?由於咱們將切片傳遞給append()時,其實傳入的是runtime.slice結構。這個結構是按值傳遞的,因此函數內部對array/len/cap這幾個字段的修改都不影響外面的切片結構。上面代碼中,執行append()以後s1lencap保持不變,故輸出爲:

[1 2 3]
[1 2 3 4]

因此咱們調用append()要寫成s = append(s, elem)這種形式,將返回值賦值給原切片,從而覆寫array/len/cap這幾個字段的值。

初學者還可能會犯忽略append()返回值的錯誤:

append(s, elem)

這就更加大錯特錯了。添加的元素將會丟失,覺得函數外切片的內部字段都沒有變化。

咱們能夠看到,雖然說切片是按引用傳遞的,可是實際上傳遞的是結構runtime.slice的值。只是對現有元素的修改會反應到函數外,由於底層數組空間是共用的。

  1. 切片的擴容策略

下面代碼的輸出是什麼?

func main() {
  var s1 []uint32
  s1 = append(s1, 1, 2, 3)
  s2 := append(s1, 4)
  fmt.Println(&s1[0] == &s2[0])
}

這涉及到切片的擴容策略。擴容時,若:

  • 當前容量小於 1024,則將容量擴大爲原來的 2 倍;
  • 當前容量大於等於 1024,則將容量逐次增長原來的 0.25 倍,直到知足所需容量。

我翻看了 Go1.16 版本runtime/slice.go中擴容相關的源碼,在執行上面規則後還會根據切片元素的大小和計算機位數進行相應的調整。整個過程比較複雜,感興趣能夠自行去研究。

咱們只須要知道一開始容量較小,擴大爲 2 倍,下降後續因添加元素致使擴容的頻次。容量擴張到必定程度時,再按照 2 倍來擴容會形成比較大的浪費。

上面例子中執行s1 = append(s1, 1, 2, 3)後,容量會擴大爲 4。再執行s2 := append(s1, 4)因爲有足夠的空間,s2底層的數組不會改變。因此s1s2第一個元素的地址相同。

  1. 切片操做符能夠切取字符串

切片操做符能夠切取字符串,可是與切取切片和數組不一樣。切取字符串返回的是字符串,而非切片。由於字符串是不可變的,若是返回切片。而切片和字符串共享底層數據,就能夠經過切片修改字符串了。

func main() {
  str := "hello, world"
  fmt.Println(str[:5])
}

輸出 hello。

  1. 切片底層數據共享

下面代碼的輸出是什麼?

func main() {
  array := [10]uint32{1, 2, 3, 4, 5}
  s1 := array[:5]

  s2 := s1[5:10]
  fmt.Println(s2)

  s1 = append(s1, 6)
  fmt.Println(s1)
  fmt.Println(s2)
}

分析:

首先注意到s2 := s1[5:10]上界 10 已經大於切片s1的長度了。要記住,使用切片操做符切取切片時,上界是切片的容量,而非長度。這時兩個切片的底層結構有重疊,以下圖:

這時輸出s2爲:

[0, 0, 0, 0, 0]

而後向切片s1中添加元素 6,這時結構以下圖,其中切片s1s2共享元素 6:

這時輸出的s1s2爲:

[1, 2, 3, 4, 5, 6]
[6, 0, 0, 0, 0]

能夠看到因爲切片底層數據共享可能形成修改一個切片會致使其餘切片也跟着修改。這有時會形成難以調試的 BUG。爲了必定程度上緩解這個問題,Go 1.2 版本中提供了一個擴展切片操做符:[low:high:max],用來限制新切片的容量。使用這種方式產生的切片容量爲max-low

func main() {
  array := [10]uint32{1, 2, 3, 4, 5}
  s1 := array[:5:5]

  s2 := array[5:10:10]
  fmt.Println(s2)

  s1 = append(s1, 6)
  fmt.Println(s1)
  fmt.Println(s2)
}

執行s1 := array[:5:5]咱們限定了s1的容量爲 5,這時結構以下圖所示:

執行s1 = append(s1, 6)時,發現沒有空閒容量了(由於len == cap == 5),從新建立一個底層數組再執行添加。這時結構以下圖,s1s2互不干擾:

總結

瞭解了切片的底層數據結構,知道了切片傳遞的是結構runtime.slice的值,咱們就能解決 90% 以上的切片問題。再結合圖形能夠很直觀的看到切片底層數據是如何操做的。

這個系列的名字是我仿造《你不知道的 JavaScript》起的😀。

參考

  1. 《Go 專家編程》,豆瓣連接:https://book.douban.com/subject/35144587/
  2. 你不知道的Go GitHub:https://github.com/darjun/you-dont-know-go

個人博客:https://darjun.github.io

歡迎關注個人微信公衆號【GoUpUp】,共同窗習,一塊兒進步~

相關文章
相關標籤/搜索