Go 切片詳解(理解是關鍵)

Dennis_Ritchie發表於2019-12-18

前言

通過上一篇博文,我們學習了陣列的使用,但是陣列有一個致命的缺陷,那就是大小固定,這個特性並不能滿足我們平時的開發需求,所以Go的切片由此誕生。

切片的記憶體分佈是連續的,所以你可以把切片當做一個大小不固定的陣列。

切片有三個欄位的資料結構,這些資料結構包含Go 語言需要操作底層陣列的後設資料,這 3 個欄位分別是指向底層陣列的指標、切片訪問的元素的個數(即長度)和切片允許增長到的元素個數(即容量)。後面會進一步講解長度和容量的區別。
picture alt

建立和初始化切片

切片的建立有多種方式,下面我們一一來講解。

使用make建立切片
//建立了一個長度和容量都為5的切片
slice1:=make[[]string,5]
//建立了一個長度5,容量為10的切片
slice2:=make[[]string,5,10]

需要說明的是,切片對應的底層陣列的大小為指定的容量,這個一定要謹記,比如對於上面的例子,我們指定了slice2的容量為10,那麼slice2對應的底層陣列的大小就是10。
上面雖然建立的切片對應底層陣列的大小為10,但是你不能訪問索引值5以後的元素,比如,如果你執行以下程式碼,你就會發現:

func main() {
    slice := make([]int, 4, 10)
    fmt.Println(slice[2])
    fmt.Println(slice[6])
}

執行結果如下:

0
panic: runtime error: index out of range

goroutine 1 [running]:
main.main()
    E:/go-source/go-arr/main.go:19 +0x8d

Process finished with exit code 2
通過切片字面量來建立切片
func main() {
    slice := []int{1, 2, 4, 4}
}

建立陣列和建立切片非常相似,如果你在[]指定了值,那麼建立的是一個陣列,反之就是一個切片

切面字面量也可以指定切片的大小和容量,如下所示:

func main() {
    slice := []int{99: 100}
}

上面建立的切片的大小和容量都為100,並且初始化第100個元素的值為100,只是在這種情況下,容量和長度是相等的。

建立空切片
func main() {
    slice1 := []int{}
    slice2 := make([]int, 0)
}

空切片在底層陣列包含0 個元素,也沒有分配任何儲存空間。想表示空集合時空切片很有用。
picture alt

切片的使用

切片的使用和陣列是一模一樣的:

func main() {
    slice1 := []int{1,2,3,4}
    fmt.Println(slice1[1])
}
切片建立切片

切片之所以稱為切片,是因為它只是對應底層陣列的一部分,看如下所示程式碼:

func main() {
    slice := []int{10, 20, 30, 40, 50}
    newSlice := slice[1:3]
}

為了說明上面的程式碼,我們看下面的這張圖:
picture alt

第一個切片slice 能夠看到底層陣列全部5 個元素的容量,不過之後的newSlice 就看不到。對於newSlice,底層陣列的容量只有4 個元素。newSlice 無法訪問到它所指向的底層陣列的第一個元素之前的部分。所以,對newSlice 來說,之前的那些元素就是不存在的。

需要記住的是,現在兩個切片共享同一個底層陣列。如果一個切片修改了該底層陣列的共享部分,另一個切片也能感知到,執行下面的程式碼:

func main() {
    slice := []int{10, 20, 30, 40, 50}
    newSlice := slice[1:3]

    slice[1] = 200
    fmt.Println(newSlice[0])
}

執行結果如下:

200

切片只能訪問到其長度內的元素。試圖訪問超出其長度的元素將會導致語言執行時異常,比如對上面的newSlice,他只能訪問索引為1和2的元素(不包括3),比如:

func main() {
    slice := []int{10, 20, 30, 40, 50}
    newSlice := slice[1:3]

    fmt.Println(newSlice[3])
}

執行程式碼,控制檯會報錯:

panic: runtime error: index out of range

goroutine 1 [running]:
main.main()
    E:/go-source/go-arr/main.go:20 +0x11
子切片的容量

我們知道切片可以再生出切片,那麼子切片的容量為多大呢?我們來測試一下:

func main() {
    slice := make([]int, 2, 10)
    slice1 := slice[1:2]
    fmt.Println(cap(slice1))
}

控制檯列印結果為:

9
9

從結果我們可以推測,子切片的容量為底層陣列的長度減去切片在底層陣列的開始偏移量,比如在上面的例子中,slice1的偏移值為1,底層陣列的大小為10,所以兩者相減,得到結果9。

向切片中追加元素

go提供了append方法用於向切片中追加元素,如下所示:

func main() {
    slice := make([]int, 2, 10)
    slice1 := slice[1:2]
    slice2 := append(slice1, 1)
    slice2[0] = 10001
    fmt.Println(slice)
    fmt.Println(cap(slice2))
}

輸出結果如下:

[0 10001]
9

此時slice,slice1,slice2共享底層陣列,所以只要一個切片改變了某一個索引的值,會影響到所有的切片,還有一點值得注意,就是slice2的容量為9,記住這個值。

為了說明問題,我把例子改為如下所示程式碼:

func main() {
    slice := make([]int, 2, 10)
    slice1 := slice[1:2]
    slice2 := append(slice1, 1)
    slice2 = append(slice2, 1)
    slice2 = append(slice2, 1)
    slice2 = append(slice2, 1)
    slice2 = append(slice2, 1)
    slice2 = append(slice2, 1)
    slice2 = append(slice2, 1)
    slice2 = append(slice2, 1)
    slice2 = append(slice2, 1)
    slice2 = append(slice2, 1)
    slice2 = append(slice2, 1)
    slice2 = append(slice2, 1)
    slice2[0] = 10001
    fmt.Println(slice)
    fmt.Println(slice1)
    fmt.Println(cap(slice2))
}

此時我們再次列印結果,神奇的事情出現了:

[0 0]
[0]
18

雖然我們改變0位置的值,但是並沒有影響到原來的slice和slice1,這是為啥呢?我們知道原始的slice2對應的底層陣列的容量為9,經過我們一系列的append操作,原始的底層陣列已經無法容納更多的元素了,此時Go會分配另外一塊記憶體,把原始切片從位置1開始的記憶體複製到新的記憶體地址中,也就是說現在的slice2切片對應的底層陣列和slice切片對應的底層陣列完全不是在同一個記憶體地址,所以當你此時更改slice2中的元素時,對slice已經來說,一點兒關係都沒有。

另外根據上面的列印結果,你也應該猜到了,當切片容量不足的時候,Go會以原始切片容量的2倍建立新的切片,在我們的例子中2*9=18,就是這麼粗暴。

如何建立子切片時指定容量

在前面的例子中,我們建立子切片的時候,沒有指定子切片的容量,所以子切片的容量和我們上面討論的計運算元切片的容量方法相等,那麼我們如何手動指定子切片的容量呢?

在這裡我們借用《Go實戰》中的一個例子:

func main() {
    source := []string{"Apple", "Orange", "Plum", "Banana", "Grape"}
    slice := source[2:3:4]
    fmt.Println(cap(slice))
}

如果你仔細看的話,上面的子切片的生成方式和普通的切片有所不同,[]裡面有三個部分組成,,第一個值表示新切片開始元素的索引位置,這個例子中是2。第二個值表示開始的索引位置(2)加上希望包括的元素的個數(1),2+1 的結果是3,所以第二個值就是3。為了設定容量,從索引位置2 開始,加上希望容量中包含的元素的個數(2),就得到了第三個值4。所以這個新的切片slice的長度為1,容量為2。還有一點大家一定要記住,你指定的容量不能比原先的容量,這裡就是source的容量大,加入我們這樣設定的話:

func main() {
    source := []string{"Apple", "Orange", "Plum", "Banana", "Grape"}
    slice := source[2:3:10]
    fmt.Println(cap(slice))
}

執行結果如下,報錯了,哈哈:

panic: runtime error: slice bounds out of range [::10] with capacity 5

goroutine 1 [running]:
main.main()
    E:/learn-go/slice/main.go:7 +0x1d

迭代切片

關於如何迭代切片,我們可以使用range配置來使用,如下:

func main() {
    slice:=[]int{1,2,4,6}
    for _, value:=range slice{
        fmt.Println(value)
    }
}

關於迭代切片,大家有一點需要注意,就以上面的例子為例,value只是slice中元素的副本,為啥呢?我們來驗證這一點:

func main() {
    slice:=[]int{1,2,4,6}
    for index, value:=range slice{
        fmt.Printf("value[%d],indexAddr:[%X],valueAddr:[%X],sliceAddr:[%X]\n",value,&index,&value,&slice[index])
    }
}

控制檯列印結果如下:

value[1],indexAddr:[C00000A0B8],valueAddr:[C00000A0D0],sliceAddr:[C000010380]
value[2],indexAddr:[C00000A0B8],valueAddr:[C00000A0D0],sliceAddr:[C000010388]
value[4],indexAddr:[C00000A0B8],valueAddr:[C00000A0D0],sliceAddr:[C000010390]
value[6],indexAddr:[C00000A0B8],valueAddr:[C00000A0D0],sliceAddr:[C000010398]

從上面的結果可以看到index和value的地址始終是不變的,所以它們始終是同一個變數,只是變數引用地址的內容發生了變化,從而驗證迭代的時候,只能是切片元素的副本,最後看看sliceAddr代表的地址相隔8個位元組,因為在64位系統上,每一個int型別的大小為8個位元組。

函式間傳遞切片

函式間傳遞切片,也是以值的方式傳遞的,但是你還記得這篇博文開頭給出的切片的佈局麼?
picture alt
切片由三個部分組成,包括指向底層陣列的指標,當前切片的長度,當前切片的容量,所以切片本身並不大,我們來測試一個切片的大小:

func main() {
    slice:=[]int{1,2,4,6}
    fmt.Println(unsafe.Sizeof(slice))
}

測試結果為:

24

也就是這個slice切片的大小為24位元組,所以當切片作為引數傳遞的時候,幾乎沒有效能開銷,還有很重要的一點,引數生成的副本的地址指標和原始切片的地址指標是一樣的,因此,如果你在函式裡面修改了切片,那麼會影響到原始的切片,我們來驗證這點:

func main() {
    slice:=[]int{1,2,4,6}
    handleSlice(slice)
    fmt.Println(slice)
}

列印結果:

[100 2 4 6]

總結

這篇博文詳細的講敘了切片的使用和要點,希望大家謹記,當然了,最重要的是一定要理解。

如果有不懂的地方,可以加我的qq:1174332406,或者是微信:itshardjs

相關文章